From 7ee008e150bc5bcf76082d726f719ee0fdfda982 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Wed, 11 Feb 2026 02:37:39 -0600 Subject: Fluorine Manager: full Linux port of Mod Organizer 2 Complete native Linux port with FUSE-based virtual filesystem, Proton/umu-run integration, and Flatpak packaging. Key features: - FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak) - Proton/GE-Proton/umu-run launcher with env var forwarding - Flatpak support (sandbox-aware VFS, NXM handler, umu-run) - Wine prefix management UI - Case-insensitive path resolution for Linux filesystems - QSettings-safe INI handling (avoids Bethesda INI corruption) - Portable instance support with auto-generated launcher scripts Co-Authored-By: Claude Opus 4.6 --- src/fluorine-manager | 4 + src/src/CMakeLists.txt | 266 + src/src/aboutdialog.cpp | 126 + src/src/aboutdialog.h | 82 + src/src/aboutdialog.ui | 727 ++ src/src/activatemodsdialog.cpp | 96 + src/src/activatemodsdialog.h | 78 + src/src/activatemodsdialog.ui | 97 + src/src/apiuseraccount.cpp | 93 + src/src/apiuseraccount.h | 140 + src/src/app_icon.rc | 1 + src/src/archivefiletree.cpp | 280 + src/src/archivefiletree.h | 71 + src/src/bbcode.cpp | 318 + src/src/bbcode.h | 39 + src/src/browserdialog.cpp | 289 + src/src/browserdialog.h | 121 + src/src/browserdialog.ui | 314 + src/src/browserview.cpp | 77 + src/src/browserview.h | 76 + src/src/categories.cpp | 519 + src/src/categories.h | 275 + src/src/categoriesdialog.cpp | 399 + src/src/categoriesdialog.h | 82 + src/src/categoriesdialog.ui | 241 + src/src/categoriestable.cpp | 77 + src/src/categoriestable.h | 37 + src/src/categoryimportdialog.cpp | 75 + src/src/categoryimportdialog.h | 44 + src/src/categoryimportdialog.ui | 144 + src/src/colortable.cpp | 319 + src/src/colortable.h | 37 + src/src/commandline.cpp | 1156 +++ src/src/commandline.h | 416 + src/src/copyeventfilter.cpp | 49 + src/src/copyeventfilter.h | 41 + src/src/createinstancedialog.cpp | 538 + src/src/createinstancedialog.h | 237 + src/src/createinstancedialog.ui | 1353 +++ src/src/createinstancedialogpages.cpp | 1321 +++ src/src/createinstancedialogpages.h | 631 ++ src/src/credentialsdialog.cpp | 42 + src/src/credentialsdialog.h | 45 + src/src/credentialsdialog.ui | 124 + src/src/csvbuilder.cpp | 254 + src/src/csvbuilder.h | 87 + src/src/datatab.cpp | 195 + src/src/datatab.h | 80 + src/src/directoryrefresher.cpp | 514 + src/src/directoryrefresher.h | 211 + src/src/disableproxyplugindialog.cpp | 34 + src/src/disableproxyplugindialog.h | 44 + src/src/disableproxyplugindialog.ui | 174 + src/src/dlls.manifest.debug.qt6 | 34 + src/src/dlls.manifest.qt6 | 34 + src/src/downloadlist.cpp | 332 + src/src/downloadlist.h | 102 + src/src/downloadlistview.cpp | 511 + src/src/downloadlistview.h | 132 + src/src/downloadmanager.cpp | 2449 +++++ src/src/downloadmanager.h | 647 ++ src/src/downloadmanagerproxy.cpp | 79 + src/src/downloadmanagerproxy.h | 45 + src/src/downloadstab.cpp | 110 + src/src/downloadstab.h | 46 + src/src/editexecutablesdialog.cpp | 905 ++ src/src/editexecutablesdialog.h | 239 + src/src/editexecutablesdialog.ui | 534 + src/src/env.cpp | 1557 +++ src/src/env.h | 313 + src/src/envdump.h | 33 + src/src/envfs.cpp | 722 ++ src/src/envfs.h | 192 + src/src/envmetrics.cpp | 379 + src/src/envmetrics.h | 80 + src/src/envmodule.cpp | 1108 ++ src/src/envmodule.h | 228 + src/src/envsecurity.cpp | 764 ++ src/src/envsecurity.h | 71 + src/src/envshell.cpp | 610 ++ src/src/envshell.h | 115 + src/src/envshortcut.cpp | 460 + src/src/envshortcut.h | 107 + src/src/envwindows.cpp | 305 + src/src/envwindows.h | 111 + src/src/executableslist.cpp | 496 + src/src/executableslist.h | 234 + src/src/executableslistproxy.cpp | 63 + src/src/executableslistproxy.h | 46 + src/src/filedialogmemory.cpp | 83 + src/src/filedialogmemory.h | 51 + src/src/filerenamer.cpp | 204 + src/src/filerenamer.h | 144 + src/src/filetree.cpp | 830 ++ src/src/filetree.h | 76 + src/src/filetreeitem.cpp | 456 + src/src/filetreeitem.h | 257 + src/src/filetreemodel.cpp | 1239 +++ src/src/filetreemodel.h | 178 + src/src/filterlist.cpp | 445 + src/src/filterlist.h | 59 + src/src/fluorineconfig.cpp | 146 + src/src/fluorineconfig.h | 32 + src/src/forcedloaddialog.cpp | 71 + src/src/forcedloaddialog.h | 35 + src/src/forcedloaddialog.ui | 117 + src/src/forcedloaddialogwidget.cpp | 109 + src/src/forcedloaddialogwidget.h | 42 + src/src/forcedloaddialogwidget.ui | 113 + src/src/fuseconnector.cpp | 676 ++ src/src/fuseconnector.h | 92 + src/src/game_features.cpp | 350 + src/src/game_features.h | 117 + src/src/gamefeaturesproxy.cpp | 48 + src/src/gamefeaturesproxy.h | 34 + src/src/genericicondelegate.cpp | 27 + src/src/genericicondelegate.h | 40 + src/src/github.cpp | 204 + src/src/github.h | 108 + src/src/glob_matching.h | 185 + src/src/icondelegate.cpp | 87 + src/src/icondelegate.h | 54 + src/src/iconfetcher.cpp | 165 + src/src/iconfetcher.h | 75 + src/src/installationmanager.cpp | 967 ++ src/src/installationmanager.h | 357 + src/src/instancemanager.cpp | 951 ++ src/src/instancemanager.h | 364 + src/src/instancemanagerdialog.cpp | 736 ++ src/src/instancemanagerdialog.h | 145 + src/src/instancemanagerdialog.ui | 444 + src/src/iuserinterface.h | 32 + src/src/json.h | 179 + src/src/lcdnumber.cpp | 34 + src/src/lcdnumber.h | 35 + src/src/listdialog.cpp | 79 + src/src/listdialog.h | 34 + src/src/listdialog.ui | 103 + src/src/loghighlighter.cpp | 47 + src/src/loghighlighter.h | 43 + src/src/loglist.cpp | 413 + src/src/loglist.h | 91 + src/src/loot.cpp | 943 ++ src/src/loot.h | 144 + src/src/lootdialog.cpp | 294 + src/src/lootdialog.h | 82 + src/src/lootdialog.ui | 230 + src/src/main.cpp | 330 + src/src/mainwindow.cpp | 4053 ++++++++ src/src/mainwindow.h | 495 + src/src/mainwindow.ui | 2054 ++++ src/src/messagedialog.cpp | 112 + src/src/messagedialog.h | 73 + src/src/messagedialog.ui | 94 + src/src/mo_icon.ico | Bin 0 -> 45874 bytes src/src/moapplication.cpp | 868 ++ src/src/moapplication.h | 111 + src/src/modconflicticondelegate.cpp | 148 + src/src/modconflicticondelegate.h | 47 + src/src/modcontenticondelegate.cpp | 60 + src/src/modcontenticondelegate.h | 30 + src/src/modelutils.cpp | 95 + src/src/modelutils.h | 28 + src/src/modflagicondelegate.cpp | 102 + src/src/modflagicondelegate.h | 37 + src/src/modidlineedit.cpp | 20 + src/src/modidlineedit.h | 21 + src/src/modinfo.cpp | 576 ++ src/src/modinfo.h | 1061 ++ src/src/modinfobackup.cpp | 17 + src/src/modinfobackup.h | 57 + src/src/modinfodialog.cpp | 826 ++ src/src/modinfodialog.h | 254 + src/src/modinfodialog.ui | 1452 +++ src/src/modinfodialogcategories.cpp | 138 + src/src/modinfodialogcategories.h | 26 + src/src/modinfodialogconflicts.cpp | 1130 +++ src/src/modinfodialogconflicts.h | 162 + src/src/modinfodialogconflictsmodels.cpp | 311 + src/src/modinfodialogconflictsmodels.h | 115 + src/src/modinfodialogesps.cpp | 371 + src/src/modinfodialogesps.h | 31 + src/src/modinfodialogfiletree.cpp | 563 ++ src/src/modinfodialogfiletree.h | 56 + src/src/modinfodialogfwd.h | 52 + src/src/modinfodialogimages.cpp | 1096 ++ src/src/modinfodialogimages.h | 380 + src/src/modinfodialognexus.cpp | 468 + src/src/modinfodialognexus.h | 74 + src/src/modinfodialogtab.cpp | 263 + src/src/modinfodialogtab.h | 302 + src/src/modinfodialogtextfiles.cpp | 236 + src/src/modinfodialogtextfiles.h | 62 + src/src/modinfoforeign.cpp | 68 + src/src/modinfoforeign.h | 124 + src/src/modinfooverwrite.cpp | 88 + src/src/modinfooverwrite.h | 108 + src/src/modinforegular.cpp | 1043 ++ src/src/modinforegular.h | 529 + src/src/modinfoseparator.cpp | 34 + src/src/modinfoseparator.h | 68 + src/src/modinfowithconflictinfo.cpp | 348 + src/src/modinfowithconflictinfo.h | 177 + src/src/modlist.cpp | 1514 +++ src/src/modlist.h | 422 + src/src/modlistbypriorityproxy.cpp | 416 + src/src/modlistbypriorityproxy.h | 116 + src/src/modlistcontextmenu.cpp | 637 ++ src/src/modlistcontextmenu.h | 115 + src/src/modlistdropinfo.cpp | 124 + src/src/modlistdropinfo.h | 89 + src/src/modlistproxy.cpp | 113 + src/src/modlistproxy.h | 53 + src/src/modlistsortproxy.cpp | 716 ++ src/src/modlistsortproxy.h | 167 + src/src/modlistversiondelegate.cpp | 67 + src/src/modlistversiondelegate.h | 22 + src/src/modlistview.cpp | 1538 +++ src/src/modlistview.h | 352 + src/src/modlistviewactions.cpp | 1496 +++ src/src/modlistviewactions.h | 194 + src/src/moshortcut.cpp | 73 + src/src/moshortcut.h | 52 + src/src/motddialog.cpp | 53 + src/src/motddialog.h | 47 + src/src/motddialog.ui | 52 + src/src/multiprocess.cpp | 137 + src/src/multiprocess.h | 62 + src/src/nexusinterface.cpp | 1311 +++ src/src/nexusinterface.h | 688 ++ src/src/nexusmanualkey.ui | 231 + src/src/noeditdelegate.cpp | 9 + src/src/noeditdelegate.h | 14 + src/src/nxmaccessmanager.cpp | 987 ++ src/src/nxmaccessmanager.h | 285 + src/src/nxmhandler_linux.cpp | 345 + src/src/nxmhandler_linux.h | 52 + src/src/organizer_en.ts | 10084 +++++++++++++++++++ src/src/organizercore.cpp | 2422 +++++ src/src/organizercore.h | 601 ++ src/src/organizerproxy.cpp | 515 + src/src/organizerproxy.h | 167 + src/src/overwriteinfodialog.cpp | 316 + src/src/overwriteinfodialog.h | 150 + src/src/overwriteinfodialog.ui | 100 + src/src/pch.cpp | 1 + src/src/pch.h | 282 + src/src/persistentcookiejar.cpp | 78 + src/src/persistentcookiejar.h | 26 + src/src/plugincontainer.cpp | 1264 +++ src/src/plugincontainer.h | 505 + src/src/pluginlist.cpp | 2104 ++++ src/src/pluginlist.h | 445 + src/src/pluginlistcontextmenu.cpp | 165 + src/src/pluginlistcontextmenu.h | 51 + src/src/pluginlistproxy.cpp | 155 + src/src/pluginlistproxy.h | 62 + src/src/pluginlistsortproxy.cpp | 164 + src/src/pluginlistsortproxy.h | 63 + src/src/pluginlistview.cpp | 420 + src/src/pluginlistview.h | 86 + src/src/previewdialog.cpp | 65 + src/src/previewdialog.h | 40 + src/src/previewdialog.ui | 90 + src/src/previewgenerator.cpp | 78 + src/src/previewgenerator.h | 47 + src/src/problemsdialog.cpp | 118 + src/src/problemsdialog.h | 44 + src/src/problemsdialog.ui | 102 + src/src/processrunner.cpp | 1415 +++ src/src/processrunner.h | 211 + src/src/profile.cpp | 1328 +++ src/src/profile.h | 417 + src/src/profileinputdialog.cpp | 45 + src/src/profileinputdialog.h | 45 + src/src/profileinputdialog.ui | 87 + src/src/profilesdialog.cpp | 426 + src/src/profilesdialog.h | 140 + src/src/profilesdialog.ui | 239 + src/src/protonlauncher.cpp | 514 + src/src/protonlauncher.h | 50 + src/src/proxyutils.h | 50 + src/src/qdirfiletree.cpp | 115 + src/src/qdirfiletree.h | 60 + src/src/qt.conf | 3 + src/src/qtgroupingproxy.cpp | 948 ++ src/src/qtgroupingproxy.h | 153 + src/src/queryoverwritedialog.cpp | 65 + src/src/queryoverwritedialog.h | 64 + src/src/queryoverwritedialog.ui | 141 + src/src/resources.qrc | 117 + src/src/resources/accessories-text-editor.png | Bin 0 -> 574 bytes src/src/resources/application-x-executable.png | Bin 0 -> 1675 bytes src/src/resources/applications-accessories.png | Bin 0 -> 2223 bytes src/src/resources/archive-conflict-loser.png | Bin 0 -> 886 bytes src/src/resources/archive-conflict-mixed.png | Bin 0 -> 969 bytes src/src/resources/archive-conflict-neutral.png | Bin 0 -> 846 bytes src/src/resources/archive-conflict-winner.png | Bin 0 -> 939 bytes src/src/resources/arrange-boxes.png | Bin 0 -> 1634 bytes src/src/resources/badge_1.png | Bin 0 -> 1119 bytes src/src/resources/badge_2.png | Bin 0 -> 1223 bytes src/src/resources/badge_3.png | Bin 0 -> 1346 bytes src/src/resources/badge_4.png | Bin 0 -> 1200 bytes src/src/resources/badge_5.png | Bin 0 -> 1269 bytes src/src/resources/badge_6.png | Bin 0 -> 1244 bytes src/src/resources/badge_7.png | Bin 0 -> 1190 bytes src/src/resources/badge_8.png | Bin 0 -> 1252 bytes src/src/resources/badge_9.png | Bin 0 -> 1259 bytes src/src/resources/badge_more.png | Bin 0 -> 1629 bytes src/src/resources/branch-closed.png | Bin 0 -> 593 bytes src/src/resources/branch-open.png | Bin 0 -> 756 bytes src/src/resources/check.png | Bin 0 -> 395 bytes src/src/resources/combobox-down.png | Bin 0 -> 548 bytes src/src/resources/conflict-hidden.png | Bin 0 -> 231 bytes src/src/resources/conflict-mixed-blue.png | Bin 0 -> 571 bytes src/src/resources/conflict-mixed.png | Bin 0 -> 657 bytes src/src/resources/conflict-overwrite-blue.png | Bin 0 -> 594 bytes src/src/resources/conflict-overwrite.png | Bin 0 -> 683 bytes src/src/resources/conflict-overwritten-blue.png | Bin 0 -> 455 bytes src/src/resources/conflict-overwritten.png | Bin 0 -> 543 bytes src/src/resources/conflict-redundant.png | Bin 0 -> 567 bytes src/src/resources/contact-new.png | Bin 0 -> 1390 bytes src/src/resources/contents/breastplate.png | Bin 0 -> 788 bytes src/src/resources/contents/checkbox-tree.png | Bin 0 -> 532 bytes src/src/resources/contents/config.png | Bin 0 -> 480 bytes src/src/resources/contents/conversation.png | Bin 0 -> 832 bytes src/src/resources/contents/double-quaver.png | Bin 0 -> 640 bytes src/src/resources/contents/empty-chessboard.png | Bin 0 -> 537 bytes src/src/resources/contents/f4-nuclear.png | Bin 0 -> 1733 bytes src/src/resources/contents/facegen.png | Bin 0 -> 246 bytes src/src/resources/contents/feather-and-scroll.png | Bin 0 -> 616 bytes src/src/resources/contents/hand-of-god.png | Bin 0 -> 687 bytes src/src/resources/contents/jigsaw-piece.png | Bin 0 -> 678 bytes src/src/resources/contents/locked-chest.png | Bin 0 -> 801 bytes src/src/resources/contents/lyre.png | Bin 0 -> 783 bytes src/src/resources/contents/media.png | Bin 0 -> 114 bytes src/src/resources/contents/mesh-box.png | Bin 0 -> 165 bytes src/src/resources/contents/tinker.png | Bin 0 -> 742 bytes src/src/resources/contents/usable.png | Bin 0 -> 834 bytes src/src/resources/contents/xedit.png | Bin 0 -> 1796 bytes src/src/resources/dialog-information.png | Bin 0 -> 1204 bytes src/src/resources/dialog-warning.png | Bin 0 -> 1391 bytes src/src/resources/dialog-warning_16.png | Bin 0 -> 603 bytes .../resources/dialog-warning_16.png (warning_16) | Bin 0 -> 603 bytes src/src/resources/document-properties.png | Bin 0 -> 688 bytes src/src/resources/document-save.png | Bin 0 -> 1117 bytes src/src/resources/document-save_32.png | Bin 0 -> 1971 bytes src/src/resources/dynamic-blue-right.png | Bin 0 -> 1236 bytes src/src/resources/edit-clear.png | Bin 0 -> 773 bytes src/src/resources/edit-find-replace.png | Bin 0 -> 1088 bytes src/src/resources/edit-undo.png | Bin 0 -> 1601 bytes src/src/resources/emblem-favorite - 64.png | Bin 0 -> 3710 bytes src/src/resources/emblem-favorite.png | Bin 0 -> 836 bytes src/src/resources/emblem-important.png | Bin 0 -> 717 bytes src/src/resources/emblem-readonly.png | Bin 0 -> 430 bytes src/src/resources/emblem-redundant.png | Bin 0 -> 615 bytes src/src/resources/emblem-unreadable.png | Bin 0 -> 669 bytes src/src/resources/error.png | Bin 0 -> 870 bytes src/src/resources/function.png | Bin 0 -> 2282 bytes src/src/resources/game-warning-16.png | Bin 0 -> 780 bytes src/src/resources/game-warning.png | Bin 0 -> 2528 bytes src/src/resources/go-down.png | Bin 0 -> 937 bytes src/src/resources/go-jump.png | Bin 0 -> 1054 bytes src/src/resources/go-next_16.png | Bin 0 -> 719 bytes src/src/resources/go-previous_16.png | Bin 0 -> 718 bytes src/src/resources/go-up.png | Bin 0 -> 974 bytes src/src/resources/help-browser.png | Bin 0 -> 1359 bytes src/src/resources/help-browser_32.png | Bin 0 -> 2231 bytes src/src/resources/icon-favorite.png | Bin 0 -> 2402 bytes src/src/resources/icons8-checked-checkbox.png | Bin 0 -> 886 bytes .../resources/icons8-indeterminate-checkbox.png | Bin 0 -> 674 bytes src/src/resources/icons8-unchecked-checkbox.png | Bin 0 -> 545 bytes src/src/resources/internet-web-browser.png | Bin 0 -> 2251 bytes src/src/resources/list-add.png | Bin 0 -> 386 bytes src/src/resources/list-remove.png | Bin 0 -> 252 bytes src/src/resources/log-debug.png | Bin 0 -> 1039 bytes src/src/resources/mail-attachment.png | Bin 0 -> 649 bytes src/src/resources/markdown.html | 292 + src/src/resources/media-playback-start.png | Bin 0 -> 961 bytes src/src/resources/mo_icon.png | Bin 0 -> 1011 bytes src/src/resources/multiply-red.png | Bin 0 -> 666 bytes src/src/resources/open-Folder-Icon.png | Bin 0 -> 1434 bytes src/src/resources/package.png | Bin 0 -> 1067 bytes src/src/resources/plugins.png | Bin 0 -> 2340 bytes src/src/resources/preferences-system.png | Bin 0 -> 2129 bytes src/src/resources/process-stop.png | Bin 0 -> 1272 bytes src/src/resources/red-archive-conflict-loser.png | Bin 0 -> 746 bytes src/src/resources/save.svg | 1 + src/src/resources/show.png | Bin 0 -> 1788 bytes src/src/resources/software-update-available.png | Bin 0 -> 740 bytes src/src/resources/start-here.png | Bin 0 -> 1394 bytes src/src/resources/status_active.png | Bin 0 -> 641 bytes src/src/resources/status_awaiting.png | Bin 0 -> 631 bytes src/src/resources/status_inactive.png | Bin 0 -> 597 bytes src/src/resources/switch-instance-icon.png | Bin 0 -> 4333 bytes src/src/resources/symbol-backup.png | Bin 0 -> 561 bytes src/src/resources/system-installer.png | Bin 0 -> 1589 bytes src/src/resources/system-search.png | Bin 0 -> 1267 bytes src/src/resources/system-software-update.png | Bin 0 -> 2216 bytes src/src/resources/tracked.png | Bin 0 -> 2284 bytes src/src/resources/view-refresh.png | Bin 0 -> 1364 bytes src/src/resources/view-refresh_16.png | Bin 0 -> 912 bytes src/src/resources/word-wrap.svg | 1 + src/src/resources/x-office-calendar.png | Bin 0 -> 604 bytes src/src/sanitychecks.cpp | 467 + src/src/sanitychecks.h | 26 + src/src/savestab.cpp | 393 + src/src/savestab.h | 66 + src/src/savetextasdialog.cpp | 49 + src/src/savetextasdialog.h | 32 + src/src/savetextasdialog.ui | 69 + src/src/selectiondialog.cpp | 116 + src/src/selectiondialog.h | 77 + src/src/selectiondialog.ui | 85 + src/src/selfupdater.cpp | 351 + src/src/selfupdater.h | 154 + src/src/serverinfo.cpp | 169 + src/src/serverinfo.h | 68 + src/src/settings.cpp | 2584 +++++ src/src/settings.h | 992 ++ src/src/settingsdialog.cpp | 162 + src/src/settingsdialog.h | 96 + src/src/settingsdialog.ui | 2568 +++++ src/src/settingsdialogdiagnostics.cpp | 113 + src/src/settingsdialogdiagnostics.h | 20 + src/src/settingsdialoggeneral.cpp | 181 + src/src/settingsdialoggeneral.h | 25 + src/src/settingsdialogmodlist.cpp | 85 + src/src/settingsdialogmodlist.h | 26 + src/src/settingsdialognexus.cpp | 469 + src/src/settingsdialognexus.h | 70 + src/src/settingsdialogpaths.cpp | 281 + src/src/settingsdialogpaths.h | 34 + src/src/settingsdialogplugins.cpp | 380 + src/src/settingsdialogplugins.h | 57 + src/src/settingsdialogproton.cpp | 447 + src/src/settingsdialogproton.h | 61 + src/src/settingsdialogtheme.cpp | 81 + src/src/settingsdialogtheme.h | 22 + src/src/settingsdialogworkarounds.cpp | 244 + src/src/settingsdialogworkarounds.h | 49 + src/src/settingsutilities.cpp | 350 + src/src/settingsutilities.h | 272 + src/src/shared/appconfig.cpp | 55 + src/src/shared/appconfig.h | 48 + src/src/shared/appconfig.inc | 27 + src/src/shared/directoryentry.cpp | 993 ++ src/src/shared/directoryentry.h | 285 + src/src/shared/fileentry.cpp | 256 + src/src/shared/fileentry.h | 91 + src/src/shared/fileregister.cpp | 183 + src/src/shared/fileregister.h | 53 + src/src/shared/fileregisterfwd.h | 121 + src/src/shared/filesorigin.cpp | 120 + src/src/shared/filesorigin.h | 68 + src/src/shared/originconnection.cpp | 129 + src/src/shared/originconnection.h | 55 + src/src/shared/util.cpp | 517 + src/src/shared/util.h | 96 + src/src/shared/windows_compat.h | 88 + src/src/shared/windows_error.cpp | 63 + src/src/shared/windows_error.h | 57 + src/src/spawn.cpp | 1323 +++ src/src/spawn.h | 134 + src/src/splash.png | Bin 0 -> 74041 bytes src/src/statusbar.cpp | 234 + src/src/statusbar.h | 68 + src/src/stylesheet_resource.qrc | 7 + src/src/stylesheets/Night Eyes.qss | 702 ++ src/src/stylesheets/Parchment v1.1 by Bob.qss | 607 ++ .../stylesheets/Parchment/checkbox-alt-checked.png | Bin 0 -> 301 bytes .../Parchment/checkbox-alt-unchecked-hover.png | Bin 0 -> 283 bytes .../Parchment/checkbox-alt-unchecked.png | Bin 0 -> 253 bytes .../Parchment/checkbox-checked-disabled.png | Bin 0 -> 309 bytes .../Parchment/checkbox-checked-hover.png | Bin 0 -> 339 bytes src/src/stylesheets/Parchment/checkbox-checked.png | Bin 0 -> 319 bytes .../stylesheets/Parchment/checkbox-disabled.png | Bin 0 -> 259 bytes src/src/stylesheets/Parchment/checkbox-hover.png | Bin 0 -> 339 bytes src/src/stylesheets/Parchment/checkbox.png | Bin 0 -> 259 bytes src/src/stylesheets/dark.qss | 395 + src/src/stylesheets/dracula.qss | 445 + src/src/stylesheets/skyrim.qss | 670 ++ src/src/stylesheets/skyrim/arrow-down.png | Bin 0 -> 218 bytes src/src/stylesheets/skyrim/arrow-left.png | Bin 0 -> 226 bytes src/src/stylesheets/skyrim/arrow-right.png | Bin 0 -> 217 bytes src/src/stylesheets/skyrim/arrow-up.png | Bin 0 -> 219 bytes src/src/stylesheets/skyrim/border-image.png | Bin 0 -> 1757 bytes src/src/stylesheets/skyrim/border-image1.png | Bin 0 -> 199 bytes src/src/stylesheets/skyrim/border-image2.png | Bin 0 -> 209 bytes src/src/stylesheets/skyrim/branch-opened.png | Bin 0 -> 233 bytes src/src/stylesheets/skyrim/button-big-border.png | Bin 0 -> 897 bytes src/src/stylesheets/skyrim/button-border.png | Bin 0 -> 833 bytes .../stylesheets/skyrim/button-checked-border.png | Bin 0 -> 874 bytes .../stylesheets/skyrim/checkbox-alt-checked.png | Bin 0 -> 354 bytes .../skyrim/checkbox-alt-unchecked-hover.png | Bin 0 -> 454 bytes .../stylesheets/skyrim/checkbox-alt-unchecked.png | Bin 0 -> 462 bytes .../skyrim/checkbox-checked-disabled.png | Bin 0 -> 804 bytes .../stylesheets/skyrim/checkbox-checked-hover.png | Bin 0 -> 810 bytes src/src/stylesheets/skyrim/checkbox-checked.png | Bin 0 -> 823 bytes src/src/stylesheets/skyrim/checkbox-disabled.png | Bin 0 -> 570 bytes src/src/stylesheets/skyrim/checkbox-hover.png | Bin 0 -> 581 bytes src/src/stylesheets/skyrim/checkbox.png | Bin 0 -> 590 bytes .../stylesheets/skyrim/context-menu-separator.png | Bin 0 -> 166 bytes src/src/stylesheets/skyrim/progress-bar-border.png | Bin 0 -> 1262 bytes src/src/stylesheets/skyrim/progress-bar-chunk.png | Bin 0 -> 164 bytes src/src/stylesheets/skyrim/radio-checked.png | Bin 0 -> 353 bytes src/src/stylesheets/skyrim/radio-hover.png | Bin 0 -> 489 bytes src/src/stylesheets/skyrim/radio.png | Bin 0 -> 513 bytes src/src/stylesheets/skyrim/scrollbar-down.png | Bin 0 -> 546 bytes .../stylesheets/skyrim/scrollbar-horizontal.png | Bin 0 -> 172 bytes src/src/stylesheets/skyrim/scrollbar-left.png | Bin 0 -> 543 bytes src/src/stylesheets/skyrim/scrollbar-right.png | Bin 0 -> 538 bytes src/src/stylesheets/skyrim/scrollbar-up.png | Bin 0 -> 547 bytes src/src/stylesheets/skyrim/scrollbar-vertical.png | Bin 0 -> 181 bytes src/src/stylesheets/skyrim/separator.png | Bin 0 -> 1355 bytes src/src/stylesheets/skyrim/slider-border.png | Bin 0 -> 725 bytes src/src/stylesheets/skyrim/slider-handle.png | Bin 0 -> 380 bytes src/src/stylesheets/vs15 Dark-Green.qss | 920 ++ src/src/stylesheets/vs15 Dark-Orange.qss | 920 ++ src/src/stylesheets/vs15 Dark-Pink.qss | 920 ++ src/src/stylesheets/vs15 Dark-Purple.qss | 920 ++ src/src/stylesheets/vs15 Dark-Red.qss | 920 ++ src/src/stylesheets/vs15 Dark-Yellow.qss | 920 ++ src/src/stylesheets/vs15 Dark.qss | 920 ++ src/src/stylesheets/vs15/branch-closed.png | Bin 0 -> 193 bytes src/src/stylesheets/vs15/branch-open.png | Bin 0 -> 182 bytes .../stylesheets/vs15/checkbox-check-disabled.png | Bin 0 -> 218 bytes src/src/stylesheets/vs15/checkbox-check.png | Bin 0 -> 229 bytes src/src/stylesheets/vs15/combobox-down.png | Bin 0 -> 183 bytes .../stylesheets/vs15/scrollbar-down-disabled.png | Bin 0 -> 177 bytes src/src/stylesheets/vs15/scrollbar-down-hover.png | Bin 0 -> 177 bytes src/src/stylesheets/vs15/scrollbar-down.png | Bin 0 -> 190 bytes .../stylesheets/vs15/scrollbar-left-disabled.png | Bin 0 -> 175 bytes src/src/stylesheets/vs15/scrollbar-left-hover.png | Bin 0 -> 175 bytes src/src/stylesheets/vs15/scrollbar-left.png | Bin 0 -> 188 bytes .../stylesheets/vs15/scrollbar-right-disabled.png | Bin 0 -> 178 bytes src/src/stylesheets/vs15/scrollbar-right-hover.png | Bin 0 -> 179 bytes src/src/stylesheets/vs15/scrollbar-right.png | Bin 0 -> 192 bytes src/src/stylesheets/vs15/scrollbar-up-disabled.png | Bin 0 -> 174 bytes src/src/stylesheets/vs15/scrollbar-up-hover.png | Bin 0 -> 174 bytes src/src/stylesheets/vs15/scrollbar-up.png | Bin 0 -> 187 bytes src/src/stylesheets/vs15/sort-asc.png | Bin 0 -> 173 bytes src/src/stylesheets/vs15/sort-desc.png | Bin 0 -> 173 bytes src/src/stylesheets/vs15/spinner-down.png | Bin 0 -> 174 bytes src/src/stylesheets/vs15/spinner-up.png | Bin 0 -> 174 bytes src/src/stylesheets/vs15/sub-menu-arrow-hover.png | Bin 0 -> 172 bytes src/src/stylesheets/vs15/sub-menu-arrow.png | Bin 0 -> 186 bytes src/src/syncoverwritedialog.cpp | 176 + src/src/syncoverwritedialog.h | 57 + src/src/syncoverwritedialog.ui | 84 + src/src/systemtraymanager.cpp | 77 + src/src/systemtraymanager.h | 48 + src/src/texteditor.cpp | 550 + src/src/texteditor.h | 163 + src/src/thread_utils.h | 76 + src/src/transfersavesdialog.cpp | 278 + src/src/transfersavesdialog.h | 115 + src/src/transfersavesdialog.ui | 187 + src/src/tutorials/Highlight.qml | 18 + src/src/tutorials/Tooltip.qml | 29 + src/src/tutorials/TooltipArea.qml | 55 + src/src/tutorials/TutorialCanceller.qml | 55 + src/src/tutorials/TutorialDescription.qml | 55 + src/src/tutorials/TutorialOverlay.qml | 80 + src/src/tutorials/tutorial_basics.js | 2 + .../tutorials/tutorial_conflictresolution_main.js | 198 + .../tutorial_conflictresolution_modinfo.js | 23 + src/src/tutorials/tutorial_firststeps.js | 5 + src/src/tutorials/tutorial_firststeps_browser.js | 12 + src/src/tutorials/tutorial_firststeps_main.js | 184 + src/src/tutorials/tutorial_firststeps_modinfo.js | 24 + src/src/tutorials/tutorial_firststeps_settings.js | 29 + src/src/tutorials/tutorial_primer_main.js | 141 + src/src/tutorials/tutorial_window_installer.js | 37 + src/src/tutorials/tutorials.js | 83 + src/src/tutorials/tutorials_installdialog.qml | 7 + src/src/tutorials/tutorials_mainwindow.qml | 7 + src/src/tutorials/tutorials_modinfodialog.qml | 7 + src/src/tutorials/tutorials_nexusdialog.qml | 8 + src/src/tutorials/tutorials_settingsdialog.qml | 7 + src/src/uilocker.cpp | 636 ++ src/src/uilocker.h | 97 + src/src/updatedialog.cpp | 81 + src/src/updatedialog.h | 31 + src/src/updatedialog.ui | 200 + src/src/usvfsconnector.cpp | 342 + src/src/usvfsconnector.h | 102 + src/src/validationprogressdialog.ui | 117 + src/src/version.rc | 37 + src/src/vfs/inodetable.cpp | 99 + src/src/vfs/inodetable.h | 23 + src/src/vfs/mo2filesystem.cpp | 794 ++ src/src/vfs/mo2filesystem.h | 64 + src/src/vfs/overwritemanager.cpp | 178 + src/src/vfs/overwritemanager.h | 34 + src/src/vfs/vfs_helper_main.cpp | 319 + src/src/vfs/vfstree.cpp | 380 + src/src/vfs/vfstree.h | 80 + src/src/viewmarkingscrollbar.cpp | 56 + src/src/viewmarkingscrollbar.h | 24 + src/src/virtualfiletree.cpp | 82 + src/src/virtualfiletree.h | 62 + src/src/wineprefix.cpp | 288 + src/src/wineprefix.h | 38 + 604 files changed, 135871 insertions(+) create mode 100755 src/fluorine-manager create mode 100644 src/src/CMakeLists.txt create mode 100644 src/src/aboutdialog.cpp create mode 100644 src/src/aboutdialog.h create mode 100644 src/src/aboutdialog.ui create mode 100644 src/src/activatemodsdialog.cpp create mode 100644 src/src/activatemodsdialog.h create mode 100644 src/src/activatemodsdialog.ui create mode 100644 src/src/apiuseraccount.cpp create mode 100644 src/src/apiuseraccount.h create mode 100644 src/src/app_icon.rc create mode 100644 src/src/archivefiletree.cpp create mode 100644 src/src/archivefiletree.h create mode 100644 src/src/bbcode.cpp create mode 100644 src/src/bbcode.h create mode 100644 src/src/browserdialog.cpp create mode 100644 src/src/browserdialog.h create mode 100644 src/src/browserdialog.ui create mode 100644 src/src/browserview.cpp create mode 100644 src/src/browserview.h create mode 100644 src/src/categories.cpp create mode 100644 src/src/categories.h create mode 100644 src/src/categoriesdialog.cpp create mode 100644 src/src/categoriesdialog.h create mode 100644 src/src/categoriesdialog.ui create mode 100644 src/src/categoriestable.cpp create mode 100644 src/src/categoriestable.h create mode 100644 src/src/categoryimportdialog.cpp create mode 100644 src/src/categoryimportdialog.h create mode 100644 src/src/categoryimportdialog.ui create mode 100644 src/src/colortable.cpp create mode 100644 src/src/colortable.h create mode 100644 src/src/commandline.cpp create mode 100644 src/src/commandline.h create mode 100644 src/src/copyeventfilter.cpp create mode 100644 src/src/copyeventfilter.h create mode 100644 src/src/createinstancedialog.cpp create mode 100644 src/src/createinstancedialog.h create mode 100644 src/src/createinstancedialog.ui create mode 100644 src/src/createinstancedialogpages.cpp create mode 100644 src/src/createinstancedialogpages.h create mode 100644 src/src/credentialsdialog.cpp create mode 100644 src/src/credentialsdialog.h create mode 100644 src/src/credentialsdialog.ui create mode 100644 src/src/csvbuilder.cpp create mode 100644 src/src/csvbuilder.h create mode 100644 src/src/datatab.cpp create mode 100644 src/src/datatab.h create mode 100644 src/src/directoryrefresher.cpp create mode 100644 src/src/directoryrefresher.h create mode 100644 src/src/disableproxyplugindialog.cpp create mode 100644 src/src/disableproxyplugindialog.h create mode 100644 src/src/disableproxyplugindialog.ui create mode 100644 src/src/dlls.manifest.debug.qt6 create mode 100644 src/src/dlls.manifest.qt6 create mode 100644 src/src/downloadlist.cpp create mode 100644 src/src/downloadlist.h create mode 100644 src/src/downloadlistview.cpp create mode 100644 src/src/downloadlistview.h create mode 100644 src/src/downloadmanager.cpp create mode 100644 src/src/downloadmanager.h create mode 100644 src/src/downloadmanagerproxy.cpp create mode 100644 src/src/downloadmanagerproxy.h create mode 100644 src/src/downloadstab.cpp create mode 100644 src/src/downloadstab.h create mode 100644 src/src/editexecutablesdialog.cpp create mode 100644 src/src/editexecutablesdialog.h create mode 100644 src/src/editexecutablesdialog.ui create mode 100644 src/src/env.cpp create mode 100644 src/src/env.h create mode 100644 src/src/envdump.h create mode 100644 src/src/envfs.cpp create mode 100644 src/src/envfs.h create mode 100644 src/src/envmetrics.cpp create mode 100644 src/src/envmetrics.h create mode 100644 src/src/envmodule.cpp create mode 100644 src/src/envmodule.h create mode 100644 src/src/envsecurity.cpp create mode 100644 src/src/envsecurity.h create mode 100644 src/src/envshell.cpp create mode 100644 src/src/envshell.h create mode 100644 src/src/envshortcut.cpp create mode 100644 src/src/envshortcut.h create mode 100644 src/src/envwindows.cpp create mode 100644 src/src/envwindows.h create mode 100644 src/src/executableslist.cpp create mode 100644 src/src/executableslist.h create mode 100644 src/src/executableslistproxy.cpp create mode 100644 src/src/executableslistproxy.h create mode 100644 src/src/filedialogmemory.cpp create mode 100644 src/src/filedialogmemory.h create mode 100644 src/src/filerenamer.cpp create mode 100644 src/src/filerenamer.h create mode 100644 src/src/filetree.cpp create mode 100644 src/src/filetree.h create mode 100644 src/src/filetreeitem.cpp create mode 100644 src/src/filetreeitem.h create mode 100644 src/src/filetreemodel.cpp create mode 100644 src/src/filetreemodel.h create mode 100644 src/src/filterlist.cpp create mode 100644 src/src/filterlist.h create mode 100644 src/src/fluorineconfig.cpp create mode 100644 src/src/fluorineconfig.h create mode 100644 src/src/forcedloaddialog.cpp create mode 100644 src/src/forcedloaddialog.h create mode 100644 src/src/forcedloaddialog.ui create mode 100644 src/src/forcedloaddialogwidget.cpp create mode 100644 src/src/forcedloaddialogwidget.h create mode 100644 src/src/forcedloaddialogwidget.ui create mode 100644 src/src/fuseconnector.cpp create mode 100644 src/src/fuseconnector.h create mode 100644 src/src/game_features.cpp create mode 100644 src/src/game_features.h create mode 100644 src/src/gamefeaturesproxy.cpp create mode 100644 src/src/gamefeaturesproxy.h create mode 100644 src/src/genericicondelegate.cpp create mode 100644 src/src/genericicondelegate.h create mode 100644 src/src/github.cpp create mode 100644 src/src/github.h create mode 100644 src/src/glob_matching.h create mode 100644 src/src/icondelegate.cpp create mode 100644 src/src/icondelegate.h create mode 100644 src/src/iconfetcher.cpp create mode 100644 src/src/iconfetcher.h create mode 100644 src/src/installationmanager.cpp create mode 100644 src/src/installationmanager.h create mode 100644 src/src/instancemanager.cpp create mode 100644 src/src/instancemanager.h create mode 100644 src/src/instancemanagerdialog.cpp create mode 100644 src/src/instancemanagerdialog.h create mode 100644 src/src/instancemanagerdialog.ui create mode 100644 src/src/iuserinterface.h create mode 100644 src/src/json.h create mode 100644 src/src/lcdnumber.cpp create mode 100644 src/src/lcdnumber.h create mode 100644 src/src/listdialog.cpp create mode 100644 src/src/listdialog.h create mode 100644 src/src/listdialog.ui create mode 100644 src/src/loghighlighter.cpp create mode 100644 src/src/loghighlighter.h create mode 100644 src/src/loglist.cpp create mode 100644 src/src/loglist.h create mode 100644 src/src/loot.cpp create mode 100644 src/src/loot.h create mode 100644 src/src/lootdialog.cpp create mode 100644 src/src/lootdialog.h create mode 100644 src/src/lootdialog.ui create mode 100644 src/src/main.cpp create mode 100644 src/src/mainwindow.cpp create mode 100644 src/src/mainwindow.h create mode 100644 src/src/mainwindow.ui create mode 100644 src/src/messagedialog.cpp create mode 100644 src/src/messagedialog.h create mode 100644 src/src/messagedialog.ui create mode 100644 src/src/mo_icon.ico create mode 100644 src/src/moapplication.cpp create mode 100644 src/src/moapplication.h create mode 100644 src/src/modconflicticondelegate.cpp create mode 100644 src/src/modconflicticondelegate.h create mode 100644 src/src/modcontenticondelegate.cpp create mode 100644 src/src/modcontenticondelegate.h create mode 100644 src/src/modelutils.cpp create mode 100644 src/src/modelutils.h create mode 100644 src/src/modflagicondelegate.cpp create mode 100644 src/src/modflagicondelegate.h create mode 100644 src/src/modidlineedit.cpp create mode 100644 src/src/modidlineedit.h create mode 100644 src/src/modinfo.cpp create mode 100644 src/src/modinfo.h create mode 100644 src/src/modinfobackup.cpp create mode 100644 src/src/modinfobackup.h create mode 100644 src/src/modinfodialog.cpp create mode 100644 src/src/modinfodialog.h create mode 100644 src/src/modinfodialog.ui create mode 100644 src/src/modinfodialogcategories.cpp create mode 100644 src/src/modinfodialogcategories.h create mode 100644 src/src/modinfodialogconflicts.cpp create mode 100644 src/src/modinfodialogconflicts.h create mode 100644 src/src/modinfodialogconflictsmodels.cpp create mode 100644 src/src/modinfodialogconflictsmodels.h create mode 100644 src/src/modinfodialogesps.cpp create mode 100644 src/src/modinfodialogesps.h create mode 100644 src/src/modinfodialogfiletree.cpp create mode 100644 src/src/modinfodialogfiletree.h create mode 100644 src/src/modinfodialogfwd.h create mode 100644 src/src/modinfodialogimages.cpp create mode 100644 src/src/modinfodialogimages.h create mode 100644 src/src/modinfodialognexus.cpp create mode 100644 src/src/modinfodialognexus.h create mode 100644 src/src/modinfodialogtab.cpp create mode 100644 src/src/modinfodialogtab.h create mode 100644 src/src/modinfodialogtextfiles.cpp create mode 100644 src/src/modinfodialogtextfiles.h create mode 100644 src/src/modinfoforeign.cpp create mode 100644 src/src/modinfoforeign.h create mode 100644 src/src/modinfooverwrite.cpp create mode 100644 src/src/modinfooverwrite.h create mode 100644 src/src/modinforegular.cpp create mode 100644 src/src/modinforegular.h create mode 100644 src/src/modinfoseparator.cpp create mode 100644 src/src/modinfoseparator.h create mode 100644 src/src/modinfowithconflictinfo.cpp create mode 100644 src/src/modinfowithconflictinfo.h create mode 100644 src/src/modlist.cpp create mode 100644 src/src/modlist.h create mode 100644 src/src/modlistbypriorityproxy.cpp create mode 100644 src/src/modlistbypriorityproxy.h create mode 100644 src/src/modlistcontextmenu.cpp create mode 100644 src/src/modlistcontextmenu.h create mode 100644 src/src/modlistdropinfo.cpp create mode 100644 src/src/modlistdropinfo.h create mode 100644 src/src/modlistproxy.cpp create mode 100644 src/src/modlistproxy.h create mode 100644 src/src/modlistsortproxy.cpp create mode 100644 src/src/modlistsortproxy.h create mode 100644 src/src/modlistversiondelegate.cpp create mode 100644 src/src/modlistversiondelegate.h create mode 100644 src/src/modlistview.cpp create mode 100644 src/src/modlistview.h create mode 100644 src/src/modlistviewactions.cpp create mode 100644 src/src/modlistviewactions.h create mode 100644 src/src/moshortcut.cpp create mode 100644 src/src/moshortcut.h create mode 100644 src/src/motddialog.cpp create mode 100644 src/src/motddialog.h create mode 100644 src/src/motddialog.ui create mode 100644 src/src/multiprocess.cpp create mode 100644 src/src/multiprocess.h create mode 100644 src/src/nexusinterface.cpp create mode 100644 src/src/nexusinterface.h create mode 100644 src/src/nexusmanualkey.ui create mode 100644 src/src/noeditdelegate.cpp create mode 100644 src/src/noeditdelegate.h create mode 100644 src/src/nxmaccessmanager.cpp create mode 100644 src/src/nxmaccessmanager.h create mode 100644 src/src/nxmhandler_linux.cpp create mode 100644 src/src/nxmhandler_linux.h create mode 100644 src/src/organizer_en.ts create mode 100644 src/src/organizercore.cpp create mode 100644 src/src/organizercore.h create mode 100644 src/src/organizerproxy.cpp create mode 100644 src/src/organizerproxy.h create mode 100644 src/src/overwriteinfodialog.cpp create mode 100644 src/src/overwriteinfodialog.h create mode 100644 src/src/overwriteinfodialog.ui create mode 100644 src/src/pch.cpp create mode 100644 src/src/pch.h create mode 100644 src/src/persistentcookiejar.cpp create mode 100644 src/src/persistentcookiejar.h create mode 100644 src/src/plugincontainer.cpp create mode 100644 src/src/plugincontainer.h create mode 100644 src/src/pluginlist.cpp create mode 100644 src/src/pluginlist.h create mode 100644 src/src/pluginlistcontextmenu.cpp create mode 100644 src/src/pluginlistcontextmenu.h create mode 100644 src/src/pluginlistproxy.cpp create mode 100644 src/src/pluginlistproxy.h create mode 100644 src/src/pluginlistsortproxy.cpp create mode 100644 src/src/pluginlistsortproxy.h create mode 100644 src/src/pluginlistview.cpp create mode 100644 src/src/pluginlistview.h create mode 100644 src/src/previewdialog.cpp create mode 100644 src/src/previewdialog.h create mode 100644 src/src/previewdialog.ui create mode 100644 src/src/previewgenerator.cpp create mode 100644 src/src/previewgenerator.h create mode 100644 src/src/problemsdialog.cpp create mode 100644 src/src/problemsdialog.h create mode 100644 src/src/problemsdialog.ui create mode 100644 src/src/processrunner.cpp create mode 100644 src/src/processrunner.h create mode 100644 src/src/profile.cpp create mode 100644 src/src/profile.h create mode 100644 src/src/profileinputdialog.cpp create mode 100644 src/src/profileinputdialog.h create mode 100644 src/src/profileinputdialog.ui create mode 100644 src/src/profilesdialog.cpp create mode 100644 src/src/profilesdialog.h create mode 100644 src/src/profilesdialog.ui create mode 100644 src/src/protonlauncher.cpp create mode 100644 src/src/protonlauncher.h create mode 100644 src/src/proxyutils.h create mode 100644 src/src/qdirfiletree.cpp create mode 100644 src/src/qdirfiletree.h create mode 100644 src/src/qt.conf create mode 100644 src/src/qtgroupingproxy.cpp create mode 100644 src/src/qtgroupingproxy.h create mode 100644 src/src/queryoverwritedialog.cpp create mode 100644 src/src/queryoverwritedialog.h create mode 100644 src/src/queryoverwritedialog.ui create mode 100644 src/src/resources.qrc create mode 100644 src/src/resources/accessories-text-editor.png create mode 100644 src/src/resources/application-x-executable.png create mode 100644 src/src/resources/applications-accessories.png create mode 100644 src/src/resources/archive-conflict-loser.png create mode 100644 src/src/resources/archive-conflict-mixed.png create mode 100644 src/src/resources/archive-conflict-neutral.png create mode 100644 src/src/resources/archive-conflict-winner.png create mode 100644 src/src/resources/arrange-boxes.png create mode 100644 src/src/resources/badge_1.png create mode 100644 src/src/resources/badge_2.png create mode 100644 src/src/resources/badge_3.png create mode 100644 src/src/resources/badge_4.png create mode 100644 src/src/resources/badge_5.png create mode 100644 src/src/resources/badge_6.png create mode 100644 src/src/resources/badge_7.png create mode 100644 src/src/resources/badge_8.png create mode 100644 src/src/resources/badge_9.png create mode 100644 src/src/resources/badge_more.png create mode 100644 src/src/resources/branch-closed.png create mode 100644 src/src/resources/branch-open.png create mode 100644 src/src/resources/check.png create mode 100644 src/src/resources/combobox-down.png create mode 100644 src/src/resources/conflict-hidden.png create mode 100644 src/src/resources/conflict-mixed-blue.png create mode 100644 src/src/resources/conflict-mixed.png create mode 100644 src/src/resources/conflict-overwrite-blue.png create mode 100644 src/src/resources/conflict-overwrite.png create mode 100644 src/src/resources/conflict-overwritten-blue.png create mode 100644 src/src/resources/conflict-overwritten.png create mode 100644 src/src/resources/conflict-redundant.png create mode 100644 src/src/resources/contact-new.png create mode 100644 src/src/resources/contents/breastplate.png create mode 100644 src/src/resources/contents/checkbox-tree.png create mode 100644 src/src/resources/contents/config.png create mode 100644 src/src/resources/contents/conversation.png create mode 100644 src/src/resources/contents/double-quaver.png create mode 100644 src/src/resources/contents/empty-chessboard.png create mode 100644 src/src/resources/contents/f4-nuclear.png create mode 100644 src/src/resources/contents/facegen.png create mode 100644 src/src/resources/contents/feather-and-scroll.png create mode 100644 src/src/resources/contents/hand-of-god.png create mode 100644 src/src/resources/contents/jigsaw-piece.png create mode 100644 src/src/resources/contents/locked-chest.png create mode 100644 src/src/resources/contents/lyre.png create mode 100644 src/src/resources/contents/media.png create mode 100644 src/src/resources/contents/mesh-box.png create mode 100644 src/src/resources/contents/tinker.png create mode 100644 src/src/resources/contents/usable.png create mode 100644 src/src/resources/contents/xedit.png create mode 100644 src/src/resources/dialog-information.png create mode 100644 src/src/resources/dialog-warning.png create mode 100644 src/src/resources/dialog-warning_16.png create mode 100644 src/src/resources/dialog-warning_16.png (warning_16) create mode 100644 src/src/resources/document-properties.png create mode 100644 src/src/resources/document-save.png create mode 100644 src/src/resources/document-save_32.png create mode 100644 src/src/resources/dynamic-blue-right.png create mode 100644 src/src/resources/edit-clear.png create mode 100644 src/src/resources/edit-find-replace.png create mode 100644 src/src/resources/edit-undo.png create mode 100644 src/src/resources/emblem-favorite - 64.png create mode 100644 src/src/resources/emblem-favorite.png create mode 100644 src/src/resources/emblem-important.png create mode 100644 src/src/resources/emblem-readonly.png create mode 100644 src/src/resources/emblem-redundant.png create mode 100644 src/src/resources/emblem-unreadable.png create mode 100644 src/src/resources/error.png create mode 100644 src/src/resources/function.png create mode 100644 src/src/resources/game-warning-16.png create mode 100644 src/src/resources/game-warning.png create mode 100644 src/src/resources/go-down.png create mode 100644 src/src/resources/go-jump.png create mode 100644 src/src/resources/go-next_16.png create mode 100644 src/src/resources/go-previous_16.png create mode 100644 src/src/resources/go-up.png create mode 100644 src/src/resources/help-browser.png create mode 100644 src/src/resources/help-browser_32.png create mode 100644 src/src/resources/icon-favorite.png create mode 100644 src/src/resources/icons8-checked-checkbox.png create mode 100644 src/src/resources/icons8-indeterminate-checkbox.png create mode 100644 src/src/resources/icons8-unchecked-checkbox.png create mode 100644 src/src/resources/internet-web-browser.png create mode 100644 src/src/resources/list-add.png create mode 100644 src/src/resources/list-remove.png create mode 100644 src/src/resources/log-debug.png create mode 100644 src/src/resources/mail-attachment.png create mode 100644 src/src/resources/markdown.html create mode 100644 src/src/resources/media-playback-start.png create mode 100644 src/src/resources/mo_icon.png create mode 100644 src/src/resources/multiply-red.png create mode 100644 src/src/resources/open-Folder-Icon.png create mode 100644 src/src/resources/package.png create mode 100644 src/src/resources/plugins.png create mode 100644 src/src/resources/preferences-system.png create mode 100644 src/src/resources/process-stop.png create mode 100644 src/src/resources/red-archive-conflict-loser.png create mode 100644 src/src/resources/save.svg create mode 100644 src/src/resources/show.png create mode 100644 src/src/resources/software-update-available.png create mode 100644 src/src/resources/start-here.png create mode 100644 src/src/resources/status_active.png create mode 100644 src/src/resources/status_awaiting.png create mode 100644 src/src/resources/status_inactive.png create mode 100644 src/src/resources/switch-instance-icon.png create mode 100644 src/src/resources/symbol-backup.png create mode 100644 src/src/resources/system-installer.png create mode 100644 src/src/resources/system-search.png create mode 100644 src/src/resources/system-software-update.png create mode 100644 src/src/resources/tracked.png create mode 100644 src/src/resources/view-refresh.png create mode 100644 src/src/resources/view-refresh_16.png create mode 100644 src/src/resources/word-wrap.svg create mode 100644 src/src/resources/x-office-calendar.png create mode 100644 src/src/sanitychecks.cpp create mode 100644 src/src/sanitychecks.h create mode 100644 src/src/savestab.cpp create mode 100644 src/src/savestab.h create mode 100644 src/src/savetextasdialog.cpp create mode 100644 src/src/savetextasdialog.h create mode 100644 src/src/savetextasdialog.ui create mode 100644 src/src/selectiondialog.cpp create mode 100644 src/src/selectiondialog.h create mode 100644 src/src/selectiondialog.ui create mode 100644 src/src/selfupdater.cpp create mode 100644 src/src/selfupdater.h create mode 100644 src/src/serverinfo.cpp create mode 100644 src/src/serverinfo.h create mode 100644 src/src/settings.cpp create mode 100644 src/src/settings.h create mode 100644 src/src/settingsdialog.cpp create mode 100644 src/src/settingsdialog.h create mode 100644 src/src/settingsdialog.ui create mode 100644 src/src/settingsdialogdiagnostics.cpp create mode 100644 src/src/settingsdialogdiagnostics.h create mode 100644 src/src/settingsdialoggeneral.cpp create mode 100644 src/src/settingsdialoggeneral.h create mode 100644 src/src/settingsdialogmodlist.cpp create mode 100644 src/src/settingsdialogmodlist.h create mode 100644 src/src/settingsdialognexus.cpp create mode 100644 src/src/settingsdialognexus.h create mode 100644 src/src/settingsdialogpaths.cpp create mode 100644 src/src/settingsdialogpaths.h create mode 100644 src/src/settingsdialogplugins.cpp create mode 100644 src/src/settingsdialogplugins.h create mode 100644 src/src/settingsdialogproton.cpp create mode 100644 src/src/settingsdialogproton.h create mode 100644 src/src/settingsdialogtheme.cpp create mode 100644 src/src/settingsdialogtheme.h create mode 100644 src/src/settingsdialogworkarounds.cpp create mode 100644 src/src/settingsdialogworkarounds.h create mode 100644 src/src/settingsutilities.cpp create mode 100644 src/src/settingsutilities.h create mode 100644 src/src/shared/appconfig.cpp create mode 100644 src/src/shared/appconfig.h create mode 100644 src/src/shared/appconfig.inc create mode 100644 src/src/shared/directoryentry.cpp create mode 100644 src/src/shared/directoryentry.h create mode 100644 src/src/shared/fileentry.cpp create mode 100644 src/src/shared/fileentry.h create mode 100644 src/src/shared/fileregister.cpp create mode 100644 src/src/shared/fileregister.h create mode 100644 src/src/shared/fileregisterfwd.h create mode 100644 src/src/shared/filesorigin.cpp create mode 100644 src/src/shared/filesorigin.h create mode 100644 src/src/shared/originconnection.cpp create mode 100644 src/src/shared/originconnection.h create mode 100644 src/src/shared/util.cpp create mode 100644 src/src/shared/util.h create mode 100644 src/src/shared/windows_compat.h create mode 100644 src/src/shared/windows_error.cpp create mode 100644 src/src/shared/windows_error.h create mode 100644 src/src/spawn.cpp create mode 100644 src/src/spawn.h create mode 100644 src/src/splash.png create mode 100644 src/src/statusbar.cpp create mode 100644 src/src/statusbar.h create mode 100644 src/src/stylesheet_resource.qrc create mode 100644 src/src/stylesheets/Night Eyes.qss create mode 100644 src/src/stylesheets/Parchment v1.1 by Bob.qss create mode 100644 src/src/stylesheets/Parchment/checkbox-alt-checked.png create mode 100644 src/src/stylesheets/Parchment/checkbox-alt-unchecked-hover.png create mode 100644 src/src/stylesheets/Parchment/checkbox-alt-unchecked.png create mode 100644 src/src/stylesheets/Parchment/checkbox-checked-disabled.png create mode 100644 src/src/stylesheets/Parchment/checkbox-checked-hover.png create mode 100644 src/src/stylesheets/Parchment/checkbox-checked.png create mode 100644 src/src/stylesheets/Parchment/checkbox-disabled.png create mode 100644 src/src/stylesheets/Parchment/checkbox-hover.png create mode 100644 src/src/stylesheets/Parchment/checkbox.png create mode 100644 src/src/stylesheets/dark.qss create mode 100644 src/src/stylesheets/dracula.qss create mode 100644 src/src/stylesheets/skyrim.qss create mode 100644 src/src/stylesheets/skyrim/arrow-down.png create mode 100644 src/src/stylesheets/skyrim/arrow-left.png create mode 100644 src/src/stylesheets/skyrim/arrow-right.png create mode 100644 src/src/stylesheets/skyrim/arrow-up.png create mode 100644 src/src/stylesheets/skyrim/border-image.png create mode 100644 src/src/stylesheets/skyrim/border-image1.png create mode 100644 src/src/stylesheets/skyrim/border-image2.png create mode 100644 src/src/stylesheets/skyrim/branch-opened.png create mode 100644 src/src/stylesheets/skyrim/button-big-border.png create mode 100644 src/src/stylesheets/skyrim/button-border.png create mode 100644 src/src/stylesheets/skyrim/button-checked-border.png create mode 100644 src/src/stylesheets/skyrim/checkbox-alt-checked.png create mode 100644 src/src/stylesheets/skyrim/checkbox-alt-unchecked-hover.png create mode 100644 src/src/stylesheets/skyrim/checkbox-alt-unchecked.png create mode 100644 src/src/stylesheets/skyrim/checkbox-checked-disabled.png create mode 100644 src/src/stylesheets/skyrim/checkbox-checked-hover.png create mode 100644 src/src/stylesheets/skyrim/checkbox-checked.png create mode 100644 src/src/stylesheets/skyrim/checkbox-disabled.png create mode 100644 src/src/stylesheets/skyrim/checkbox-hover.png create mode 100644 src/src/stylesheets/skyrim/checkbox.png create mode 100644 src/src/stylesheets/skyrim/context-menu-separator.png create mode 100644 src/src/stylesheets/skyrim/progress-bar-border.png create mode 100644 src/src/stylesheets/skyrim/progress-bar-chunk.png create mode 100644 src/src/stylesheets/skyrim/radio-checked.png create mode 100644 src/src/stylesheets/skyrim/radio-hover.png create mode 100644 src/src/stylesheets/skyrim/radio.png create mode 100644 src/src/stylesheets/skyrim/scrollbar-down.png create mode 100644 src/src/stylesheets/skyrim/scrollbar-horizontal.png create mode 100644 src/src/stylesheets/skyrim/scrollbar-left.png create mode 100644 src/src/stylesheets/skyrim/scrollbar-right.png create mode 100644 src/src/stylesheets/skyrim/scrollbar-up.png create mode 100644 src/src/stylesheets/skyrim/scrollbar-vertical.png create mode 100644 src/src/stylesheets/skyrim/separator.png create mode 100644 src/src/stylesheets/skyrim/slider-border.png create mode 100644 src/src/stylesheets/skyrim/slider-handle.png create mode 100644 src/src/stylesheets/vs15 Dark-Green.qss create mode 100644 src/src/stylesheets/vs15 Dark-Orange.qss create mode 100644 src/src/stylesheets/vs15 Dark-Pink.qss create mode 100644 src/src/stylesheets/vs15 Dark-Purple.qss create mode 100644 src/src/stylesheets/vs15 Dark-Red.qss create mode 100644 src/src/stylesheets/vs15 Dark-Yellow.qss create mode 100644 src/src/stylesheets/vs15 Dark.qss create mode 100644 src/src/stylesheets/vs15/branch-closed.png create mode 100644 src/src/stylesheets/vs15/branch-open.png create mode 100644 src/src/stylesheets/vs15/checkbox-check-disabled.png create mode 100644 src/src/stylesheets/vs15/checkbox-check.png create mode 100644 src/src/stylesheets/vs15/combobox-down.png create mode 100644 src/src/stylesheets/vs15/scrollbar-down-disabled.png create mode 100644 src/src/stylesheets/vs15/scrollbar-down-hover.png create mode 100644 src/src/stylesheets/vs15/scrollbar-down.png create mode 100644 src/src/stylesheets/vs15/scrollbar-left-disabled.png create mode 100644 src/src/stylesheets/vs15/scrollbar-left-hover.png create mode 100644 src/src/stylesheets/vs15/scrollbar-left.png create mode 100644 src/src/stylesheets/vs15/scrollbar-right-disabled.png create mode 100644 src/src/stylesheets/vs15/scrollbar-right-hover.png create mode 100644 src/src/stylesheets/vs15/scrollbar-right.png create mode 100644 src/src/stylesheets/vs15/scrollbar-up-disabled.png create mode 100644 src/src/stylesheets/vs15/scrollbar-up-hover.png create mode 100644 src/src/stylesheets/vs15/scrollbar-up.png create mode 100644 src/src/stylesheets/vs15/sort-asc.png create mode 100644 src/src/stylesheets/vs15/sort-desc.png create mode 100644 src/src/stylesheets/vs15/spinner-down.png create mode 100644 src/src/stylesheets/vs15/spinner-up.png create mode 100644 src/src/stylesheets/vs15/sub-menu-arrow-hover.png create mode 100644 src/src/stylesheets/vs15/sub-menu-arrow.png create mode 100644 src/src/syncoverwritedialog.cpp create mode 100644 src/src/syncoverwritedialog.h create mode 100644 src/src/syncoverwritedialog.ui create mode 100644 src/src/systemtraymanager.cpp create mode 100644 src/src/systemtraymanager.h create mode 100644 src/src/texteditor.cpp create mode 100644 src/src/texteditor.h create mode 100644 src/src/thread_utils.h create mode 100644 src/src/transfersavesdialog.cpp create mode 100644 src/src/transfersavesdialog.h create mode 100644 src/src/transfersavesdialog.ui create mode 100644 src/src/tutorials/Highlight.qml create mode 100644 src/src/tutorials/Tooltip.qml create mode 100644 src/src/tutorials/TooltipArea.qml create mode 100644 src/src/tutorials/TutorialCanceller.qml create mode 100644 src/src/tutorials/TutorialDescription.qml create mode 100644 src/src/tutorials/TutorialOverlay.qml create mode 100644 src/src/tutorials/tutorial_basics.js create mode 100644 src/src/tutorials/tutorial_conflictresolution_main.js create mode 100644 src/src/tutorials/tutorial_conflictresolution_modinfo.js create mode 100644 src/src/tutorials/tutorial_firststeps.js create mode 100644 src/src/tutorials/tutorial_firststeps_browser.js create mode 100644 src/src/tutorials/tutorial_firststeps_main.js create mode 100644 src/src/tutorials/tutorial_firststeps_modinfo.js create mode 100644 src/src/tutorials/tutorial_firststeps_settings.js create mode 100644 src/src/tutorials/tutorial_primer_main.js create mode 100644 src/src/tutorials/tutorial_window_installer.js create mode 100644 src/src/tutorials/tutorials.js create mode 100644 src/src/tutorials/tutorials_installdialog.qml create mode 100644 src/src/tutorials/tutorials_mainwindow.qml create mode 100644 src/src/tutorials/tutorials_modinfodialog.qml create mode 100644 src/src/tutorials/tutorials_nexusdialog.qml create mode 100644 src/src/tutorials/tutorials_settingsdialog.qml create mode 100644 src/src/uilocker.cpp create mode 100644 src/src/uilocker.h create mode 100644 src/src/updatedialog.cpp create mode 100644 src/src/updatedialog.h create mode 100644 src/src/updatedialog.ui create mode 100644 src/src/usvfsconnector.cpp create mode 100644 src/src/usvfsconnector.h create mode 100644 src/src/validationprogressdialog.ui create mode 100644 src/src/version.rc create mode 100644 src/src/vfs/inodetable.cpp create mode 100644 src/src/vfs/inodetable.h create mode 100644 src/src/vfs/mo2filesystem.cpp create mode 100644 src/src/vfs/mo2filesystem.h create mode 100644 src/src/vfs/overwritemanager.cpp create mode 100644 src/src/vfs/overwritemanager.h create mode 100644 src/src/vfs/vfs_helper_main.cpp create mode 100644 src/src/vfs/vfstree.cpp create mode 100644 src/src/vfs/vfstree.h create mode 100644 src/src/viewmarkingscrollbar.cpp create mode 100644 src/src/viewmarkingscrollbar.h create mode 100644 src/src/virtualfiletree.cpp create mode 100644 src/src/virtualfiletree.h create mode 100644 src/src/wineprefix.cpp create mode 100644 src/src/wineprefix.h (limited to 'src') diff --git a/src/fluorine-manager b/src/fluorine-manager new file mode 100755 index 0000000..c694ad3 --- /dev/null +++ b/src/fluorine-manager @@ -0,0 +1,4 @@ +#!/bin/bash +# Fluorine Manager - thin wrapper around ModOrganizer +MO2_DIR="$(cd "$(dirname "$0")" && pwd)" +exec "${MO2_DIR}/ModOrganizer" "$@" diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt new file mode 100644 index 0000000..2b3d9cc --- /dev/null +++ b/src/src/CMakeLists.txt @@ -0,0 +1,266 @@ +cmake_minimum_required(VERSION 3.16) + +find_package(Qt6 REQUIRED COMPONENTS + Widgets WebSockets Network) +if(WIN32) + find_package(Qt6 REQUIRED COMPONENTS WebEngineWidgets) +endif() +find_package(Boost CONFIG REQUIRED COMPONENTS + program_options) +find_package(spdlog CONFIG REQUIRED) +find_package(PkgConfig REQUIRED) +pkg_check_modules(FUSE3 REQUIRED IMPORTED_TARGET fuse3) +find_package(Threads REQUIRED) + +qt_standard_project_setup() + +# Collect all source files +file(GLOB ORGANIZER_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/shared/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/vfs/*.cpp) +file(GLOB ORGANIZER_HEADERS + ${CMAKE_CURRENT_SOURCE_DIR}/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/shared/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/vfs/*.h) +file(GLOB ORGANIZER_UI + ${CMAKE_CURRENT_SOURCE_DIR}/*.ui) +file(GLOB ORGANIZER_QRC + ${CMAKE_CURRENT_SOURCE_DIR}/*.qrc) + +# Keep explicitly listed sources tracked by CMake even when using GLOB. +list(APPEND ORGANIZER_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/fluorineconfig.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/protonlauncher.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/settingsdialogproton.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/wineprefix.cpp) +list(APPEND ORGANIZER_HEADERS + ${CMAKE_CURRENT_SOURCE_DIR}/fluorineconfig.h + ${CMAKE_CURRENT_SOURCE_DIR}/protonlauncher.h + ${CMAKE_CURRENT_SOURCE_DIR}/settingsdialogproton.h + ${CMAKE_CURRENT_SOURCE_DIR}/wineprefix.h) + +# Remove Windows-only sources (replaced by Linux equivalents) +list(REMOVE_ITEM ORGANIZER_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/usvfsconnector.cpp) +list(REMOVE_ITEM ORGANIZER_HEADERS + ${CMAKE_CURRENT_SOURCE_DIR}/usvfsconnector.h) + +# VFS helper has its own main() — exclude from organizer +list(REMOVE_ITEM ORGANIZER_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/vfs/vfs_helper_main.cpp) + +# Remove WebEngine-dependent sources when not available +if(NOT Qt6WebEngineWidgets_FOUND) + list(REMOVE_ITEM ORGANIZER_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/browserview.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/browserdialog.cpp) + list(REMOVE_ITEM ORGANIZER_HEADERS + ${CMAKE_CURRENT_SOURCE_DIR}/browserview.h + ${CMAKE_CURRENT_SOURCE_DIR}/browserdialog.h) +endif() +if(WIN32) + list(REMOVE_ITEM ORGANIZER_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/nxmhandler_linux.cpp) + list(REMOVE_ITEM ORGANIZER_HEADERS + ${CMAKE_CURRENT_SOURCE_DIR}/nxmhandler_linux.h) +endif() + +add_executable(organizer + ${ORGANIZER_SOURCES} + ${ORGANIZER_HEADERS} + ${ORGANIZER_UI} + ${ORGANIZER_QRC}) + +set_target_properties(organizer PROPERTIES + OUTPUT_NAME "ModOrganizer" + AUTOMOC ON + AUTOUIC ON + AUTORCC ON + CXX_STANDARD 23 + CXX_STANDARD_REQUIRED ON) + +target_include_directories(organizer PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_BINARY_DIR} + # Bare includes: source uses , , etc. without uibase/ prefix + ${CMAKE_SOURCE_DIR}/libs/uibase/include/uibase + ${CMAKE_SOURCE_DIR}/libs/uibase/include/uibase/game_features) + +# Use precompiled header +target_precompile_headers(organizer PRIVATE pch.h) + +target_link_libraries(organizer PRIVATE + # Our sub-libraries + mo2::uibase + mo2::archive + libbsarch + libbsarch_OOP + mo2::bsatk + mo2::esptk + mo2::lootcli-header + # NaK FFI for game detection + Proton + mo2::nak_ffi + # Qt6 + Qt6::Widgets + $<$:Qt6::WebEngineWidgets> + Qt6::WebSockets + Qt6::Network + # Boost + Boost::program_options + # spdlog + spdlog::spdlog_header_only + # FUSE3 (low-level API) + PkgConfig::FUSE3 + # Linux system libs + dl) + +target_compile_definitions(organizer PRIVATE + SPDLOG_USE_STD_FORMAT + FUSE_USE_VERSION=35 + $<$:MO2_WEBENGINE>) + +if(NOT WIN32) + # ── Standalone VFS helper for Flatpak (runs on host via flatpak-spawn) ── + add_executable(mo2-vfs-helper + vfs/vfs_helper_main.cpp + vfs/vfstree.cpp + vfs/mo2filesystem.cpp + vfs/inodetable.cpp + vfs/overwritemanager.cpp) + # Statically link libfuse3 so the helper is fully self-contained (runs on + # the host via flatpak-spawn where the Flatpak SDK's .so files don't exist). + target_link_directories(mo2-vfs-helper PRIVATE ${FUSE3_LIBRARY_DIRS}) + target_link_libraries(mo2-vfs-helper PRIVATE + -Wl,-Bstatic -lfuse3 -Wl,-Bdynamic + Threads::Threads) + target_include_directories(mo2-vfs-helper PRIVATE ${FUSE3_INCLUDE_DIRS}) + target_compile_definitions(mo2-vfs-helper PRIVATE FUSE_USE_VERSION=35) + target_compile_features(mo2-vfs-helper PRIVATE cxx_std_23) + + option(MO2_BUNDLE_7Z_RUNTIME "Copy a Linux 7z module into organizer/dlls" ON) + option(MO2_STAGE_PYTHON_PLUGIN_PAYLOAD + "Stage shipped Python plugin payload into build plugins/ for Linux runs" ON) + option(MO2_STAGE_INSTALLER_WIZARD + "Stage installer_wizard python plugin payload (requires additional wizard package)" OFF) + set(MO2_7Z_SO_PATH "" CACHE FILEPATH + "Path to a compatible 7z shared module (exports CreateObject/GetHandlerProperty2)") + set(MO2_UPSTREAM_PLUGIN_PAYLOAD_DIR + "$ENV{HOME}/Downloads/Mod.Organizer-2.5.2/plugins" + CACHE PATH "Optional path to upstream MO2 plugins folder for pyCfg.py/data payload") + + if(MO2_BUNDLE_7Z_RUNTIME) + if(TARGET 7z) + # Use the 7z.so built from source by libs/7zip/CMakeLists.txt + add_dependencies(organizer 7z) + add_custom_command(TARGET organizer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "$/dlls" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" "$/dlls/7z.so" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" "$/dlls/7zip.dll") + message(STATUS "[MO2] Will bundle 7z.so built from source") + else() + message(WARNING + "[MO2] MO2_BUNDLE_7Z_RUNTIME is ON but the 7z target was not found. " + "Ensure libs/7zip is included in the build.") + endif() + endif() + + if(MO2_STAGE_PYTHON_PLUGIN_PAYLOAD) + add_custom_command(TARGET organizer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "$/plugins/basic_games" + COMMAND ${CMAKE_COMMAND} -E make_directory "$/plugins/data" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/libs/basic_games" + "$/plugins/basic_games" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_SOURCE_DIR}/libs/preview_dds/src/DDSPreview.py" + "${CMAKE_SOURCE_DIR}/libs/fnistool/src/FNISPatches.py" + "${CMAKE_SOURCE_DIR}/libs/fnistool/src/FNISTool.py" + "${CMAKE_SOURCE_DIR}/libs/fnistool/src/FNISToolReset.py" + "${CMAKE_SOURCE_DIR}/libs/form43_checker/src/Form43Checker.py" + "${CMAKE_SOURCE_DIR}/libs/script_extender_plugin_checker/src/ScriptExtenderPluginChecker.py" + "${CMAKE_SOURCE_DIR}/libs/winreg.py" + "${CMAKE_SOURCE_DIR}/libs/lzokay.py" + "$/plugins" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/libs/preview_dds/src/DDS" + "$/plugins/data/DDS" + ) + + if(MO2_STAGE_INSTALLER_WIZARD) + add_custom_command(TARGET organizer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "$/plugins/installer_wizard" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_SOURCE_DIR}/libs/installer_wizard/src/__init__.py" + "${CMAKE_SOURCE_DIR}/libs/installer_wizard/src/dialog.py" + "${CMAKE_SOURCE_DIR}/libs/installer_wizard/src/installer.py" + "${CMAKE_SOURCE_DIR}/libs/installer_wizard/src/runner.py" + "${CMAKE_SOURCE_DIR}/libs/installer_wizard/src/utils.py" + "$/plugins/installer_wizard" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/libs/installer_wizard/src/ui" + "$/plugins/installer_wizard/ui") + else() + add_custom_command(TARGET organizer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E rm -rf "$/plugins/installer_wizard") + endif() + + if(EXISTS "${MO2_UPSTREAM_PLUGIN_PAYLOAD_DIR}/pyCfg.py") + add_custom_command(TARGET organizer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${MO2_UPSTREAM_PLUGIN_PAYLOAD_DIR}/pyCfg.py" + "$/plugins/pyCfg.py") + endif() + + if(EXISTS "${MO2_UPSTREAM_PLUGIN_PAYLOAD_DIR}/data") + add_custom_command(TARGET organizer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${MO2_UPSTREAM_PLUGIN_PAYLOAD_DIR}/data" + "$/plugins/data") + endif() + endif() + + # ---------- Bundle umu-run (Python zipapp) ---------- + option(MO2_BUNDLE_UMU "Download and bundle umu-run launcher" ON) + set(MO2_UMU_VERSION "1.3.0" CACHE STRING "umu-launcher release version to bundle") + + if(MO2_BUNDLE_UMU) + set(UMU_TAR "${CMAKE_BINARY_DIR}/umu-launcher-${MO2_UMU_VERSION}-zipapp.tar") + set(UMU_URL "https://github.com/Open-Wine-Components/umu-launcher/releases/download/${MO2_UMU_VERSION}/umu-launcher-${MO2_UMU_VERSION}-zipapp.tar") + set(UMU_EXTRACT_DIR "${CMAKE_BINARY_DIR}/umu-extract") + + if(NOT EXISTS "${UMU_EXTRACT_DIR}/umu/umu-run") + message(STATUS "[MO2] Downloading umu-launcher ${MO2_UMU_VERSION} zipapp...") + file(DOWNLOAD "${UMU_URL}" "${UMU_TAR}" + STATUS UMU_DL_STATUS + SHOW_PROGRESS) + list(GET UMU_DL_STATUS 0 UMU_DL_CODE) + if(NOT UMU_DL_CODE EQUAL 0) + message(WARNING "[MO2] Failed to download umu-launcher: ${UMU_DL_STATUS}") + else() + file(MAKE_DIRECTORY "${UMU_EXTRACT_DIR}") + execute_process( + COMMAND ${CMAKE_COMMAND} -E tar xf "${UMU_TAR}" + WORKING_DIRECTORY "${UMU_EXTRACT_DIR}") + message(STATUS "[MO2] Extracted umu-run zipapp") + endif() + endif() + + if(EXISTS "${UMU_EXTRACT_DIR}/umu/umu-run") + add_custom_command(TARGET organizer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${UMU_EXTRACT_DIR}/umu/umu-run" + "$/umu-run" + COMMAND chmod +x "$/umu-run") + message(STATUS "[MO2] Will bundle umu-run ${MO2_UMU_VERSION}") + else() + message(WARNING "[MO2] umu-run not found after extraction") + endif() + endif() + + # Keep only the canonical Fallout NV plugin filename on Linux (case-sensitive FS). + add_custom_command(TARGET organizer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E rm -f "$/plugins/libgame_falloutnv.so") +endif() diff --git a/src/src/aboutdialog.cpp b/src/src/aboutdialog.cpp new file mode 100644 index 0000000..7c95990 --- /dev/null +++ b/src/src/aboutdialog.cpp @@ -0,0 +1,126 @@ +/* +Copyright (C) 2014 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "aboutdialog.h" +#include "shared/util.h" +#include "ui_aboutdialog.h" +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +AboutDialog::AboutDialog(const QString& version, QWidget* parent) + : QDialog(parent), ui(new Ui::AboutDialog) +{ + ui->setupUi(this); + + m_LicenseFiles[LICENSE_LGPL3] = "LGPL-v3.0.txt"; + m_LicenseFiles[LICENSE_LGPL21] = "GNU-LGPL-v2.1.txt"; + m_LicenseFiles[LICENSE_GPL3] = "GPL-v3.0.txt"; + m_LicenseFiles[LICENSE_GPL2] = "GPL-v2.0.txt"; + m_LicenseFiles[LICENSE_BOOST] = "boost.txt"; + m_LicenseFiles[LICENSE_7ZIP] = "7zip.txt"; + m_LicenseFiles[LICENSE_CCBY3] = "BY-SA-v3.0.txt"; + m_LicenseFiles[LICENSE_ZLIB] = "zlib.txt"; + m_LicenseFiles[LICENSE_PYTHON] = "python.txt"; + m_LicenseFiles[LICENSE_SSL] = "openssl.txt"; + m_LicenseFiles[LICENSE_CPPTOML] = "cpptoml.txt"; + m_LicenseFiles[LICENSE_UDIS] = "udis86.txt"; + m_LicenseFiles[LICENSE_SPDLOG] = "spdlog.txt"; + m_LicenseFiles[LICENSE_FMT] = "fmt.txt"; + m_LicenseFiles[LICENSE_SIP] = "sip.txt"; + m_LicenseFiles[LICENSE_CASTLE] = "Castle.txt"; + m_LicenseFiles[LICENSE_ANTLR] = "AntlrBuildTask.txt"; + m_LicenseFiles[LICENSE_DXTEX] = "DXTex.txt"; + m_LicenseFiles[LICENSE_VDF] = "ValveFileVDF.txt"; + + addLicense("Qt", LICENSE_LGPL3); + addLicense("Qt Json", LICENSE_GPL3); + addLicense("Boost Library", LICENSE_BOOST); + addLicense("7-zip", LICENSE_7ZIP); + addLicense("ZLib", LICENSE_NONE); + addLicense("Tango Icon Theme", LICENSE_NONE); + addLicense("RRZE Icon Set", LICENSE_CCBY3); + addLicense("Icons by Lorc, Delapouite and sbed available on http://game-icons.net", + LICENSE_CCBY3); + addLicense("Castle Core", LICENSE_CASTLE); + addLicense("ANTLR", LICENSE_ANTLR); + addLicense("LOOT", LICENSE_GPL3); + addLicense("Python", LICENSE_PYTHON); + addLicense("OpenSSL", LICENSE_SSL); + addLicense("cpptoml", LICENSE_CPPTOML); + addLicense("Udis86", LICENSE_UDIS); + addLicense("spdlog", LICENSE_SPDLOG); + addLicense("{fmt}", LICENSE_FMT); + addLicense("SIP", LICENSE_SIP); + addLicense("DXTex Headers", LICENSE_DXTEX); + addLicense("Valve File VDF Reader", LICENSE_VDF); + + ui->nameLabel->setText( + QString("%1 %2") + .arg(ui->nameLabel->text()) + .arg(version)); +#if defined(HGID) + ui->revisionLabel->setText(ui->revisionLabel->text() + " " + HGID); +#elif defined(GITID) + ui->revisionLabel->setText(ui->revisionLabel->text() + " " + GITID); +#else + ui->revisionLabel->setText(ui->revisionLabel->text() + " unknown"); +#endif + + ui->usvfsLabel->setText(ui->usvfsLabel->text() + " " + + MOShared::getUsvfsVersionString()); + ui->licenseText->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); +} + +AboutDialog::~AboutDialog() +{ + delete ui; +} + +void AboutDialog::addLicense(const QString& name, Licenses license) +{ + QListWidgetItem* item = new QListWidgetItem(name); + item->setData(Qt::UserRole, license); + ui->creditsList->addItem(item); +} + +void AboutDialog::on_creditsList_currentItemChanged(QListWidgetItem* current, + QListWidgetItem*) +{ + auto iter = m_LicenseFiles.find(current->data(Qt::UserRole).toInt()); + if (iter != m_LicenseFiles.end()) { + QString filePath = qApp->applicationDirPath() + "/licenses/" + iter->second; + QString text = MOBase::readFileText(filePath); + ui->licenseText->setText(text); + } else { + ui->licenseText->setText(tr("No license")); + } +} + +void AboutDialog::on_sourceText_linkActivated(const QString& link) +{ + MOBase::shell::Open(QUrl(link)); +} diff --git a/src/src/aboutdialog.h b/src/src/aboutdialog.h new file mode 100644 index 0000000..5784da1 --- /dev/null +++ b/src/src/aboutdialog.h @@ -0,0 +1,82 @@ +#ifndef ABOUTDIALOG_H +/* +Copyright (C) 2014 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#define ABOUTDIALOG_H + +#include +class QListWidgetItem; +#include +#include + +#include + +namespace Ui +{ +class AboutDialog; +} + +class AboutDialog : public QDialog +{ + Q_OBJECT + +public: + explicit AboutDialog(const QString& version, QWidget* parent = 0); + ~AboutDialog(); + +private: + enum Licenses + { + LICENSE_NONE, + LICENSE_LGPL3, + LICENSE_LGPL21, + LICENSE_GPL3, + LICENSE_GPL2, + LICENSE_BOOST, + LICENSE_CCBY3, + LICENSE_PYTHON, + LICENSE_SSL, + LICENSE_CPPTOML, + LICENSE_7ZIP, + LICENSE_ZLIB, + LICENSE_UDIS, + LICENSE_SPDLOG, + LICENSE_FMT, + LICENSE_SIP, + LICENSE_CASTLE, + LICENSE_ANTLR, + LICENSE_DXTEX, + LICENSE_VDF, + }; + +private: + void addLicense(const QString& name, Licenses license); + +private slots: + void on_creditsList_currentItemChanged(QListWidgetItem* current, + QListWidgetItem* previous); + void on_sourceText_linkActivated(const QString& link); + +private: + Ui::AboutDialog* ui; + + std::map m_LicenseFiles; +}; + +#endif // ABOUTDIALOG_H diff --git a/src/src/aboutdialog.ui b/src/src/aboutdialog.ui new file mode 100644 index 0000000..323e20a --- /dev/null +++ b/src/src/aboutdialog.ui @@ -0,0 +1,727 @@ + + + AboutDialog + + + + 0 + 0 + 1010 + 477 + + + + About + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 0 + 0 + + + + + 250 + 250 + + + + + 250 + 250 + + + + + + + :/MO/gui/splash + + + true + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + 0 + + + + About + + + + + + Mod Organizer + + + + + + + Revision: + + + + + + + usvfs: + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + <html><head/><body><p>Copyright © 2011-2016 Sebastian Herbord<br/>Copyright © 2016-2026 Mod Organizer 2 Contributors</p></body></html> + + + + + + + <html><head/><body><p>This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.</p><p>See the GNU General Public License for more details.</p></body></html> + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 20 + 40 + + + + + + + + <html><head/><body><p>Source code can be found at <a href="https://github.com/ModOrganizer2/modorganizer">GitHub</a>.</p></body></html> + + + + + + + + Used Software + + + + + + + + + + + + + Thanks + + + + + + Lead Developers && Maintainers + + + + + + QAbstractItemView::NoSelection + + + + LePresidente (Project Lead) + + + + + AL12 + + + + + Silarn + + + + + LostDragonist + + + + + isa + + + + + Holt59 + + + + + + + + + + + MO2 Developers && Contributors + + + + + + QAbstractItemView::NoSelection + + + + AnyOldName3 + + + + + erasmux + + + + + Project579 + + + + + przester + + + + + Qudix + + + + + RJ + + + + + Jonathan Feenstra + + + + + + + + + + + Translators + + + + + + QAbstractItemView::NoSelection + + + + Scythe1912 (Chinese) + + + + + yc0620shen (Chinese) + + + + + miraclefreak (Czech) + + + + + Meiton (Czech) + + + + + Cyb3r (Dutch) + + + + + madmedic (Dutch) + + + + + Ilja (Finnish) + + + + + Mikmet (Finnish) + + + + + Alyndiar (French) + + + + + fruttyx (French) + + + + + Jlkawaii (French) + + + + + Rigoletto (French) + + + + + Yoplala (French) + + + + + CircleOfDao (German) + + + + + Faron (German) + + + + + pndrev (German) + + + + + Xahtax (German) + + + + + Ypselon (German) + + + + + yohru (Japanese) + + + + + Mordan (Greek) + + + + + Ren (Korean) + + + + + + + + Yoosk (Polish) + + + + + Brgodfx (Portuguese) + + + + + Dasinho (Portuguese) + + + + + tokcdk (Russian) + + + + + DaWul (Spanish) + + + + + Fiama (Spanish) + + + + + Jax (Swedish) + + + + + Nubbie (Swedish) + + + + + Hakan "Nyks45" Albayrak (Turkish) + + + + + Kato (Turkish) + + + + + ...and all other contributors! + + + + + + + + + + + + + + Other Supporters && Contributors + + + + + + QAbstractItemView::NoSelection + + + + Tannin (Original Creator) + + + + + 6788 + + + + + blacksol + + + + + BlueAmulet + + + + + Bridger + + + + + Brixified + + + + + ddbb07 + + + + + deathneko11 + + + + + dekart811 + + + + + DoubleYou + + + + + Drew Warwick + + + + + erri120 + + + + + ianpatt + + + + + Falsellyu + + + + + foresto + + + + + GamerPoet + + + + + Gopher + + + + + GrantSP + + + + + GSDFan + + + + + JayLCypher + + + + + jimfcarroll + + + + + Luca|EzioTheDeadPoet + + + + + ogrotten + + + + + outdatedtv + + + + + Patchier + + + + + PurpleFez + + + + + reedts + + + + + Schilduin + + + + + SuperSandro2000 + + + + + Trosski + + + + + Uhuru + + + + + Wolverine2710 + + + + + thosrtanner + + + + + z929669 + + + + + Eddoursul + + + + + Twinki + + + + + ashemedai + + + + + + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Close + + + + + + + + + + LinkLabel + QLabel +
linklabel.h
+
+
+ + + + + + closeButton + clicked() + AboutDialog + accept() + + + 460 + 313 + + + 253 + 167 + + + + +
diff --git a/src/src/activatemodsdialog.cpp b/src/src/activatemodsdialog.cpp new file mode 100644 index 0000000..dd2d1a6 --- /dev/null +++ b/src/src/activatemodsdialog.cpp @@ -0,0 +1,96 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "activatemodsdialog.h" +#include "ui_activatemodsdialog.h" + +#include +#include +#include +#include +#include + +#include + +using namespace MOBase; + +ActivateModsDialog::ActivateModsDialog(SaveGameInfo::MissingAssets const& missingAssets, + QWidget* parent) + : TutorableDialog("ActivateMods", parent), ui(new Ui::ActivateModsDialog) +{ + ui->setupUi(this); + + QTableWidget* modsTable = findChild("modsTable"); + QHeaderView* headerView = modsTable->horizontalHeader(); + headerView->setSectionResizeMode(0, QHeaderView::Stretch); + headerView->setSectionResizeMode(1, QHeaderView::Interactive); + + int row = 0; + + modsTable->setRowCount(missingAssets.size()); + + for (SaveGameInfo::MissingAssets::const_iterator espIter = missingAssets.begin(); + espIter != missingAssets.end(); ++espIter, ++row) { + modsTable->setCellWidget(row, 0, new QLabel(espIter.key())); + if (espIter->size() == 0) { + modsTable->setCellWidget(row, 1, new QLabel(tr("not found"))); + } else { + QComboBox* combo = new QComboBox(); + for (QString const& mod : espIter.value()) { + combo->addItem(mod); + } + modsTable->setCellWidget(row, 1, combo); + } + } +} + +ActivateModsDialog::~ActivateModsDialog() +{ + delete ui; +} + +std::set ActivateModsDialog::getModsToActivate() +{ + std::set result; + QTableWidget* modsTable = findChild("modsTable"); + + for (int row = 0; row < modsTable->rowCount(); ++row) { + QComboBox* comboBox = dynamic_cast(modsTable->cellWidget(row, 1)); + if (comboBox != nullptr) { + result.insert(comboBox->currentText()); + } + } + return result; +} + +std::set ActivateModsDialog::getESPsToActivate() +{ + std::set result; + QTableWidget* modsTable = findChild("modsTable"); + + for (int row = 0; row < modsTable->rowCount(); ++row) { + QComboBox* comboBox = dynamic_cast(modsTable->cellWidget(row, 1)); + if (comboBox != nullptr) { + QLabel* espName = dynamic_cast(modsTable->cellWidget(row, 0)); + + result.insert(espName->text()); + } + } + return result; +} diff --git a/src/src/activatemodsdialog.h b/src/src/activatemodsdialog.h new file mode 100644 index 0000000..91de327 --- /dev/null +++ b/src/src/activatemodsdialog.h @@ -0,0 +1,78 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef ACTIVATEMODSDIALOG_H +#define ACTIVATEMODSDIALOG_H + +#include "savegameinfo.h" +#include "tutorabledialog.h" + +#include +#include +#include + +#include + +namespace Ui +{ +class ActivateModsDialog; +} + +/** + * @brief Dialog that is used to batch activate/deactivate mods and plugins + **/ +class ActivateModsDialog : public MOBase::TutorableDialog +{ + Q_OBJECT + +public: + /** + * @brief constructor + * + * @param missingPlugins a map containing missing plugins that need to be activated + * @param parent ... Defaults to 0. + **/ + explicit ActivateModsDialog(MOBase::SaveGameInfo::MissingAssets const& missingAssets, + QWidget* parent = 0); + ~ActivateModsDialog(); + + /** + * @brief get a list of mods that the user chose to activate + * + * @note This can of ocurse only be called after the dialog has been displayed + * + * @return set< QString > the mods to activate + **/ + std::set getModsToActivate(); + + /** + * @brief get a list of plugins that should be activated + * + * @return set< QString > the plugins to activate. This contains only plugins that + *become available after enabling the mods retrieved with getModsToActivate + **/ + std::set getESPsToActivate(); + +private slots: + +private: + Ui::ActivateModsDialog* ui; +}; + +#endif // ACTIVATEMODSDIALOG_H diff --git a/src/src/activatemodsdialog.ui b/src/src/activatemodsdialog.ui new file mode 100644 index 0000000..cd8b0ee --- /dev/null +++ b/src/src/activatemodsdialog.ui @@ -0,0 +1,97 @@ + + + ActivateModsDialog + + + + 0 + 0 + 400 + 562 + + + + Activate Mods + + + + + + This is a list of esps, esms, and esls that were active when the save game was created. + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">This is a list of esps, esms, and esls that were active when the save game was created.</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">For each esp, the right column contains the mod (or mods) that can be enabled to make the missing esps/esms available.</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">If you hit Ok, all the mods selected in the right columns and all missing esps that have become available will be activated.</span></p></body></html> + + + 2 + + + + Missing ESP + + + + + Mod + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + false + + + + + + + + + buttonBox + accepted() + ActivateModsDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + ActivateModsDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/src/apiuseraccount.cpp b/src/src/apiuseraccount.cpp new file mode 100644 index 0000000..9a22fe8 --- /dev/null +++ b/src/src/apiuseraccount.cpp @@ -0,0 +1,93 @@ +#include "apiuseraccount.h" + +QString localizedUserAccountType(APIUserAccountTypes t) +{ + switch (t) { + case APIUserAccountTypes::Regular: + return QObject::tr("Regular"); + + case APIUserAccountTypes::Premium: + return QObject::tr("Premium"); + + case APIUserAccountTypes::None: // fall-through + default: + return QObject::tr("None"); + } +} + +APIUserAccount::APIUserAccount() : m_type(APIUserAccountTypes::None) {} + +bool APIUserAccount::isValid() const +{ + return !m_key.isEmpty(); +} + +const QString& APIUserAccount::apiKey() const +{ + return m_key; +} + +const QString& APIUserAccount::id() const +{ + return m_id; +} + +const QString& APIUserAccount::name() const +{ + return m_name; +} + +APIUserAccountTypes APIUserAccount::type() const +{ + return m_type; +} + +const APILimits& APIUserAccount::limits() const +{ + return m_limits; +} + +APIUserAccount& APIUserAccount::apiKey(const QString& key) +{ + m_key = key; + return *this; +} + +APIUserAccount& APIUserAccount::id(const QString& id) +{ + m_id = id; + return *this; +} + +APIUserAccount& APIUserAccount::name(const QString& name) +{ + m_name = name; + return *this; +} + +APIUserAccount& APIUserAccount::type(APIUserAccountTypes type) +{ + m_type = type; + return *this; +} + +APIUserAccount& APIUserAccount::limits(const APILimits& limits) +{ + m_limits = limits; + return *this; +} + +int APIUserAccount::remainingRequests() const +{ + return std::max(m_limits.remainingDailyRequests, m_limits.remainingHourlyRequests); +} + +bool APIUserAccount::shouldThrottle() const +{ + return (remainingRequests() < ThrottleThreshold); +} + +bool APIUserAccount::exhausted() const +{ + return isValid() && (remainingRequests() <= 0); +} diff --git a/src/src/apiuseraccount.h b/src/src/apiuseraccount.h new file mode 100644 index 0000000..3fbabeb --- /dev/null +++ b/src/src/apiuseraccount.h @@ -0,0 +1,140 @@ +#ifndef APIUSERACCOUNT_H +#define APIUSERACCOUNT_H + +#include + +/** + * represents user account types on a mod provider website such as nexus + */ +enum class APIUserAccountTypes +{ + // not logged in + None = 0, + + // regular account + Regular, + + // premium account + Premium +}; + +QString localizedUserAccountType(APIUserAccountTypes t); + +/** + * current limits imposed on the user account + **/ +struct APILimits +{ + // maximum number of requests per day + int maxDailyRequests = 0; + + // remaining number of requests today + int remainingDailyRequests = 0; + + // maximum number of requests per hour + int maxHourlyRequests = 0; + + // remaining number of requests this hour + int remainingHourlyRequests = 0; +}; + +/** + * API statistics + */ +struct APIStats +{ + // number of API requests currently queued + int requestsQueued = 0; +}; + +/** + * represents a user account on the mod provider website + */ +class APIUserAccount +{ +public: + // when the number of remaining requests is under this number, further + // requests will be throttled by avoiding non-critical ones + static constexpr int ThrottleThreshold = 200; + + APIUserAccount(); + + /** + * whether the user is logged in + */ + bool isValid() const; + + /** + * api key + */ + const QString& apiKey() const; + + /** + * user id + */ + const QString& id() const; + + /** + * user name + */ + const QString& name() const; + + /** + * account type + */ + APIUserAccountTypes type() const; + + /** + * current API limits + */ + const APILimits& limits() const; + + /** + * sets the api key + */ + APIUserAccount& apiKey(const QString& key); + + /** + * sets the user id + */ + APIUserAccount& id(const QString& id); + + /** + * sets the user name + **/ + APIUserAccount& name(const QString& name); + + /** + * sets the account type + */ + APIUserAccount& type(APIUserAccountTypes type); + + /** + * sets the current limits + */ + APIUserAccount& limits(const APILimits& limits); + + /** + * returns the number of remaining requests + */ + int remainingRequests() const; + + /** + * whether the number of remaining requests is low enough that further + * requests should be throttled + */ + bool shouldThrottle() const; + + /** + * true if all the remaining requests have been used and the API will refuse + * further requests + */ + bool exhausted() const; + +private: + QString m_key, m_id, m_name; + APIUserAccountTypes m_type; + APILimits m_limits; +}; + +#endif // APIUSERACCOUNT_H diff --git a/src/src/app_icon.rc b/src/src/app_icon.rc new file mode 100644 index 0000000..6e79ef7 --- /dev/null +++ b/src/src/app_icon.rc @@ -0,0 +1 @@ +IDI_ICON1 ICON DISCARDABLE "mo_icon.ico" diff --git a/src/src/archivefiletree.cpp b/src/src/archivefiletree.cpp new file mode 100644 index 0000000..4365b39 --- /dev/null +++ b/src/src/archivefiletree.cpp @@ -0,0 +1,280 @@ +/* +Copyright (C) MO2 Team. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +// For QObject::tr: +#include + +#include "archivefiletree.h" + +#include "log.h" + +using namespace MOBase; + +/** + * We use custom file entries to store the index. + */ +class ArchiveFileEntry : public virtual FileTreeEntry +{ +public: + /** + * @brief Create a new entry. + * + * @param parent The tree containing this entry. + * @param name The name of this entry. + * @param index The index of the entry in the archive. + */ + ArchiveFileEntry(std::shared_ptr parent, QString name, int index) + : FileTreeEntry(parent, name), m_Index(index) + {} + + virtual std::shared_ptr clone() const override + { + return std::make_shared(nullptr, name(), m_Index); + } + + // No private since we are in an implementation file: + const int m_Index; +}; + +/** + * + */ +class ArchiveFileTreeImpl : public virtual ArchiveFileTree, + public virtual ArchiveFileEntry +{ +public: + using File = std::tuple; + +public + : // Public for make_shared (but not accessible by other since not exposed in .h): + ArchiveFileTreeImpl(std::shared_ptr parent, QString name, int index, + std::vector files) + : FileTreeEntry(parent, name), ArchiveFileEntry(parent, name, index), IFileTree(), + m_Files(std::move(files)) + {} + +public: // Override to avoid VS warnings: + virtual std::shared_ptr astree() override { return IFileTree::astree(); } + + virtual std::shared_ptr astree() const override + { + return IFileTree::astree(); + } + +protected: + virtual std::shared_ptr clone() const override + { + return IFileTree::clone(); + } + +public: // Overrides: + /** + * + */ + static void mapToArchive(IFileTree const& tree, QString path, + std::vector const& data) + { + if (path.length() > 0) { + // when using a long windows path (starting with \\?\) we apparently can have + // redundant . components in the path. This wasn't a problem with "regular" path + // names. + if (path == ".") { + path.clear(); + } else { + path.append("/"); + } + } + + for (auto const& entry : tree) { + if (entry->isDir()) { + const ArchiveFileTreeImpl& archiveEntry = + dynamic_cast(*entry); + QString tmp = path + archiveEntry.name(); + if (archiveEntry.m_Index != -1) { + data[archiveEntry.m_Index]->addOutputFilePath(tmp.toStdWString()); + } + mapToArchive(*archiveEntry.astree(), tmp, data); + } else { + const ArchiveFileEntry& archiveFileEntry = + dynamic_cast(*entry); + if (archiveFileEntry.m_Index != -1) { + data[archiveFileEntry.m_Index]->addOutputFilePath( + (path + archiveFileEntry.name()).toStdWString()); + } + } + } + } + + /** + * + */ + void mapToArchive(Archive& archive) const override + { + mapToArchive(*this, "", archive.getFileList()); + } + +protected: + /** + * Overriding makeDirectory and makeFile to create file tree or file entry with index + * -1. + * + */ + virtual std::shared_ptr + makeDirectory(std::shared_ptr parent, QString name) const override + { + return std::make_shared(parent, name, -1, std::vector{}); + } + + virtual std::shared_ptr + makeFile(std::shared_ptr parent, QString name) const override + { + return std::make_shared(parent, name, -1); + } + + virtual bool + doPopulate(std::shared_ptr parent, + std::vector>& entries) const override + { + + // Sort by name: + std::sort(std::begin(m_Files), std::end(m_Files), [](const auto& a, const auto& b) { + return std::get<0>(a)[0].compare(std::get<0>(b)[0], Qt::CaseInsensitive) < 0; + }); + + // We know that the files are sorted: + QString currentName = ""; + int currentIndex = -1; + std::vector currentFiles; + for (auto& p : m_Files) { + + // At the start or if we have reset, just retrieve the current name: + if (currentName == "") { + currentName = std::get<0>(p)[0]; + } + + // If the name is different, we need to create a directory from what we have + // accumulated: + if (currentName != std::get<0>(p)[0]) { + + // We may or may not have an index here, it depends on the type of archive (some + // archives list intermediate non-empty folders, some don't): + entries.push_back(std::make_shared( + parent, currentName, currentIndex, std::move(currentFiles))); + + currentFiles.clear(); // Back to a valid state. + + // Reset the index: + currentIndex = -1; + } + + // We can always override the current name: + currentName = std::get<0>(p)[0]; + + // If the current path contains only one components: + if (std::get<0>(p).size() == 1) { + + // If it is not a directory, then it is a file in directly under this tree: + if (!std::get<1>(p)) { + entries.push_back( + std::make_shared(parent, currentName, std::get<2>(p))); + currentName = ""; + } else { + // Otherwize, it is the actual "file" corresponding to the directory we are + // listing, so we can retrieve the index here: + currentIndex = std::get<2>(p); + } + } else { + currentFiles.push_back( + {QStringList(std::get<0>(p).begin() + 1, std::get<0>(p).end()), + std::get<1>(p), std::get<2>(p)}); + } + } + + if (currentName != "") { + entries.push_back(std::make_shared( + parent, currentName, currentIndex, std::move(currentFiles))); + } + + // Let the parent class sort the entries: + return false; + } + + virtual std::shared_ptr doClone() const override + { + return std::make_shared(nullptr, name(), m_Index, m_Files); + } + +private: + mutable std::vector m_Files; +}; + +std::shared_ptr ArchiveFileTree::makeTree(Archive const& archive) +{ + auto const& data = archive.getFileList(); + + std::vector files; + files.reserve(data.size()); + + for (size_t i = 0; i < data.size(); ++i) { + // Ignore "." and ".." as they're useless and muck things up + if (data[i]->getArchiveFilePath().compare(L".") == 0 || + data[i]->getArchiveFilePath().compare(L"..") == 0) { + continue; + } + + files.push_back( + std::make_tuple(QString::fromStdWString(data[i]->getArchiveFilePath()) + .replace("\\", "/") + .split("/", Qt::SkipEmptyParts), + data[i]->isDirectory(), (int)i)); + } + + auto tree = std::make_shared(nullptr, "", -1, std::move(files)); + return tree; +} + +/** + * @brief Recursive function for the ArchiveFileTree::mapToArchive method. Need a + * template here because iterators from a vector of entries are not exactly the same as + * the iterators returned by a IFileTree. + * + */ +template +void mapToArchive(std::vector const& data, It begin, It end) +{ + for (auto it = begin; it != end; ++it) { + auto entry = *it; + auto* aentry = dynamic_cast(entry.get()); + + if (aentry->m_Index != -1) { + data[aentry->m_Index]->addOutputFilePath(aentry->path().toStdWString()); + } + + if (entry->isDir()) { + auto tree = entry->astree(); + mapToArchive(data, tree->begin(), tree->end()); + } + } +} + +void ArchiveFileTree::mapToArchive( + Archive& archive, std::vector> const& entries) +{ + ::mapToArchive(archive.getFileList(), entries.cbegin(), entries.cend()); +} diff --git a/src/src/archivefiletree.h b/src/src/archivefiletree.h new file mode 100644 index 0000000..4635aa4 --- /dev/null +++ b/src/src/archivefiletree.h @@ -0,0 +1,71 @@ +/* +Copyright (C) MO2 Team. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef ARCHIVEFILENETRY_H +#define ARCHIVEFILENTRY_H + +#include +#include + +/** + * + */ +class ArchiveFileTree : public virtual MOBase::IFileTree +{ +public: + /** + * @brief Create a new file tree representing the given archive. + * + * @param archive Archive to represent by the file tree. + * + * @return a file tree representing the given archive. + */ + static std::shared_ptr makeTree(Archive const& archive); + + /** + * @brief Update the given archive to reflect change in this tree. + * + * This method disables files that have been removed from the file + * tree and move the ones that have been moved. + * + * @param archive The archive to update. Must be the one used to + * create the tree. + */ + virtual void mapToArchive(Archive& archive) const = 0; + + /** + * @brief Update the given archive to prepare for the extraction + * of the given entries. + * + * This method "enables" files that correspond to the given entry. + * + * @param archive The archive to update. Must be the one used to + * create the tree. + * @param entries List of entries to mark for extraction. All the entries must + * come from a tree created with the given archive. + */ + static void + mapToArchive(Archive& archive, + std::vector> const& entries); + +protected: + using IFileTree::IFileTree; +}; + +#endif diff --git a/src/src/bbcode.cpp b/src/src/bbcode.cpp new file mode 100644 index 0000000..4d8148e --- /dev/null +++ b/src/src/bbcode.cpp @@ -0,0 +1,318 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "bbcode.h" +#include +#include +#include + +namespace BBCode +{ + +namespace log = MOBase::log; + +class BBCodeMap +{ + + typedef std::map> TagMap; + +public: + static BBCodeMap& instance() + { + static BBCodeMap s_Instance; + return s_Instance; + } + + QString convertTag(QString input, int& length) + { + // extract the tag name + auto match = m_TagNameExp.match(input, 1, QRegularExpression::NormalMatch, + QRegularExpression::AnchoredMatchOption); + QString tagName = match.captured(0).toLower(); + TagMap::iterator tagIter = m_TagMap.find(tagName); + if (tagIter != m_TagMap.end()) { + // recognized tag + if (tagName.endsWith('=')) { + tagName.chop(1); + } + + int closeTagPos = 0; + int nextTagPos = 0; + int nextTagSearchIndex = input.indexOf("]"); + int closeTagLength = 0; + if (tagName == "*") { + // ends at the next bullet point + closeTagPos = + input.indexOf(QRegularExpression("(\\[\\*\\]|)", + QRegularExpression::CaseInsensitiveOption), + 3); + // leave closeTagLength at 0 because we don't want to "eat" the next bullet + // point + } else if (tagName == "line") { + // ends immediately after the tag + closeTagPos = 6; + // leave closeTagLength at 0 because there is no close tag to skip over + } else { + QRegularExpression nextTag(QString("\\[%1[=\\]]?").arg(tagName), + QRegularExpression::CaseInsensitiveOption); + QString closeTag = QString("[/%1]").arg(tagName); + closeTagPos = input.indexOf(closeTag, 0, Qt::CaseInsensitive); + nextTagPos = nextTag.match(input, nextTagSearchIndex).capturedStart(0); + while (nextTagPos != -1 && closeTagPos != -1 && nextTagPos < closeTagPos) { + closeTagPos = input.indexOf(closeTag, closeTagPos + closeTag.size(), + Qt::CaseInsensitive); + nextTagSearchIndex = input.indexOf("]", nextTagPos); + nextTagPos = nextTag.match(input, nextTagSearchIndex).capturedStart(0); + } + if (closeTagPos == -1) { + // workaround to improve compatibility: add fake closing tag + input.append(closeTag); + closeTagPos = input.size() - closeTag.size(); + } + closeTagLength = closeTag.size(); + } + + if (closeTagPos > -1) { + length = closeTagPos + closeTagLength; + QString temp = input.mid(0, length); + tagIter->second.first.setPatternOptions( + QRegularExpression::PatternOption::DotMatchesEverythingOption); + auto match = tagIter->second.first.match(temp); + if (match.hasMatch()) { + if (tagIter->second.second.isEmpty()) { + if (tagName == "color") { + QString color = match.captured(1); + QString content = match.captured(2); + if (color.at(0) == '#') { + return temp.replace(tagIter->second.first, + QString("%2") + .arg(color, content)); + } else { + auto colIter = m_ColorMap.find(color.toLower()); + if (colIter != m_ColorMap.end()) { + color = colIter->second; + } + return temp.replace(tagIter->second.first, + QString("%2") + .arg(color, content)); + } + } else { + log::warn("don't know how to deal with tag {}", tagName); + } + } else { + if (tagName == "*") { + temp.remove(QRegularExpression("(\\[/\\*\\])?(
)?$")); + } + return temp.replace(tagIter->second.first, tagIter->second.second); + } + } else { + // expression doesn't match. either the input string is invalid + // or the expression is + log::warn("{} doesn't match the expression for {}", temp, tagName); + length = 0; + return QString(); + } + } + } + + // not a recognized tag or tag invalid + length = 0; + return QString(); + } + +private: + BBCodeMap() : m_TagNameExp("[a-zA-Z*]*=?") + { + m_TagMap["b"] = + std::make_pair(QRegularExpression("\\[b\\](.*)\\[/b\\]"), "\\1"); + m_TagMap["i"] = + std::make_pair(QRegularExpression("\\[i\\](.*)\\[/i\\]"), "\\1"); + m_TagMap["u"] = + std::make_pair(QRegularExpression("\\[u\\](.*)\\[/u\\]"), "\\1"); + m_TagMap["s"] = + std::make_pair(QRegularExpression("\\[s\\](.*)\\[/s\\]"), "\\1"); + m_TagMap["sub"] = + std::make_pair(QRegularExpression("\\[sub\\](.*)\\[/sub\\]"), "\\1"); + m_TagMap["sup"] = + std::make_pair(QRegularExpression("\\[sup\\](.*)\\[/sup\\]"), "\\1"); + m_TagMap["size="] = + std::make_pair(QRegularExpression("\\[size=([^\\]]*)\\](.*)\\[/size\\]"), + "\\2"); + m_TagMap["color="] = + std::make_pair(QRegularExpression("\\[color=([^\\]]*)\\](.*)\\[/color\\]"), ""); + m_TagMap["font="] = + std::make_pair(QRegularExpression("\\[font=([^\\]]*)\\](.*)\\[/font\\]"), + "\\2"); + m_TagMap["center"] = + std::make_pair(QRegularExpression("\\[center\\](.*)\\[/center\\]"), + "
\\1
"); + m_TagMap["right"] = + std::make_pair(QRegularExpression("\\[right\\](.*)\\[/right\\]"), + "
\\1
"); + m_TagMap["quote"] = + std::make_pair(QRegularExpression("\\[quote\\](.*)\\[/quote\\]"), + "
\\1
"); + m_TagMap["quote="] = + std::make_pair(QRegularExpression("\\[quote=([^\\]]*)\\](.*)\\[/quote\\]"), + "
\\2
"); + m_TagMap["spoiler"] = + std::make_pair(QRegularExpression("\\[spoiler\\](.*)\\[/spoiler\\]"), + "
Spoiler:
Show
\\1
"); + m_TagMap["code"] = std::make_pair(QRegularExpression("\\[code\\](.*)\\[/code\\]"), + "\\1"); + m_TagMap["heading"] = + std::make_pair(QRegularExpression("\\[heading\\](.*)\\[/heading\\]"), + "

\\1

"); + m_TagMap["line"] = std::make_pair(QRegularExpression("\\[line\\]"), "
"); + + // lists + m_TagMap["list"] = + std::make_pair(QRegularExpression("\\[list\\](.*)\\[/list\\]"), "
    \\1
"); + m_TagMap["list="] = std::make_pair( + QRegularExpression("\\[list.*\\](.*)\\[/list\\]"), "
    \\1
"); + m_TagMap["ul"] = + std::make_pair(QRegularExpression("\\[ul\\](.*)\\[/ul\\]"), "
    \\1
"); + m_TagMap["ol"] = + std::make_pair(QRegularExpression("\\[ol\\](.*)\\[/ol\\]"), "
    \\1
"); + m_TagMap["li"] = + std::make_pair(QRegularExpression("\\[li\\](.*)\\[/li\\]"), "
  • \\1
  • "); + + // tables + m_TagMap["table"] = std::make_pair( + QRegularExpression("\\[table\\](.*)\\[/table\\]"), "\\1
    "); + m_TagMap["tr"] = + std::make_pair(QRegularExpression("\\[tr\\](.*)\\[/tr\\]"), "\\1"); + m_TagMap["th"] = + std::make_pair(QRegularExpression("\\[th\\](.*)\\[/th\\]"), "\\1"); + m_TagMap["td"] = + std::make_pair(QRegularExpression("\\[td\\](.*)\\[/td\\]"), "\\1"); + + // web content + m_TagMap["url"] = std::make_pair(QRegularExpression("\\[url\\](.*)\\[/url\\]"), + "\\1"); + m_TagMap["url="] = + std::make_pair(QRegularExpression("\\[url=([^\\]]*)\\](.*)\\[/url\\]"), + "\\2"); + m_TagMap["img"] = std::make_pair( + QRegularExpression( + "\\[img(?:\\s*width=\\d+\\s*,?\\s*height=\\d+)?\\](.*)\\[/img\\]"), + ""); + m_TagMap["img="] = + std::make_pair(QRegularExpression("\\[img=([^\\]]*)\\](.*)\\[/img\\]"), + "\"\\1\""); + m_TagMap["email="] = std::make_pair( + QRegularExpression("\\[email=\"?([^\\]]*)\"?\\](.*)\\[/email\\]"), + "\\2"); + m_TagMap["youtube"] = + std::make_pair(QRegularExpression("\\[youtube\\](.*)\\[/youtube\\]"), + "https://" + "www.youtube.com/watch?v=\\1"); + + // make all patterns non-greedy and case-insensitive + for (TagMap::iterator iter = m_TagMap.begin(); iter != m_TagMap.end(); ++iter) { + iter->second.first.setPatternOptions( + QRegularExpression::CaseInsensitiveOption | + QRegularExpression::InvertedGreedinessOption); + } + + // this tag is in fact greedy + m_TagMap["*"] = std::make_pair(QRegularExpression("\\[\\*\\](.*)"), "
  • \\1
  • "); + + m_ColorMap.insert(std::make_pair("red", "FF0000")); + m_ColorMap.insert(std::make_pair("green", "00FF00")); + m_ColorMap.insert(std::make_pair("blue", "0000FF")); + m_ColorMap.insert(std::make_pair("black", "000000")); + m_ColorMap.insert(std::make_pair("gray", "7F7F7F")); + m_ColorMap.insert(std::make_pair("white", "FFFFFF")); + m_ColorMap.insert(std::make_pair("yellow", "FFFF00")); + m_ColorMap.insert(std::make_pair("cyan", "00FFFF")); + m_ColorMap.insert(std::make_pair("magenta", "FF00FF")); + m_ColorMap.insert(std::make_pair("brown", "A52A2A")); + m_ColorMap.insert(std::make_pair("orange", "FFA500")); + m_ColorMap.insert(std::make_pair("gold", "FFD700")); + m_ColorMap.insert(std::make_pair("deepskyblue", "00BFFF")); + m_ColorMap.insert(std::make_pair("salmon", "FA8072")); + m_ColorMap.insert(std::make_pair("dodgerblue", "1E90FF")); + m_ColorMap.insert(std::make_pair("greenyellow", "ADFF2F")); + m_ColorMap.insert(std::make_pair("peru", "CD853F")); + } + +private: + QRegularExpression m_TagNameExp; + TagMap m_TagMap; + std::map m_ColorMap; +}; + +QString convertToHTML(const QString& inputParam) +{ + // this code goes over the input string once and replaces all bbtags + // it encounters. This function is called recursively for every replaced + // string to convert nested tags. + // + // This could be implemented simpler by applying a set of regular expressions + // for each recognized bb-tag one after the other but that would probably be + // very inefficient (O(n^2)). + + QString input = inputParam.mid(0).replace("\r\n", "
    "); + input.replace("\\\"", "\"").replace("\\'", "'"); + QString result; + int lastBlock = 0; + int pos = 0; + + // iterate over the input buffer + while ((pos = input.indexOf('[', lastBlock)) != -1) { + // append everything between the previous tag-block and the current one + result.append(input.mid(lastBlock, pos - lastBlock)); + + if ((pos < (input.size() - 1)) && (input.at(pos + 1) == '/')) { + // skip invalid end tag + int tagEnd = input.indexOf(']', pos) + 1; + if (tagEnd == 0) { + // no closing tag found + // move the pos up one so that the opening bracket is ignored next iteration + pos++; + } else { + pos = tagEnd; + } + } else { + // convert the tag and content if necessary + int length = -1; + QString replacement = BBCodeMap::instance().convertTag(input.mid(pos), length); + if (length != 0) { + result.append(convertToHTML(replacement)); + // length contains the number of characters in the original tag + pos += length; + } else { + // nothing replaced + result.append('['); + ++pos; + } + } + lastBlock = pos; + } + + // append the remainder (everything after the last tag) + result.append(input.mid(lastBlock)); + return result; +} + +} // namespace BBCode diff --git a/src/src/bbcode.h b/src/src/bbcode.h new file mode 100644 index 0000000..9889dc9 --- /dev/null +++ b/src/src/bbcode.h @@ -0,0 +1,39 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef BBCODE_H +#define BBCODE_H + +#include + +namespace BBCode +{ + +/** + * @brief convert a string with BB Code-Tags to HTML + * @param input the input string with BB tags + * @param replaceOccured if not nullptr, this parameter will be set to true if any bb + *tags were replaced + * @return the same string in html representation + **/ +QString convertToHTML(const QString& input); + +} // namespace BBCode + +#endif // BBCODE_H diff --git a/src/src/browserdialog.cpp b/src/src/browserdialog.cpp new file mode 100644 index 0000000..644fdd4 --- /dev/null +++ b/src/src/browserdialog.cpp @@ -0,0 +1,289 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "browserdialog.h" + +#include "browserview.h" +#include "messagedialog.h" +#include "persistentcookiejar.h" +#include "report.h" +#include "settings.h" +#include "ui_browserdialog.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; + +BrowserDialog::BrowserDialog(QWidget* parent) + : QDialog(parent), ui(new Ui::BrowserDialog), + m_AccessManager(new QNetworkAccessManager(this)) +{ + ui->setupUi(this); + + m_AccessManager->setCookieJar(new PersistentCookieJar(QDir::fromNativeSeparators( + Settings::instance().paths().cache() + "/cookies.dat"))); + + Qt::WindowFlags flags = + windowFlags() | Qt::WindowMaximizeButtonHint | Qt::WindowMinimizeButtonHint; + Qt::WindowFlags helpFlag = Qt::WindowContextHelpButtonHint; + flags = flags & (~helpFlag); + setWindowFlags(flags); + + m_Tabs = this->findChild("browserTabWidget"); + + installEventFilter(this); + + connect(m_Tabs, SIGNAL(tabCloseRequested(int)), this, SLOT(tabCloseRequested(int))); + + ui->urlEdit->setVisible(false); +} + +BrowserDialog::~BrowserDialog() +{ + delete ui; +} + +void BrowserDialog::closeEvent(QCloseEvent* event) +{ + Settings::instance().geometry().saveGeometry(this); + QDialog::closeEvent(event); +} + +void BrowserDialog::initTab(BrowserView* newView) +{ + // newView->page()->setNetworkAccessManager(m_AccessManager); + // newView->page()->setForwardUnsupportedContent(true); + + connect(newView, SIGNAL(loadProgress(int)), this, SLOT(progress(int))); + connect(newView, SIGNAL(titleChanged(QString)), this, SLOT(titleChanged(QString))); + connect(newView, SIGNAL(initTab(BrowserView*)), this, SLOT(initTab(BrowserView*))); + connect(newView, SIGNAL(startFind()), this, SLOT(startSearch())); + connect(newView, SIGNAL(urlChanged(QUrl)), this, SLOT(urlChanged(QUrl))); + connect(newView, SIGNAL(openUrlInNewTab(QUrl)), this, SLOT(openInNewTab(QUrl))); + connect(newView, SIGNAL(downloadRequested(QNetworkRequest)), this, + SLOT(downloadRequested(QNetworkRequest))); + connect(newView, SIGNAL(unsupportedContent(QNetworkReply*)), this, + SLOT(unsupportedContent(QNetworkReply*))); + + ui->backBtn->setEnabled(false); + ui->fwdBtn->setEnabled(false); + m_Tabs->addTab(newView, tr("new")); + newView->settings()->setAttribute(QWebEngineSettings::PluginsEnabled, true); + newView->settings()->setAttribute(QWebEngineSettings::AutoLoadImages, true); +} + +void BrowserDialog::openInNewTab(const QUrl& url) +{ + BrowserView* newView = new BrowserView(this); + initTab(newView); + newView->setUrl(url); +} + +BrowserView* BrowserDialog::getCurrentView() +{ + return qobject_cast(m_Tabs->currentWidget()); +} + +void BrowserDialog::urlChanged(const QUrl& url) +{ + BrowserView* currentView = getCurrentView(); + if (currentView != nullptr) { + ui->backBtn->setEnabled(currentView->history()->canGoBack()); + ui->fwdBtn->setEnabled(currentView->history()->canGoForward()); + } + ui->urlEdit->setText(url.toString()); +} + +void BrowserDialog::openUrl(const QUrl& url) +{ + if (isHidden()) { + Settings::instance().geometry().restoreGeometry(this); + show(); + } + openInNewTab(url); +} + +void BrowserDialog::maximizeWidth() +{ + int viewportWidth = getCurrentView()->page()->contentsSize().width(); + int frameWidth = width() - viewportWidth; + + int contentWidth = getCurrentView()->page()->contentsSize().width(); + + QScreen* screen = this->window()->windowHandle()->screen(); + int screenWidth = screen->geometry().size().width(); + + int targetWidth = std::min( + std::max(viewportWidth, contentWidth) + frameWidth, screenWidth); + this->resize(targetWidth, height()); +} + +void BrowserDialog::progress(int value) +{ + ui->loadProgress->setValue(value); + if (value == 100) { + maximizeWidth(); + ui->loadProgress->setVisible(false); + } else { + ui->loadProgress->setVisible(true); + } +} + +void BrowserDialog::titleChanged(const QString& title) +{ + BrowserView* view = qobject_cast(sender()); + for (int i = 0; i < m_Tabs->count(); ++i) { + if (m_Tabs->widget(i) == view) { + m_Tabs->setTabText(i, title.mid(0, 15)); + m_Tabs->setTabToolTip(i, title); + } + } +} + +QString BrowserDialog::guessFileName(const QString& url) +{ + QRegularExpression uploadsExp(QString("https://.+/uploads/([^/]+)$")); + auto match = uploadsExp.match(url); + if (match.hasMatch()) { + // these seem to be premium downloads + return match.captured(1); + } + + QRegularExpression filesExp(QString("https://.+\\?file=([^&]+)")); + match = filesExp.match(url); + if (match.hasMatch()) { + // a regular manual download? + return match.captured(1); + } + return "unknown"; +} + +void BrowserDialog::unsupportedContent(QNetworkReply* reply) +{ + try { + QWebEnginePage* page = qobject_cast(sender()); + if (page == nullptr) { + log::error("sender not a page"); + return; + } + /*browserview *view = qobject_cast(page->view()); + if (view == nullptr) { + log::error("no view?"); + return; + }*/ + + emit requestDownload(page->url(), reply); + } catch (const std::exception& e) { + if (isVisible()) { + MessageDialog::showMessage(tr("failed to start download"), this); + } + log::error("exception downloading unsupported content: {}", e.what()); + } +} + +void BrowserDialog::downloadRequested(const QNetworkRequest& request) +{ + log::error("download request {} ignored", request.url().toString()); +} + +void BrowserDialog::tabCloseRequested(int index) +{ + if (m_Tabs->count() == 1) { + this->close(); + } else { + m_Tabs->widget(index)->deleteLater(); + m_Tabs->removeTab(index); + } +} + +void BrowserDialog::on_backBtn_clicked() +{ + BrowserView* currentView = getCurrentView(); + if (currentView != nullptr) { + currentView->back(); + } +} + +void BrowserDialog::on_fwdBtn_clicked() +{ + BrowserView* currentView = getCurrentView(); + if (currentView != nullptr) { + currentView->forward(); + } +} + +void BrowserDialog::startSearch() +{ + ui->searchEdit->setFocus(); +} + +void BrowserDialog::on_searchEdit_returnPressed() +{ + // BrowserView *currentView = getCurrentView(); + // if (currentView != nullptr) { + // currentView->findText(ui->searchEdit->text(), + // QWebEnginePage::FindWrapsAroundDocument); + // } +} + +void BrowserDialog::on_refreshBtn_clicked() +{ + getCurrentView()->reload(); +} + +void BrowserDialog::on_browserTabWidget_currentChanged(int index) +{ + BrowserView* currentView = + qobject_cast(ui->browserTabWidget->widget(index)); + if (currentView != nullptr) { + ui->backBtn->setEnabled(currentView->history()->canGoBack()); + ui->fwdBtn->setEnabled(currentView->history()->canGoForward()); + } +} + +void BrowserDialog::on_urlEdit_returnPressed() +{ + QWebEngineView* currentView = getCurrentView(); + if (currentView != nullptr) { + currentView->setUrl(QUrl(ui->urlEdit->text())); + } +} + +bool BrowserDialog::eventFilter(QObject* object, QEvent* event) +{ + if (event->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = reinterpret_cast(event); + if ((keyEvent->modifiers() & Qt::ControlModifier) && + (keyEvent->key() == Qt::Key_U)) { + ui->urlEdit->setVisible(!ui->urlEdit->isVisible()); + return true; + } + } + return QDialog::eventFilter(object, event); +} diff --git a/src/src/browserdialog.h b/src/src/browserdialog.h new file mode 100644 index 0000000..1eaea14 --- /dev/null +++ b/src/src/browserdialog.h @@ -0,0 +1,121 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef BROWSERDIALOG_H +#define BROWSERDIALOG_H + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Ui +{ +class BrowserDialog; +} + +class BrowserView; + +/** + * @brief a dialog containing a webbrowser that is intended to browse the nexus network + **/ +class BrowserDialog : public QDialog +{ + Q_OBJECT + +public: + /** + * @brief constructor + * + * @param accessManager the access manager to use for network requests + * @param parent parent widget + **/ + explicit BrowserDialog(QWidget* parent = 0); + ~BrowserDialog(); + + /** + * @brief set the url to open. If automatic login is enabled, the url is opened after + *login + * + * @param url the url to open + **/ + void openUrl(const QUrl& url); + + virtual bool eventFilter(QObject* object, QEvent* event); +signals: + + /** + * @brief emitted when the user starts a download + * @param pageUrl url of the current web site from which the download was started + * @param reply network reply of the started download + */ + void requestDownload(const QUrl& pageUrl, QNetworkReply* reply); + +protected: + virtual void closeEvent(QCloseEvent*); + +private slots: + + void initTab(BrowserView* newView); + void openInNewTab(const QUrl& url); + + void progress(int value); + + void titleChanged(const QString& title); + void unsupportedContent(QNetworkReply* reply); + void downloadRequested(const QNetworkRequest& request); + + void tabCloseRequested(int index); + + void urlChanged(const QUrl& url); + + void on_backBtn_clicked(); + + void on_fwdBtn_clicked(); + + void on_searchEdit_returnPressed(); + + void startSearch(); + + void on_refreshBtn_clicked(); + + void on_browserTabWidget_currentChanged(int index); + + void on_urlEdit_returnPressed(); + +private: + QString guessFileName(const QString& url); + + BrowserView* getCurrentView(); + + void maximizeWidth(); + +private: + Ui::BrowserDialog* ui; + + QNetworkAccessManager* m_AccessManager; + + QTabWidget* m_Tabs; +}; + +#endif // BROWSERDIALOG_H diff --git a/src/src/browserdialog.ui b/src/src/browserdialog.ui new file mode 100644 index 0000000..c682fc5 --- /dev/null +++ b/src/src/browserdialog.ui @@ -0,0 +1,314 @@ + + + BrowserDialog + + + + 0 + 0 + 1008 + 750 + + + + Some Page + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 16777215 + 22 + + + + + + + + + 226 + 226 + 226 + + + + + + + 106 + 106 + 106 + + + + + + + 199 + 199 + 199 + + + + + + + 255 + 255 + 255 + + + + + + + 81 + 81 + 81 + + + + + + + + + 226 + 226 + 226 + + + + + + + 106 + 106 + 106 + + + + + + + 199 + 199 + 199 + + + + + + + 255 + 255 + 255 + + + + + + + 81 + 81 + 81 + + + + + + + + + 120 + 120 + 120 + + + + + + + 106 + 106 + 106 + + + + + + + 120 + 120 + 120 + + + + + + + 81 + 81 + 81 + + + + + + + 81 + 81 + 81 + + + + + + + + true + + + + 6 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + false + + + + + + + :/MO/gui/previous:/MO/gui/previous + + + false + + + true + + + + + + + + + + + :/MO/gui/next:/MO/gui/next + + + false + + + true + + + + + + + + + + + :/MO/gui/refresh:/MO/gui/refresh + + + false + + + true + + + + + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Search + + + + + + + + + + + + + Qt::DefaultContextMenu + + + true + + + + + + + 0 + + + + + + + + + + diff --git a/src/src/browserview.cpp b/src/src/browserview.cpp new file mode 100644 index 0000000..ee9523f --- /dev/null +++ b/src/src/browserview.cpp @@ -0,0 +1,77 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "browserview.h" + +#include "utility.h" +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#endif + +BrowserView::BrowserView(QWidget* parent) : QWebEngineView(parent) +{ + installEventFilter(this); + + // page()->settings()->setMaximumPagesInCache(10); +} + +QWebEngineView* BrowserView::createWindow(QWebEnginePage::WebWindowType) +{ + BrowserView* newView = new BrowserView(parentWidget()); + emit initTab(newView); + return newView; +} + +bool BrowserView::eventFilter(QObject* obj, QEvent* event) +{ + if (event->type() == QEvent::ShortcutOverride) { + QKeyEvent* keyEvent = static_cast(event); + if (keyEvent->matches(QKeySequence::Find)) { + emit startFind(); + } else if (keyEvent->matches(QKeySequence::FindNext)) { + emit findAgain(); + } + } else if (event->type() == QEvent::MouseButtonPress) { + QMouseEvent* mouseEvent = static_cast(event); + if (mouseEvent->button() == Qt::MouseButton::MiddleButton) { + mouseEvent->ignore(); + return true; + } + // TODO This is due to that QTWebEnginePage doesn't support QWebFrame anymore + // } else if (event->type() == QEvent::MouseButtonRelease) { + // QMouseEvent *mouseEvent = static_cast(event); + // if (mouseEvent->button() == Qt::MidButton) { + // QWebEngineContextMenuData hitTest = + // page()->hitTestContent(mouseEvent->pos()); if (hitTest.linkUrl().isValid()) + // { + // emit openUrlInNewTab(hitTest.linkUrl()); + // } + // mouseEvent->ignore(); + // + // return true; + // } + } + return QWebEngineView::eventFilter(obj, event); +} diff --git a/src/src/browserview.h b/src/src/browserview.h new file mode 100644 index 0000000..97801cb --- /dev/null +++ b/src/src/browserview.h @@ -0,0 +1,76 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef NEXUSVIEW_H +#define NEXUSVIEW_H + +class QEvent; +class QUrl; +class QWidget; +#include +#include + +/** + * @brief web view used to display a nexus page + **/ +class BrowserView : public QWebEngineView +{ + Q_OBJECT + +public: + explicit BrowserView(QWidget* parent = 0); + +signals: + + /** + * @brief emitted when the user opens a new window to be displayed in another tab + * + * @param newView the view for the newly opened window + **/ + void initTab(BrowserView* newView); + + /** + * @brief emitted when the user requests a link to be opened in a new tab by + * middle-clicking + * + * @param url the url to open + */ + void openUrlInNewTab(const QUrl& url); + + /** + * @brief Ctrl-f was clicked. The containing dialog should activate its find-facility + */ + void startFind(); + + /** + * @brief F3 was pressed. The containing dialog should search again + */ + void findAgain(); + +protected: + virtual QWebEngineView* createWindow(QWebEnginePage::WebWindowType type); + + virtual bool eventFilter(QObject* obj, QEvent* event); + +private: + QString m_FindPattern; + bool m_MiddleClick; +}; + +#endif // NEXUSVIEW_H diff --git a/src/src/categories.cpp b/src/src/categories.cpp new file mode 100644 index 0000000..9fbce18 --- /dev/null +++ b/src/src/categories.cpp @@ -0,0 +1,519 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "categories.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "nexusinterface.h" + +using namespace MOBase; + +CategoryFactory* CategoryFactory::s_Instance = nullptr; + +QString CategoryFactory::categoriesFilePath() +{ + return qApp->property("dataPath").toString() + "/categories.dat"; +} + +CategoryFactory::CategoryFactory() : QObject() +{ + atexit(&cleanup); +} + +QString CategoryFactory::nexusMappingFilePath() +{ + return qApp->property("dataPath").toString() + "/nexuscatmap.dat"; +} + +void CategoryFactory::loadCategories() +{ + reset(); + + QFile categoryFile(categoriesFilePath()); + bool needLoad = false; + + if (!categoryFile.open(QIODevice::ReadOnly)) { + needLoad = true; + } else { + int lineNum = 0; + while (!categoryFile.atEnd()) { + QByteArray line = categoryFile.readLine(); + ++lineNum; + QList cells = line.split('|'); + if (cells.count() == 4) { + std::vector nexusCats; + if (cells[2].length() > 0) { + QList nexusIDStrings = cells[2].split(','); + for (QList::iterator iter = nexusIDStrings.begin(); + iter != nexusIDStrings.end(); ++iter) { + bool ok = false; + int temp = iter->toInt(&ok); + if (!ok) { + log::error(tr("invalid category id {0}"), iter->constData()); + } + nexusCats.push_back(NexusCategory("Unknown", temp)); + } + } + bool cell0Ok = true; + bool cell3Ok = true; + int id = cells[0].toInt(&cell0Ok); + int parentID = cells[3].trimmed().toInt(&cell3Ok); + if (!cell0Ok || !cell3Ok) { + log::error(tr("invalid category line {0}: {1}"), lineNum, line.constData()); + } + addCategory(id, QString::fromUtf8(cells[1].constData()), nexusCats, parentID); + } else if (cells.count() == 3) { + bool cell0Ok = true; + bool cell3Ok = true; + int id = cells[0].toInt(&cell0Ok); + int parentID = cells[2].trimmed().toInt(&cell3Ok); + if (!cell0Ok || !cell3Ok) { + log::error(tr("invalid category line {0}: {1}"), lineNum, line.constData()); + } + + addCategory(id, QString::fromUtf8(cells[1].constData()), + std::vector(), parentID); + } else { + log::error(tr("invalid category line {0}: {1} ({2} cells)"), lineNum, + line.constData(), cells.count()); + } + } + categoryFile.close(); + + QFile nexusMapFile(nexusMappingFilePath()); + if (!nexusMapFile.open(QIODevice::ReadOnly)) { + needLoad = true; + } else { + int nexLineNum = 0; + while (!nexusMapFile.atEnd()) { + QByteArray nexLine = nexusMapFile.readLine(); + ++nexLineNum; + QList nexCells = nexLine.split('|'); + if (nexCells.count() == 3) { + std::vector nexusCats; + QString nexName = nexCells[1]; + bool ok = false; + int nexID = nexCells[2].toInt(&ok); + if (!ok) { + log::error(tr("invalid nexus ID {}"), nexCells[2].constData()); + } + int catID = nexCells[0].toInt(&ok); + if (!ok) { + log::error(tr("invalid category id {}"), nexCells[0].constData()); + } + m_NexusMap.insert_or_assign(nexID, NexusCategory(nexName, nexID)); + m_NexusMap.at(nexID).setCategoryID(catID); + } else { + log::error(tr("invalid nexus category line {0}: {1} ({2} cells)"), lineNum, + nexLine.constData(), nexCells.count()); + } + } + } + nexusMapFile.close(); + } + std::sort(m_Categories.begin(), m_Categories.end()); + setParents(); + if (needLoad) + loadDefaultCategories(); +} + +CategoryFactory& CategoryFactory::instance() +{ + static CategoryFactory s_Instance; + return s_Instance; +} + +void CategoryFactory::reset() +{ + m_Categories.clear(); + m_NexusMap.clear(); + m_IDMap.clear(); + addCategory(0, "None", std::vector(), 0); +} + +void CategoryFactory::setParents() +{ + for (auto& category : m_Categories) { + category.setHasChildren(false); + } + + for (const auto& category : m_Categories) { + if (category.parentID() != 0) { + std::map::const_iterator iter = + m_IDMap.find(category.parentID()); + if (iter != m_IDMap.end()) { + m_Categories[iter->second].setHasChildren(true); + } + } + } +} + +void CategoryFactory::cleanup() +{ + delete s_Instance; + s_Instance = nullptr; +} + +void CategoryFactory::saveCategories() +{ + QFile categoryFile(categoriesFilePath()); + + if (!categoryFile.open(QIODevice::WriteOnly)) { + reportError(tr("Failed to save custom categories")); + return; + } + + categoryFile.resize(0); + for (const auto& category : m_Categories) { + if (category.ID() == 0) { + continue; + } + QByteArray line; + line.append(QByteArray::number(category.ID())) + .append("|") + .append(category.name().toUtf8()) + .append("|") + .append(QByteArray::number(category.parentID())) + .append("\n"); + categoryFile.write(line); + } + categoryFile.close(); + + QFile nexusMapFile(nexusMappingFilePath()); + + if (!nexusMapFile.open(QIODevice::WriteOnly)) { + reportError(tr("Failed to save nexus category mappings")); + return; + } + + nexusMapFile.resize(0); + for (const auto& nexMap : m_NexusMap) { + QByteArray line; + line.append(QByteArray::number(nexMap.second.categoryID())).append("|"); + line.append(nexMap.second.name().toUtf8()).append("|"); + line.append(QByteArray::number(nexMap.second.ID())).append("\n"); + nexusMapFile.write(line); + } + nexusMapFile.close(); + + emit categoriesSaved(); +} + +unsigned int +CategoryFactory::countCategories(std::function filter) +{ + unsigned int result = 0; + for (const Category& cat : m_Categories) { + if (filter(cat)) { + ++result; + } + } + return result; +} + +int CategoryFactory::addCategory(const QString& name, + const std::vector& nexusCats, + int parentID) +{ + int id = 1; + while (m_IDMap.find(id) != m_IDMap.end()) { + ++id; + } + addCategory(id, name, nexusCats, parentID); + + saveCategories(); + return id; +} + +void CategoryFactory::addCategory(int id, const QString& name, int parentID) +{ + int index = static_cast(m_Categories.size()); + m_Categories.push_back( + Category(index, id, name, parentID, std::vector())); + m_IDMap[id] = index; +} + +void CategoryFactory::addCategory(int id, const QString& name, + const std::vector& nexusCats, + int parentID) +{ + for (const auto& nexusCat : nexusCats) { + m_NexusMap.insert_or_assign(nexusCat.ID(), nexusCat); + m_NexusMap.at(nexusCat.ID()).setCategoryID(id); + } + int index = static_cast(m_Categories.size()); + m_Categories.push_back(Category(index, id, name, parentID, nexusCats)); + m_IDMap[id] = index; +} + +void CategoryFactory::setNexusCategories( + const std::vector& nexusCats) +{ + for (const auto& nexusCat : nexusCats) { + m_NexusMap.emplace(nexusCat.ID(), nexusCat); + } + + saveCategories(); +} + +void CategoryFactory::refreshNexusCategories(CategoriesDialog* dialog) +{ + emit nexusCategoryRefresh(dialog); +} + +void CategoryFactory::loadDefaultCategories() +{ + // the order here is relevant as it defines the order in which the + // mods appear in the combo box + addCategory(1, "Animations", 0); + addCategory(52, "Poses", 1); + addCategory(2, "Armour", 0); + addCategory(53, "Power Armor", 2); + addCategory(3, "Audio", 0); + addCategory(38, "Music", 0); + addCategory(39, "Voice", 0); + addCategory(5, "Clothing", 0); + addCategory(41, "Jewelry", 5); + addCategory(42, "Backpacks", 5); + addCategory(6, "Collectables", 0); + addCategory(28, "Companions", 0); + addCategory(7, "Creatures, Mounts, & Vehicles", 0); + addCategory(8, "Factions", 0); + addCategory(9, "Gameplay", 0); + addCategory(27, "Combat", 9); + addCategory(43, "Crafting", 9); + addCategory(48, "Overhauls", 9); + addCategory(49, "Perks", 9); + addCategory(54, "Radio", 9); + addCategory(55, "Shouts", 9); + addCategory(22, "Skills & Levelling", 9); + addCategory(58, "Weather & Lighting", 9); + addCategory(44, "Equipment", 43); + addCategory(45, "Home/Settlement", 43); + addCategory(10, "Body, Face, & Hair", 0); + addCategory(39, "Tattoos", 10); + addCategory(40, "Character Presets", 0); + addCategory(11, "Items", 0); + addCategory(32, "Mercantile", 0); + addCategory(37, "Ammo", 11); + addCategory(19, "Weapons", 11); + addCategory(36, "Weapon & Armour Sets", 11); + addCategory(23, "Player Homes", 0); + addCategory(25, "Castles & Mansions", 23); + addCategory(51, "Settlements", 23); + addCategory(12, "Locations", 0); + addCategory(4, "Cities", 12); + addCategory(31, "Landscape Changes", 0); + addCategory(29, "Environment", 0); + addCategory(30, "Immersion", 0); + addCategory(20, "Magic", 0); + addCategory(21, "Models & Textures", 0); + addCategory(33, "Modders resources", 0); + addCategory(13, "NPCs", 0); + addCategory(24, "Bugfixes", 0); + addCategory(14, "Patches", 24); + addCategory(35, "Utilities", 0); + addCategory(26, "Cheats", 0); + addCategory(15, "Quests", 0); + addCategory(16, "Races & Classes", 0); + addCategory(34, "Stealth", 0); + addCategory(17, "UI", 0); + addCategory(18, "Visuals", 0); + addCategory(50, "Pip-Boy", 18); + addCategory(46, "Shader Presets", 0); + addCategory(47, "Miscellaneous", 0); +} + +int CategoryFactory::getParentID(unsigned int index) const +{ + if (index >= m_Categories.size()) { + throw MyException(tr("invalid category index: %1").arg(index)); + } + + return m_Categories[index].parentID(); +} + +bool CategoryFactory::categoryExists(int id) const +{ + return m_IDMap.find(id) != m_IDMap.end(); +} + +bool CategoryFactory::isDescendantOf(int id, int parentID) const +{ + // handles cycles + std::set seen; + return isDescendantOfImpl(id, parentID, seen); +} + +bool CategoryFactory::isDescendantOfImpl(int id, int parentID, + std::set& seen) const +{ + if (!seen.insert(id).second) { + log::error("cycle in category: {}", id); + return false; + } + + std::map::const_iterator iter = m_IDMap.find(id); + + if (iter != m_IDMap.end()) { + unsigned int index = iter->second; + if (m_Categories[index].parentID() == 0) { + return false; + } else if (m_Categories[index].parentID() == parentID) { + return true; + } else { + return isDescendantOfImpl(m_Categories[index].parentID(), parentID, seen); + } + } else { + log::warn(tr("{} is no valid category id"), id); + return false; + } +} + +bool CategoryFactory::hasChildren(unsigned int index) const +{ + if (index >= m_Categories.size()) { + throw MyException(tr("invalid category index: %1").arg(index)); + } + + return m_Categories[index].hasChildren(); +} + +QString CategoryFactory::getCategoryName(unsigned int index) const +{ + if (index >= m_Categories.size()) { + throw MyException(tr("invalid category index: %1").arg(index)); + } + + return m_Categories[index].name(); +} + +QString CategoryFactory::getSpecialCategoryName(SpecialCategories type) const +{ + QString label; + switch (type) { + case Checked: + label = QObject::tr("Active"); + break; + case UpdateAvailable: + label = QObject::tr("Update available"); + break; + case HasCategory: + label = QObject::tr("Has category"); + break; + case Conflict: + label = QObject::tr("Conflicted"); + break; + case HasHiddenFiles: + label = QObject::tr("Has hidden files"); + break; + case Endorsed: + label = QObject::tr("Endorsed"); + break; + case Backup: + label = QObject::tr("Has backup"); + break; + case Managed: + label = QObject::tr("Managed"); + break; + case HasGameData: + label = QObject::tr("Has valid game data"); + break; + case HasNexusID: + label = QObject::tr("Has Nexus ID"); + break; + case Tracked: + label = QObject::tr("Tracked on Nexus"); + break; + default: + return {}; + } + return QString("<%1>").arg(label); +} + +QString CategoryFactory::getCategoryNameByID(int id) const +{ + auto itor = m_IDMap.find(id); + + if (itor == m_IDMap.end()) { + return getSpecialCategoryName(static_cast(id)); + } else { + const auto index = itor->second; + if (index >= m_Categories.size()) { + return {}; + } + + return m_Categories[index].name(); + } +} + +int CategoryFactory::getCategoryID(unsigned int index) const +{ + if (index >= m_Categories.size()) { + throw MyException(tr("invalid category index: %1").arg(index)); + } + + return m_Categories[index].ID(); +} + +int CategoryFactory::getCategoryIndex(int ID) const +{ + std::map::const_iterator iter = m_IDMap.find(ID); + if (iter == m_IDMap.end()) { + throw MyException(tr("invalid category id: %1").arg(ID)); + } + return iter->second; +} + +int CategoryFactory::getCategoryID(const QString& name) const +{ + auto iter = std::find_if(m_Categories.begin(), m_Categories.end(), + [name](const Category& cat) -> bool { + return cat.name() == name; + }); + + if (iter != m_Categories.end()) { + return iter->ID(); + } else { + return -1; + } +} + +unsigned int CategoryFactory::resolveNexusID(int nexusID) const +{ + auto result = m_NexusMap.find(nexusID); + if (result != m_NexusMap.end()) { + if (m_IDMap.count(result->second.categoryID())) { + log::debug(tr("nexus category id {0} maps to internal {1}"), nexusID, + m_IDMap.at(result->second.categoryID())); + return m_IDMap.at(result->second.categoryID()); + } + } + log::debug(tr("nexus category id {} not mapped"), nexusID); + return 0U; +} diff --git a/src/src/categories.h b/src/src/categories.h new file mode 100644 index 0000000..c96b421 --- /dev/null +++ b/src/src/categories.h @@ -0,0 +1,275 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef CATEGORIES_H +#define CATEGORIES_H + +#include +#include +#include +#include + +class CategoriesDialog; + +/** + * @brief Manage the available mod categories + * @warning member functions of this class currently use a wild mix of ids and indexes + *to look up categories, optimized to where the request comes from. Therefore be very + *careful which of the two you have available + **/ +class CategoryFactory : public QObject +{ + Q_OBJECT; + + friend class CategoriesDialog; + +public: + enum SpecialCategories + { + Checked = 10000, + UpdateAvailable, + HasCategory, + Conflict, + HasHiddenFiles, + Endorsed, + Backup, + Managed, + HasGameData, + HasNexusID, + Tracked + }; + +public: + struct NexusCategory + { + NexusCategory(const QString name, const int nexusID) : m_Name(name), m_ID(nexusID) + {} + + friend bool operator==(const NexusCategory& LHS, const NexusCategory& RHS) + { + return LHS.ID() == RHS.ID(); + } + + friend bool operator==(const NexusCategory& LHS, const int RHS) + { + return LHS.ID() == RHS; + } + + friend bool operator<(const NexusCategory& LHS, const NexusCategory& RHS) + { + return LHS.ID() < RHS.ID(); + } + + QString name() const { return m_Name; } + int ID() const { return m_ID; } + int categoryID() const { return m_CategoryID; } + void setCategoryID(int categoryID) { m_CategoryID = categoryID; } + + private: + QString m_Name; + int m_ID; + int m_CategoryID = -1; + }; + + struct Category + { + Category(int sortValue, int id, const QString name, int parentID, + std::vector nexusCats) + : m_SortValue(sortValue), m_ID(id), m_Name(name), m_HasChildren(false), + m_ParentID(parentID), m_NexusCats(std::move(nexusCats)) + {} + + friend bool operator<(const Category& LHS, const Category& RHS) + { + return LHS.sortValue() < RHS.sortValue(); + } + + int sortValue() const { return m_SortValue; } + int ID() const { return m_ID; } + int parentID() const { return m_ParentID; } + QString name() const { return m_Name; } + bool hasChildren() const { return m_HasChildren; } + void setHasChildren(bool b) { m_HasChildren = b; } + + private: + int m_SortValue; + int m_ID; + int m_ParentID; + QString m_Name; + std::vector m_NexusCats; + bool m_HasChildren; + }; + +public: + /** + * @brief reset the list of categories + **/ + void reset(); + + /** + * @brief read categories from file + */ + void loadCategories(); + + /** + * @brief save the categories to the categories.dat file + **/ + void saveCategories(); + + void setNexusCategories(const std::vector& nexusCats); + + void refreshNexusCategories(CategoriesDialog* dialog); + + int addCategory(const QString& name, const std::vector& nexusCats, + int parentID); + + /** + * @brief retrieve the number of available categories + * + * @return unsigned int number of categories + **/ + size_t numCategories() const { return m_Categories.size(); } + + /** + * @brief count all categories that match a specified filter + * @param filter the filter to test + * @return number of matching categories + */ + unsigned int countCategories(std::function filter); + + /** + * @brief get the id of the parent category + * + * @param index the index to look up + * @return int id of the parent category + **/ + int getParentID(unsigned int index) const; + + /** + * @brief determine if a category exists (by id) + * + * @param id the id to check for existance + * @return true if the category exists, false otherwise + **/ + bool categoryExists(int id) const; + + /** + * @brief test if a category is child of a second one + * @param id the presumed child id + * @param parentID the parent id to test for + * @return true if id is a child of parentID + **/ + bool isDescendantOf(int id, int parentID) const; + + /** + * @brief test if the specified category has child categories + * + * @param index index of the category to look up + * @return bool true if the category has child categories + **/ + bool hasChildren(unsigned int index) const; + + /** + * @brief retrieve the name of a category + * + * @param index index of the category to look up + * @return QString name of the category + **/ + QString getCategoryName(unsigned int index) const; + QString getSpecialCategoryName(SpecialCategories type) const; + QString getCategoryNameByID(int id) const; + + /** + * @brief look up the id of a category by its index + * + * @param index index of the category to look up + * @return int id of the category + **/ + int getCategoryID(unsigned int index) const; + + /** + * @brief look up the id of a category by its name + * @note O(n) + */ + int getCategoryID(const QString& name) const; + + /** + * @brief look up the index of a category by its id + * + * @param id index of the category to look up + * @return unsigned int index of the category + **/ + int getCategoryIndex(int ID) const; + + /** + * @brief retrieve the index of a category by its nexus id + * + * @param nexusID nexus id of the category to look up + * @return unsigned int index of the category or 0 if no category matches + **/ + unsigned int resolveNexusID(int nexusID) const; + +public: + /** + * @brief retrieve a reference to the singleton instance + * + * @return the reference to the singleton + **/ + static CategoryFactory& instance(); + + /** + * @return path to the file that contains the categories list + */ + static QString categoriesFilePath(); + + /** + * @return path to the file that contains the nexus category mappings + */ + static QString nexusMappingFilePath(); + +signals: + void nexusCategoryRefresh(CategoriesDialog*); + void categoriesSaved(); + +private: + explicit CategoryFactory(); + + void loadDefaultCategories(); + + void addCategory(int id, const QString& name, + const std::vector& nexusCats, int parentID); + void addCategory(int id, const QString& name, int parentID); + + void setParents(); + + static void cleanup(); + +private: + static CategoryFactory* s_Instance; + + std::vector m_Categories; + std::map m_IDMap; + std::map m_NexusMap; + +private: + // called by isDescendantOf() + bool isDescendantOfImpl(int id, int parentID, std::set& seen) const; +}; + +#endif // CATEGORIES_H diff --git a/src/src/categoriesdialog.cpp b/src/src/categoriesdialog.cpp new file mode 100644 index 0000000..09b89ba --- /dev/null +++ b/src/src/categoriesdialog.cpp @@ -0,0 +1,399 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "categoriesdialog.h" +#include "categories.h" +#include "categoryimportdialog.h" +#include "messagedialog.h" +#include "nexusinterface.h" +#include "settings.h" +#include "ui_categoriesdialog.h" +#include "utility.h" +#include +#include +#include +#include + +class NewIDValidator : public QIntValidator +{ +public: + NewIDValidator(const std::set& ids) : m_UsedIDs(ids) {} + virtual State validate(QString& input, int& pos) const + { + State intRes = QIntValidator::validate(input, pos); + if (intRes == Acceptable) { + bool ok = false; + int id = input.toInt(&ok); + if (m_UsedIDs.find(id) != m_UsedIDs.end()) { + return QValidator::Intermediate; + } + } + return intRes; + } + +private: + const std::set& m_UsedIDs; +}; + +class ExistingIDValidator : public QIntValidator +{ +public: + ExistingIDValidator(const std::set& ids) : m_UsedIDs(ids) {} + virtual State validate(QString& input, int& pos) const + { + State intRes = QIntValidator::validate(input, pos); + if (intRes == Acceptable) { + bool ok = false; + int id = input.toInt(&ok); + if ((id == 0) || (m_UsedIDs.find(id) != m_UsedIDs.end())) { + return QValidator::Acceptable; + } else { + return QValidator::Intermediate; + } + } else { + return intRes; + } + } + +private: + const std::set& m_UsedIDs; +}; + +class ValidatingDelegate : public QItemDelegate +{ + +public: + ValidatingDelegate(QObject* parent, QValidator* validator) + : QItemDelegate(parent), m_Validator(validator) + {} + + QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem&, + const QModelIndex&) const + { + QLineEdit* edit = new QLineEdit(parent); + edit->setValidator(m_Validator); + return edit; + } + virtual void setModelData(QWidget* editor, QAbstractItemModel* model, + const QModelIndex& index) const + { + QLineEdit* edit = qobject_cast(editor); + int pos = 0; + QString editText = edit->text(); + if (m_Validator->validate(editText, pos) == QValidator::Acceptable) { + // Set ID and Parent ID as ints so sorting works as expected + if (index.column() == 0 || index.column() == 2) { + model->setData(index, editText.toInt()); + return; + } + QItemDelegate::setModelData(editor, model, index); + } + } + +private: + QValidator* m_Validator; +}; + +CategoriesDialog::CategoriesDialog(QWidget* parent) + : TutorableDialog("Categories", parent), ui(new Ui::CategoriesDialog) +{ + ui->setupUi(this); + fillTable(); + connect(ui->categoriesTable, SIGNAL(cellChanged(int, int)), this, + SLOT(cellChanged(int, int))); + if (Settings::instance().nexus().categoryMappings()) { + connect(ui->nexusRefresh, SIGNAL(clicked()), this, SLOT(nexusRefresh_clicked())); + connect(ui->nexusImportButton, SIGNAL(clicked()), this, + SLOT(nexusImport_clicked())); + ui->nexusCategoryList->setDisabled(false); + } else { + ui->nexusCategoryList->setDisabled(true); + } +} + +CategoriesDialog::~CategoriesDialog() +{ + delete ui; +} + +int CategoriesDialog::exec() +{ + GeometrySaver gs(Settings::instance(), this); + return QDialog::exec(); +} + +void CategoriesDialog::cellChanged(int row, int) +{ + int currentID = ui->categoriesTable->item(row, 0)->text().toInt(); + if (currentID > m_HighestID) { + m_HighestID = currentID; + } +} + +void CategoriesDialog::commitChanges() +{ + CategoryFactory& categories = CategoryFactory::instance(); + categories.reset(); + + for (int i = 0; i < ui->categoriesTable->rowCount(); ++i) { + int index = ui->categoriesTable->verticalHeader()->logicalIndex(i); + QVariantList nexusData; + if (ui->categoriesTable->item(index, 3) != nullptr) + nexusData = ui->categoriesTable->item(index, 3)->data(Qt::UserRole).toList(); + std::vector nexusCats; + for (auto nexusCat : nexusData) { + nexusCats.push_back(CategoryFactory::NexusCategory( + nexusCat.toList()[0].toString(), nexusCat.toList()[1].toInt())); + } + + categories.addCategory(ui->categoriesTable->item(index, 0)->text().toInt(), + ui->categoriesTable->item(index, 1)->text(), nexusCats, + ui->categoriesTable->item(index, 2)->text().toInt()); + } + + categories.setParents(); + + std::vector nexusCats; + for (int i = 0; i < ui->nexusCategoryList->count(); ++i) { + nexusCats.push_back(CategoryFactory::NexusCategory( + ui->nexusCategoryList->item(i)->data(Qt::DisplayRole).toString(), + ui->nexusCategoryList->item(i)->data(Qt::UserRole).toInt())); + } + + categories.setNexusCategories(nexusCats); + + categories.saveCategories(); +} + +void CategoriesDialog::refreshIDs() +{ + m_HighestID = 0; + for (int i = 0; i < ui->categoriesTable->rowCount(); ++i) { + int id = ui->categoriesTable->item(i, 0)->text().toInt(); + if (id > m_HighestID) { + m_HighestID = id; + } + m_IDs.insert(id); + } +} + +void CategoriesDialog::fillTable() +{ + CategoryFactory& categories = CategoryFactory::instance(); + QTableWidget* table = ui->categoriesTable; + QListWidget* list = ui->nexusCategoryList; + + table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Fixed); + table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + table->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Fixed); + table->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Stretch); + table->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); + table->verticalHeader()->setVisible(false); + table->setItemDelegateForColumn( + 0, new ValidatingDelegate(this, new NewIDValidator(m_IDs))); + table->setItemDelegateForColumn( + 2, new ValidatingDelegate(this, new ExistingIDValidator(m_IDs))); + table->setItemDelegateForColumn( + 3, new ValidatingDelegate(this, + new QRegularExpressionValidator( + QRegularExpression("([0-9]+)?(,[0-9]+)*"), this))); + + int row = 0; + for (const auto& category : categories.m_Categories) { + if (category.ID() == 0) { + --row; + continue; + } + ++row; + table->insertRow(row); + + QScopedPointer idItem(new QTableWidgetItem()); + idItem->setData(Qt::DisplayRole, category.ID()); + + QScopedPointer nameItem(new QTableWidgetItem(category.name())); + QScopedPointer parentIDItem(new QTableWidgetItem()); + parentIDItem->setData(Qt::DisplayRole, category.parentID()); + QScopedPointer nexusCatItem(new QTableWidgetItem()); + + table->setItem(row, 0, idItem.take()); + table->setItem(row, 1, nameItem.take()); + table->setItem(row, 2, parentIDItem.take()); + table->setItem(row, 3, nexusCatItem.take()); + } + + for (const auto& nexusCat : categories.m_NexusMap) { + QScopedPointer nexusItem(new QListWidgetItem()); + nexusItem->setData(Qt::DisplayRole, nexusCat.second.name()); + nexusItem->setData(Qt::UserRole, nexusCat.second.ID()); + list->addItem(nexusItem.take()); + auto item = table->item(categories.resolveNexusID(nexusCat.first) - 1, 3); + if (item != nullptr) { + auto itemData = item->data(Qt::UserRole).toList(); + QVariantList newData; + newData.append(nexusCat.second.name()); + newData.append(nexusCat.second.ID()); + itemData.insert(itemData.length(), newData); + QStringList names; + for (auto cat : itemData) { + names.append(cat.toList()[0].toString()); + } + item->setData(Qt::UserRole, itemData); + item->setData(Qt::DisplayRole, names.join(", ")); + } + } + + table->setSortingEnabled(true); + refreshIDs(); +} + +void CategoriesDialog::addCategory_clicked() +{ + int row = m_ContextRow >= 0 ? m_ContextRow : 0; + ui->categoriesTable->setSortingEnabled(false); + ui->categoriesTable->insertRow(row); + + QScopedPointer idItem(new QTableWidgetItem()); + idItem->setData(Qt::DisplayRole, ++m_HighestID); + QScopedPointer parentIDItem(new QTableWidgetItem()); + parentIDItem->setData(Qt::DisplayRole, 0); + + ui->categoriesTable->setItem(row, 0, idItem.take()); + ui->categoriesTable->setItem(row, 1, new QTableWidgetItem("new")); + ui->categoriesTable->setItem(row, 2, parentIDItem.take()); + ui->categoriesTable->setItem(row, 3, new QTableWidgetItem("")); + ui->categoriesTable->setSortingEnabled(true); +} + +void CategoriesDialog::removeCategory_clicked() +{ + if (m_ContextRow >= 0) + ui->categoriesTable->removeRow(m_ContextRow); +} + +void CategoriesDialog::removeNexusMap_clicked() +{ + if (m_ContextRow >= 0) { + ui->categoriesTable->item(m_ContextRow, 3)->setData(Qt::UserRole, QVariantList()); + ui->categoriesTable->item(m_ContextRow, 3)->setData(Qt::DisplayRole, QString()); + } +} + +void CategoriesDialog::nexusRefresh_clicked() +{ + CategoryFactory::instance().refreshNexusCategories(this); +} + +void CategoriesDialog::nexusImport_clicked() +{ + auto importDialog = CategoryImportDialog(this); + if (importDialog.exec() && importDialog.strategy()) { + refreshIDs(); + QTableWidget* table = ui->categoriesTable; + QListWidget* list = ui->nexusCategoryList; + if (importDialog.strategy() == CategoryImportDialog::Overwrite) { + table->setRowCount(0); + m_HighestID = 0; + } + int row = 0; + table->setSortingEnabled(false); + for (int i = 0; i < list->count(); ++i) { + QString name = list->item(i)->data(Qt::DisplayRole).toString(); + int nexusID = list->item(i)->data(Qt::UserRole).toInt(); + QStringList nexusLabel; + QVariantList nexusData; + nexusLabel.append(name); + QVariantList data; + data.append(QVariant(name)); + data.append(QVariant(nexusID)); + nexusData.insert(nexusData.size(), data); + QScopedPointer nexusCatItem( + new QTableWidgetItem(nexusLabel.join(", "))); + nexusCatItem->setData(Qt::UserRole, nexusData); + if (!table->findItems(name, Qt::MatchExactly).size()) { + row = table->rowCount(); + table->insertRow(table->rowCount()); + + QScopedPointer idItem(new QTableWidgetItem()); + idItem->setData(Qt::DisplayRole, ++m_HighestID); + + QScopedPointer nameItem(new QTableWidgetItem(name)); + QScopedPointer parentIDItem(new QTableWidgetItem()); + parentIDItem->setData(Qt::DisplayRole, 0); // No parent + + table->setItem(row, 0, idItem.take()); + table->setItem(row, 1, nameItem.take()); + table->setItem(row, 2, parentIDItem.take()); + + if (importDialog.assign()) { + table->setItem(row, 3, nexusCatItem.take()); + } + } else { + for (auto item : table->findItems(name, Qt::MatchContains | Qt::MatchWrap)) { + if (item->column() == 1 && item->text() == name && importDialog.remap()) { + table->setItem(item->row(), 3, nexusCatItem.take()); + } else if (importDialog.remap()) { + QScopedPointer blankItem(new QTableWidgetItem()); + blankItem->setData(Qt::UserRole, QVariantList()); + table->setItem(item->row(), 3, blankItem.get()); + } + } + } + } + table->setSortingEnabled(true); + refreshIDs(); + } +} + +void CategoriesDialog::nxmGameInfoAvailable(QString gameName, QVariant, + QVariant resultData, int) +{ + QVariantMap result = resultData.toMap(); + QVariantList categories = result["categories"].toList(); + CategoryFactory& catFactory = CategoryFactory::instance(); + QListWidget* list = ui->nexusCategoryList; + list->clear(); + for (const auto& category : categories) { + auto catMap = category.toMap(); + QScopedPointer nexusItem(new QListWidgetItem()); + nexusItem->setData(Qt::DisplayRole, catMap["name"].toString()); + nexusItem->setData(Qt::UserRole, catMap["category_id"].toInt()); + list->addItem(nexusItem.take()); + } +} + +void CategoriesDialog::nxmRequestFailed(QString, int, int, QVariant, int, int errorCode, + const QString& errorMessage) +{ + MessageDialog::showMessage( + tr("Error %1: Request to Nexus failed: %2").arg(errorCode).arg(errorMessage), + this); +} + +void CategoriesDialog::on_categoriesTable_customContextMenuRequested(const QPoint& pos) +{ + m_ContextRow = ui->categoriesTable->rowAt(pos.y()); + QMenu menu; + menu.addAction(tr("Add"), this, SLOT(addCategory_clicked())); + menu.addAction(tr("Remove"), this, SLOT(removeCategory_clicked())); + if (Settings::instance().nexus().categoryMappings()) { + menu.addAction(tr("Remove Nexus Mapping(s)"), this, SLOT(removeNexusMap_clicked())); + } + + menu.exec(ui->categoriesTable->mapToGlobal(pos)); +} diff --git a/src/src/categoriesdialog.h b/src/src/categoriesdialog.h new file mode 100644 index 0000000..df05f38 --- /dev/null +++ b/src/src/categoriesdialog.h @@ -0,0 +1,82 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef CATEGORIESDIALOG_H +#define CATEGORIESDIALOG_H + +#include "categories.h" +#include "plugincontainer.h" +#include "tutorabledialog.h" +#include + +namespace Ui +{ +class CategoriesDialog; +} + +/** + * @brief Dialog that allows users to configure mod categories + **/ +class CategoriesDialog : public MOBase::TutorableDialog +{ + Q_OBJECT + +public: + explicit CategoriesDialog(QWidget* parent = 0); + ~CategoriesDialog(); + + // also saves and restores geometry + // + int exec() override; + + /** + * @brief store changes here to the global categories store (categories.h) + * + **/ + void commitChanges(); + +public slots: + void nxmGameInfoAvailable(QString gameName, QVariant, QVariant resultData, int); + void nxmRequestFailed(QString, int, int, QVariant, int, int errorCode, + const QString& errorMessage); + +private slots: + void on_categoriesTable_customContextMenuRequested(const QPoint& pos); + void addCategory_clicked(); + void removeCategory_clicked(); + void removeNexusMap_clicked(); + void nexusRefresh_clicked(); + void nexusImport_clicked(); + void cellChanged(int row, int column); + +private: + void refreshIDs(); + void fillTable(); + +private: + Ui::CategoriesDialog* ui; + PluginContainer* m_PluginContainer; + int m_ContextRow; + + int m_HighestID; + std::set m_IDs; + std::vector m_NexusCategories; +}; + +#endif // CATEGORIESDIALOG_H diff --git a/src/src/categoriesdialog.ui b/src/src/categoriesdialog.ui new file mode 100644 index 0000000..27fbe69 --- /dev/null +++ b/src/src/categoriesdialog.ui @@ -0,0 +1,241 @@ + + + CategoriesDialog + + + + 0 + 0 + 711 + 434 + + + + Categories + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + Refresh from Nexus + + + + + + + + 0 + 0 + + + + <-- Import Nexus Cats + + + + + + + + 0 + 0 + + + + Qt::CustomContextMenu + + + true + + + false + + + false + + + QAbstractItemView::DropOnly + + + Qt::IgnoreAction + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + Qt::DashLine + + + false + + + true + + + 26 + + + 100 + + + true + + + false + + + true + + + + ID + + + Internal ID for the category. + + + Internal ID for the category. The categories a mod belongs to are stored by this ID. It is recommended you use new IDs for categories you add instead of re-using existing ones. + + + + + Name + + + The display name of the category. + + + The display name of the category. + + + + + Parent ID + + + If set, the category is defined as a sub-category of another one. Parent ID needs to be a valid category ID. + + + + + Nexus Categories + + + Comma-Separated list of Nexus IDs to be matched to the internal ID. + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + <html><head><meta name="qrichtext" content="1" /><style type="text/css"> + p, li { white-space: pre-wrap; } + </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">You can match one or multiple nexus categories to an internal ID. Whenever you download a mod from a Nexus Page, Mod Organizer will try to resolve the category defined on the Nexus to one available in MO.</span></p> + <p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8pt;"></p> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">To find out a category id used by the nexus, visit the categories list of the nexus page and hover over the links there.</span></p></body></html> + + + + + + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + Drag & drop nexus categories from this pane onto the target category on the left. + + + Qt::LeftToRight + + + Nexus Categories (Drag && Drop to Assign) + + + + + + + 0 + 0 + + + + true + + + QAbstractItemView::DragOnly + + + + + + + + + + + CategoriesTable + QTableWidget +
    categoriestable.h
    +
    +
    + + + + buttonBox + accepted() + CategoriesDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + CategoriesDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + +
    diff --git a/src/src/categoriestable.cpp b/src/src/categoriestable.cpp new file mode 100644 index 0000000..50eff1b --- /dev/null +++ b/src/src/categoriestable.cpp @@ -0,0 +1,77 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "categoriestable.h" + +CategoriesTable::CategoriesTable(QWidget* parent) : QTableWidget(parent) {} + +bool CategoriesTable::dropMimeData(int row, int column, const QMimeData* data, + Qt::DropAction action) +{ + if (row == -1) + return false; + + if (action == Qt::IgnoreAction) + return true; + + if (!data->hasFormat("application/x-qabstractitemmodeldatalist")) + return false; + + QByteArray encoded = data->data("application/x-qabstractitemmodeldatalist"); + QDataStream stream(&encoded, QIODevice::ReadOnly); + + while (!stream.atEnd()) { + int curRow, curCol; + QMap roleDataMap; + stream >> curRow >> curCol >> roleDataMap; + + for (auto item : findItems(roleDataMap.value(Qt::DisplayRole).toString(), + Qt::MatchContains | Qt::MatchWrap)) { + if (item->column() != 3) + continue; + QVariantList newData; + for (auto nexData : item->data(Qt::UserRole).toList()) { + if (nexData.toList()[1].toInt() != roleDataMap.value(Qt::UserRole)) { + newData.insert(newData.length(), nexData); + } + } + QStringList names; + for (auto nexData : newData) { + names.append(nexData.toList()[0].toString()); + } + item->setData(Qt::DisplayRole, names.join(", ")); + item->setData(Qt::UserRole, newData); + } + + auto nexusItem = item(row, 3); + auto itemData = nexusItem->data(Qt::UserRole).toList(); + QVariantList newData; + newData.append(roleDataMap.value(Qt::DisplayRole).toString()); + newData.append(roleDataMap.value(Qt::UserRole).toInt()); + itemData.insert(itemData.length(), newData); + QStringList names; + for (auto cat : itemData) { + names.append(cat.toList()[0].toString()); + } + nexusItem->setData(Qt::UserRole, itemData); + nexusItem->setData(Qt::DisplayRole, names.join(", ")); + } + + return true; +} diff --git a/src/src/categoriestable.h b/src/src/categoriestable.h new file mode 100644 index 0000000..7012eb5 --- /dev/null +++ b/src/src/categoriestable.h @@ -0,0 +1,37 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef CATEGORIESTABLE_H +#define CATEGORIESTABLE_H + +#include +#include + +class CategoriesTable : public QTableWidget +{ + Q_OBJECT +public: + CategoriesTable(QWidget* parent = 0); + +protected: + virtual bool dropMimeData(int row, int column, const QMimeData* data, + Qt::DropAction action); +}; + +#endif // CATEGORIESTABLE_H diff --git a/src/src/categoryimportdialog.cpp b/src/src/categoryimportdialog.cpp new file mode 100644 index 0000000..39132ba --- /dev/null +++ b/src/src/categoryimportdialog.cpp @@ -0,0 +1,75 @@ +#include "categoryimportdialog.h" +#include "ui_categoryimportdialog.h" + +#include "organizercore.h" + +using namespace MOBase; + +CategoryImportDialog::CategoryImportDialog(QWidget* parent) + : QDialog(parent), ui(new Ui::CategoryImportDialog) +{ + ui->setupUi(this); + connect(ui->buttonBox, &QDialogButtonBox::accepted, this, + &CategoryImportDialog::accepted); + connect(ui->buttonBox, &QDialogButtonBox::rejected, this, + &CategoryImportDialog::rejected); + connect(ui->strategyGroup, &QButtonGroup::buttonClicked, this, + &CategoryImportDialog::on_strategyClicked); + connect(ui->assignOption, &QCheckBox::clicked, this, + &CategoryImportDialog::on_assignOptionClicked); +} + +void CategoryImportDialog::accepted() +{ + accept(); +} + +void CategoryImportDialog::rejected() +{ + reject(); +} + +CategoryImportDialog::~CategoryImportDialog() +{ + delete ui; +} + +CategoryImportDialog::ImportStrategy CategoryImportDialog::strategy() +{ + if (ui->mergeOption->isChecked()) { + return ImportStrategy::Merge; + } else if (ui->replaceOption->isChecked()) { + return ImportStrategy::Overwrite; + } + return ImportStrategy::None; +} + +bool CategoryImportDialog::assign() +{ + return ui->assignOption->isChecked(); +} + +bool CategoryImportDialog::remap() +{ + return ui->remapOption->isChecked(); +} + +void CategoryImportDialog::on_strategyClicked(QAbstractButton* button) +{ + if (button == ui->replaceOption) { + ui->remapOption->setChecked(false); + ui->remapOption->setDisabled(true); + } else { + ui->remapOption->setEnabled(true); + } +} + +void CategoryImportDialog::on_assignOptionClicked(bool checked) +{ + if (checked && strategy() == ImportStrategy::Merge) { + ui->remapOption->setEnabled(true); + } else { + ui->remapOption->setChecked(false); + ui->remapOption->setDisabled(true); + } +} diff --git a/src/src/categoryimportdialog.h b/src/src/categoryimportdialog.h new file mode 100644 index 0000000..e7ada2a --- /dev/null +++ b/src/src/categoryimportdialog.h @@ -0,0 +1,44 @@ +#ifndef CATEGORYIMPORTDIALOG_H +#define CATEGORYIMPORTDIALOG_H + +#include + +namespace Ui +{ +class CategoryImportDialog; +} + +/** + * @brief Dialog that allows users to configure mod categories + **/ +class CategoryImportDialog : public QDialog +{ + Q_OBJECT + +public: + enum ImportStrategy + { + None, + Overwrite, + Merge + }; + +public: + explicit CategoryImportDialog(QWidget* parent = 0); + ~CategoryImportDialog(); + + ImportStrategy strategy(); + bool assign(); + bool remap(); + +public slots: + void accepted(); + void rejected(); + void on_strategyClicked(QAbstractButton* button); + void on_assignOptionClicked(bool clicked); + +private: + Ui::CategoryImportDialog* ui; +}; + +#endif // CATEGORYIMPORTDIALOG_H diff --git a/src/src/categoryimportdialog.ui b/src/src/categoryimportdialog.ui new file mode 100644 index 0000000..c3a18ab --- /dev/null +++ b/src/src/categoryimportdialog.ui @@ -0,0 +1,144 @@ + + + CategoryImportDialog + + + + 0 + 0 + 400 + 193 + + + + Nexus Category Import + + + + + + + 0 + 0 + + + + <h3>How do you want to import the categories?</h3> + + + + + + + + + + 0 + 0 + + + + Import Strategy + + + + + + Merge + + + strategyGroup + + + + + + + Replace + + + true + + + strategyGroup + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + Options + + + + + + Assign nexus mappings + + + true + + + + + + + false + + + Remap existing mappings + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + diff --git a/src/src/colortable.cpp b/src/src/colortable.cpp new file mode 100644 index 0000000..3bc252d --- /dev/null +++ b/src/src/colortable.cpp @@ -0,0 +1,319 @@ +#include "colortable.h" +#include "modconflicticondelegate.h" +#include "modflagicondelegate.h" +#include "settings.h" + +class ColorItem; +ColorItem* colorItemForRow(QTableWidget* table, int row); + +void paintBackground(QTableWidget* table, QPainter* p, + const QStyleOptionViewItem& option, const QModelIndex& index); + +// delegate for the sample text column; paints the background color +// +class ColoredBackgroundDelegate : public QStyledItemDelegate +{ +public: + ColoredBackgroundDelegate(QTableWidget* table) : m_table(table) {} + + void paint(QPainter* p, const QStyleOptionViewItem& option, + const QModelIndex& index) const override + { + paintBackground(m_table, p, option, index); + + QStyleOptionViewItem itemOption(option); + initStyleOption(&itemOption, index); + + // paint the default stuff like text, but override the state to avoid + // destroying the background for selected items, etc. + itemOption.state = QStyle::State_Enabled; + + QStyledItemDelegate::paint(p, itemOption, index); + } + +private: + QTableWidget* m_table; +}; + +// delegate for the icons column; paints the background and icons +// +class FakeModFlagIconDelegate : public ModFlagIconDelegate +{ +public: + explicit FakeModFlagIconDelegate(QTableWidget* table) : m_table(table) {} + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override + { + paintBackground(m_table, painter, option, index); + ModFlagIconDelegate::paintIcons(painter, option, index, getIcons(index)); + } + +protected: + QList getIcons(const QModelIndex& index) const override + { + const auto flags = {ModInfo::FLAG_BACKUP, ModInfo::FLAG_NOTENDORSED, + ModInfo::FLAG_NOTES, ModInfo::FLAG_ALTERNATE_GAME}; + + return getIconsForFlags(flags, false); + } + + size_t getNumIcons(const QModelIndex& index) const override + { + return getIcons(index).size(); + } + +private: + QTableWidget* m_table; +}; + +// delegate for the icons column; paints the background and icons +// +class FakeModConflictIconDelegate : public ModConflictIconDelegate +{ +public: + explicit FakeModConflictIconDelegate(QTableWidget* table) : m_table(table) {} + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override + { + paintBackground(m_table, painter, option, index); + ModFlagIconDelegate::paintIcons(painter, option, index, getIcons(index)); + } + +protected: + QList getIcons(const QModelIndex& index) const override + { + const auto flags = {ModInfo::FLAG_CONFLICT_MIXED, + ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE, + ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN, + ModInfo::FLAG_ARCHIVE_CONFLICT_MIXED}; + + return getIconsForFlags(flags, false); + } + + size_t getNumIcons(const QModelIndex& index) const override + { + return getIcons(index).size(); + } + +private: + QTableWidget* m_table; +}; + +// item used in the first column of the table +// +class ColorItem : public QTableWidgetItem +{ +public: + ColorItem(QString caption, QColor defaultColor, std::function get, + std::function commit) + : m_caption(std::move(caption)), m_default(defaultColor), m_get(get), + m_commit(commit) + { + setText(m_caption); + set(get()); + } + + // color caption + // + const QString& caption() const { return m_caption; } + + // the current color + // + QColor get() const { return m_temp; } + + // sets the current color, commit() must be called to save it + // + bool set(const QColor& c) + { + if (m_temp != c) { + m_temp = c; + return true; + } + + return false; + } + + // resets the current color, commit() must be called to save it + // + bool reset() { return set(m_default); } + + // saves the current color + // + void commit() { m_commit(m_temp); } + +private: + const QString m_caption; + const QColor m_default; + std::function m_get; + std::function m_commit; + QColor m_temp; +}; + +ColorItem* colorItemForRow(QTableWidget* table, int row) +{ + return dynamic_cast(table->item(row, 0)); +} + +template +void forEachColorItem(QTableWidget* table, F&& f) +{ + const auto rowCount = table->rowCount(); + + for (int i = 0; i < rowCount; ++i) { + if (auto* ci = colorItemForRow(table, i)) { + f(ci); + } + } +} + +void paintBackground(QTableWidget* table, QPainter* p, + const QStyleOptionViewItem& option, const QModelIndex& index) +{ + if (auto* ci = colorItemForRow(table, index.row())) { + p->save(); + p->fillRect(option.rect, ci->get()); + p->restore(); + } +} + +ColorTable::ColorTable(QWidget* parent) : QTableWidget(parent), m_settings(nullptr) +{ + setColumnCount(4); + setHorizontalHeaderLabels({"", "", "", ""}); + + setItemDelegateForColumn(1, new ColoredBackgroundDelegate(this)); + setItemDelegateForColumn(2, new FakeModConflictIconDelegate(this)); + setItemDelegateForColumn(3, new FakeModFlagIconDelegate(this)); + + connect(this, &QTableWidget::cellActivated, [&] { + onColorActivated(); + }); +} + +void ColorTable::load(Settings& s) +{ + m_settings = &s; + + addColor( + QObject::tr("Is overwritten (loose files)"), QColor(0, 255, 0, 64), + [this] { + return m_settings->colors().modlistOverwrittenLoose(); + }, + [this](auto&& v) { + m_settings->colors().setModlistOverwrittenLoose(v); + }); + + addColor( + QObject::tr("Is overwriting (loose files)"), QColor(255, 0, 0, 64), + [this] { + return m_settings->colors().modlistOverwritingLoose(); + }, + [this](auto&& v) { + m_settings->colors().setModlistOverwritingLoose(v); + }); + + addColor( + QObject::tr("Is overwritten (archives)"), QColor(0, 255, 255, 64), + [this] { + return m_settings->colors().modlistOverwrittenArchive(); + }, + [this](auto&& v) { + m_settings->colors().setModlistOverwrittenArchive(v); + }); + + addColor( + QObject::tr("Is overwriting (archives)"), QColor(255, 0, 255, 64), + [this] { + return m_settings->colors().modlistOverwritingArchive(); + }, + [this](auto&& v) { + m_settings->colors().setModlistOverwritingArchive(v); + }); + + addColor( + QObject::tr("Mod contains selected file"), QColor(0, 0, 255, 64), + [this] { + return m_settings->colors().modlistContainsFile(); + }, + [this](auto&& v) { + m_settings->colors().setModlistContainsFile(v); + }); + + addColor( + QObject::tr("Plugin is contained in selected mod"), QColor(0, 0, 255, 64), + [this] { + return m_settings->colors().pluginListContained(); + }, + [this](auto&& v) { + m_settings->colors().setPluginListContained(v); + }); + + addColor( + QObject::tr("Plugin is master of selected plugin"), QColor(255, 255, 0, 64), + [this] { + return m_settings->colors().pluginListMaster(); + }, + [this](auto&& v) { + m_settings->colors().setPluginListMaster(v); + }); +} + +void ColorTable::resetColors() +{ + bool changed = false; + + forEachColorItem(this, [&](auto* item) { + if (item->reset()) { + changed = true; + } + }); + + if (changed) { + update(); + } +} + +void ColorTable::commitColors() +{ + forEachColorItem(this, [](auto* item) { + item->commit(); + }); +} + +void ColorTable::addColor(const QString& text, const QColor& defaultColor, + std::function get, + std::function commit) +{ + const auto r = rowCount(); + setRowCount(r + 1); + + setItem(r, 0, new ColorItem(text, defaultColor, get, commit)); + setItem(r, 1, new QTableWidgetItem("Text")); + setItem(r, 2, new QTableWidgetItem); + + resizeColumnsToContents(); +} + +void ColorTable::onColorActivated() +{ + const auto rows = selectionModel()->selectedRows(); + if (rows.isEmpty()) { + return; + } + + const auto row = rows[0].row(); + auto* ci = colorItemForRow(this, row); + if (!ci) { + return; + } + + const QColor result = QColorDialog::getColor( + ci->get(), topLevelWidget(), ci->caption(), QColorDialog::ShowAlphaChannel); + + if (result.isValid()) { + ci->set(result); + update(model()->index(row, 1)); + } +} diff --git a/src/src/colortable.h b/src/src/colortable.h new file mode 100644 index 0000000..9c1f235 --- /dev/null +++ b/src/src/colortable.h @@ -0,0 +1,37 @@ +#ifndef COLORTABLE_H +#define COLORTABLE_H + +#include + +class Settings; + +// a QTableWidget to view and modify color settings +// +class ColorTable : public QTableWidget +{ +public: + ColorTable(QWidget* parent = nullptr); + + // adds colors to the table from the settings + // + void load(Settings& s); + + // resets the colors to their default values; commitColors() must be called + // to save them + // + void resetColors(); + + // commits any changes + // + void commitColors(); + +private: + Settings* m_settings; + + void addColor(const QString& text, const QColor& defaultColor, + std::function get, std::function commit); + + void onColorActivated(); +}; + +#endif // COLORTABLE_H diff --git a/src/src/commandline.cpp b/src/src/commandline.cpp new file mode 100644 index 0000000..1582923 --- /dev/null +++ b/src/src/commandline.cpp @@ -0,0 +1,1156 @@ +#include "commandline.h" +#include "env.h" +#include "instancemanager.h" +#include "loglist.h" +#include "messagedialog.h" +#include "multiprocess.h" +#include "organizercore.h" +#include "shared/appconfig.h" +#include "shared/util.h" +#include +#include +#include +#include +#include +#include + +namespace cl +{ + +using namespace MOBase; + +std::string pad_right(std::string s, std::size_t n, char c = ' ') +{ + if (s.size() < n) + s.append(n - s.size(), c); + + return s; +} + +// formats the list of pairs in two columns +// +std::string table(const std::vector>& v, + std::size_t indent, std::size_t spacing) +{ + std::size_t longest = 0; + + for (auto&& p : v) + longest = std::max(longest, p.first.size()); + + std::string s; + + for (auto&& p : v) { + if (!s.empty()) + s += "\n"; + + s += std::string(indent, ' ') + pad_right(p.first, longest) + " " + + std::string(spacing, ' ') + p.second; + } + + return s; +} + +CommandLine::CommandLine() : m_command(nullptr) +{ + createOptions(); + + add(); +} + +std::optional CommandLine::process(const std::wstring& line) +{ + m_originalLine = line; + try { +#ifdef _WIN32 + auto args = po::split_winmain(line); +#else + // Convert wstring args to vector for compatibility with wcommand_line_parser + auto narrow_args = po::split_unix(QString::fromStdWString(line).toStdString()); + std::vector args; + for (const auto& a : narrow_args) { + args.push_back(QString::fromStdString(a).toStdWString()); + } +#endif + if (!args.empty()) { + // remove program name + args.erase(args.begin()); + } + + // parsing the first part of the command line, including global options and + // command name, but not the rest, which will be collected below + + auto parsed = po::wcommand_line_parser(args) + .options(m_allOptions) + .positional(m_positional) + .allow_unregistered() + .run(); + + po::store(parsed, m_vm); + po::notify(m_vm); + + // collect options past the command name + auto opts = po::collect_unrecognized(parsed.options, po::include_positional); + + if (m_vm.count("command")) { + // there's a word as the first argument; this may be a command name or + // an old style exe name/binary + + const auto commandName = m_vm["command"].as(); + + // look for the command by name first + for (auto&& c : m_commands) { + if (c->name() == commandName) { + // this is a command + + // remove the command name itself + opts.erase(opts.begin()); + + try { + // legacy commands handle their own parsing, such as 'launch'; don't + // attempt to parse anything here + if (!c->legacy()) { + // parse the the remainder of the command line according to the + // command's options + po::wcommand_line_parser parser(opts); + + auto co = c->allOptions(); + parser.options(co); + + auto pos = c->positional(); + parser.positional(pos); + + parsed = parser.run(); + + po::store(parsed, m_vm); + + if (m_vm.count("help")) { + env::Console console; + std::cout << usage(c.get()) << "\n"; + return 0; + } + + // must be below the help check because it throws if required + // positional arguments are missing + po::notify(m_vm); + } + + c->set(line, m_vm, opts); + m_command = c.get(); + + return runEarly(); + } catch (po::error& e) { + env::Console console; + + std::cerr << e.what() << "\n" << usage(c.get()) << "\n"; + + return 1; + } + } + } + } + + // the first word on the command line is not a valid command, try the other + // stuff; this is used in setupCore() below when called from + // MOApplication::doOneRun() + + // look for help + if (m_vm.count("help")) { + env::Console console; + std::cout << usage() << "\n"; + return 0; + } + + if (!opts.empty()) { + const auto qs = QString::fromStdWString(opts[0]); + + if (qs.startsWith("--")) { + // assume that for something like `ModOrganizer.exe --bleh`, it's just + // a bad option instead of an executable that starts with "--" + env::Console console; + std::cerr << "\nUnrecognized option " << qs.toStdString() << "\n"; + + return 1; + } + + // try as an moshortcut:// + m_shortcut = qs; + + if (!m_shortcut.isValid()) { + // not a shortcut, try a link + if (isNxmLink(qs)) { + m_nxmLink = qs; + } else { + // assume an executable name/binary + m_executable = qs; + } + } + + // remove the shortcut/nxm/executable + opts.erase(opts.begin()); + + for (auto&& o : opts) { + m_untouched.push_back(QString::fromStdWString(o)); + } + } + + return {}; + } catch (po::error& e) { + env::Console console; + + std::cerr << e.what() << "\n" << usage() << "\n"; + + return 1; + } +} + +bool CommandLine::forwardToPrimary(MOMultiProcess& multiProcess) +{ + if (m_shortcut.isValid()) { + multiProcess.sendMessage(m_shortcut.toString()); + } else if (m_nxmLink) { + multiProcess.sendMessage(*m_nxmLink); + } else if (m_command && m_command->canForwardToPrimary()) { +#ifdef _WIN32 + multiProcess.sendMessage(QString::fromWCharArray(GetCommandLineW())); +#else + multiProcess.sendMessage(QString::fromStdWString(m_originalLine)); +#endif + } else { + return false; + } + + return true; +} + +std::optional CommandLine::runEarly() +{ + if (m_vm.count("logs")) { + // in loglist.h + logToStdout(true); + } + + if (m_command) { + return m_command->runEarly(); + } + + return {}; +} + +std::optional CommandLine::runPostApplication(MOApplication& a) +{ + // handle -i with no arguments + if (m_vm.count("instance") && m_vm["instance"].as() == "") { + env::Console c; + + if (auto i = InstanceManager::singleton().currentInstance()) { + std::cout << i->displayName().toStdString() << "\n"; + } else { + std::cout << "no instance configured\n"; + } + + return 0; + } + + if (m_command) { + return m_command->runPostApplication(a); + } + + return {}; +} + +std::optional CommandLine::runPostMultiProcess(MOMultiProcess& mp) +{ + if (m_command) { + return m_command->runPostMultiProcess(mp); + } + + return {}; +} + +std::optional CommandLine::runPostOrganizer(OrganizerCore& core) +{ + if (m_shortcut.isValid()) { + if (m_shortcut.hasExecutable()) { + try { + // make sure MO doesn't exit even if locking is disabled, ForceWait and + // PreventExit will do that + core.processRunner() + .setFromShortcut(m_shortcut) + .setWaitForCompletion(ProcessRunner::ForCommandLine, UILocker::PreventExit) + .run(); + + return 0; + } catch (std::exception&) { + // user was already warned + return 1; + } + } + } else if (m_nxmLink) { + log::debug("starting download from command line: {}", *m_nxmLink); + core.downloadRequestedNXM(*m_nxmLink); + } else if (m_executable) { + const QString exeName = *m_executable; + log::debug("starting {} from command line", exeName); + + try { + // pass the remaining parameters to the binary + // + // make sure MO doesn't exit even if locking is disabled, ForceWait and + // PreventExit will do that + core.processRunner() + .setFromFileOrExecutable(exeName, m_untouched) + .setWaitForCompletion(ProcessRunner::ForCommandLine, UILocker::PreventExit) + .run(); + + return 0; + } catch (const std::exception& e) { + reportError(QObject::tr("failed to start application: %1").arg(e.what())); + return 1; + } + } else if (m_command) { + return m_command->runPostOrganizer(core); + } + + return {}; +} + +void CommandLine::clear() +{ + m_vm.clear(); + m_shortcut = {}; + m_nxmLink = {}; +} + +void CommandLine::createOptions() +{ + m_visibleOptions.add_options()("help", "show this message") + + ("multiple", "allow multiple MO processes to run; see below") + + ("pick", "show the select instance dialog on startup") + + ("logs", "duplicates the logs to stdout") + + ("instance,i", po::value()->implicit_value(""), + "use the given instance (defaults to last used)") + + ("profile,p", po::value(), + "use the given profile (defaults to last used)"); + + po::options_description options; + options.add_options()("command", po::value(), "command")( + "subargs", po::value>(), "args"); + + // one command name, followed by any arguments for that command + m_positional.add("command", 1).add("subargs", -1); + + m_allOptions.add(m_visibleOptions); + m_allOptions.add(options); +} + +std::string CommandLine::usage(const Command* c) const +{ + std::ostringstream oss; + + oss << "\n" + << "Usage:\n"; + + if (c) { + oss << " ModOrganizer.exe [global-options] " << c->usageLine() << "\n\n"; + + const std::string more = c->moreInfo(); + if (!more.empty()) { + oss << more << "\n\n"; + } + + oss << "Command options:\n" << c->visibleOptions() << "\n"; + } else { + oss << " ModOrganizer.exe [options] [[command] [command-options]]\n" + << "\n" + << "Commands:\n"; + + // name and description for all commands + std::vector> v; + for (auto&& c : m_commands) { + // don't show legacy commands + if (c->legacy()) { + continue; + } + + v.push_back({c->name(), c->description()}); + } + + oss << table(v, 2, 4) << "\n" + << "\n"; + } + + oss << "Global options:\n" << m_visibleOptions << "\n"; + + // show the more text unless this is usage for a specific command + if (!c) { + oss << "\n" << more() << "\n"; + } + + return oss.str(); +} + +bool CommandLine::pick() const +{ + return (m_vm.count("pick") > 0); +} + +bool CommandLine::multiple() const +{ + return (m_vm.count("multiple") > 0); +} + +std::optional CommandLine::profile() const +{ + if (m_vm.count("profile")) { + return QString::fromStdString(m_vm["profile"].as()); + } + + return {}; +} + +std::optional CommandLine::instance() const +{ + // note that moshortcut:// overrides -i + + if (m_shortcut.isValid() && m_shortcut.hasInstance()) { + return m_shortcut.instanceName(); + } else if (m_vm.count("instance")) { + return QString::fromStdString(m_vm["instance"].as()); + } + + return {}; +} + +const MOShortcut& CommandLine::shortcut() const +{ + return m_shortcut; +} + +std::optional CommandLine::nxmLink() const +{ + return m_nxmLink; +} + +std::optional CommandLine::executable() const +{ + return m_executable; +} + +const QStringList& CommandLine::untouched() const +{ + return m_untouched; +} + +std::string CommandLine::more() const +{ + return "Multiple processes\n" + " A note on terminology: 'instance' can either mean an MO process\n" + " that's running on the system, or a set of mods and profiles managed\n" + " by MO. To avoid confusion, the term 'process' is used below for the\n" + " former.\n" + " \n" + " --multiple can be used to allow multiple MO processes to run\n" + " simultaneously. This is unsupported and can create all sorts of weird\n" + " problems. To minimize these:\n" + " \n" + " 1) Never have multiple MO processes running that manage the same\n" + " game instance.\n" + " 2) If an executable is launched from an MO process, only this\n" + " process may launch executables until all processes are \n" + " terminated.\n" + " \n" + " It is recommended to close _all_ MO processes as soon as multiple\n" + " processes become unnecessary."; +} + +Command::Meta::Meta(std::string n, std::string d, std::string u, std::string m) + : name(n), description(d), usage(u), more(m) +{} + +std::string Command::name() const +{ + return meta().name; +} + +std::string Command::description() const +{ + return meta().description; +} + +std::string Command::usageLine() const +{ + return name() + " " + meta().usage; +} + +std::string Command::moreInfo() const +{ + return meta().more; +} + +po::options_description Command::allOptions() const +{ + po::options_description d; + + d.add(visibleOptions()); + d.add(getInternalOptions()); + + return d; +} + +po::options_description Command::visibleOptions() const +{ + po::options_description d(getVisibleOptions()); + + d.add_options()("help", "shows this message"); + + return d; +} + +po::positional_options_description Command::positional() const +{ + return getPositional(); +} + +bool Command::legacy() const +{ + return false; +} + +po::options_description Command::getVisibleOptions() const +{ + // no-op + return {}; +} + +po::options_description Command::getInternalOptions() const +{ + // no-op + return {}; +} + +po::positional_options_description Command::getPositional() const +{ + // no-op + return {}; +} + +void Command::set(const std::wstring& originalLine, po::variables_map vm, + std::vector untouched) +{ + m_original = originalLine; + m_vm = vm; + m_untouched = untouched; +} + +std::optional Command::runEarly() +{ + return {}; +} + +std::optional Command::runPostApplication(MOApplication& a) +{ + return {}; +} + +std::optional Command::runPostMultiProcess(MOMultiProcess&) +{ + return {}; +} + +std::optional Command::runPostOrganizer(OrganizerCore&) +{ + return {}; +} + +bool Command::canForwardToPrimary() const +{ + return false; +} + +const std::wstring& Command::originalCmd() const +{ + return m_original; +} + +const po::variables_map& Command::vm() const +{ + return m_vm; +} + +const std::vector& Command::untouched() const +{ + return m_untouched; +} + +po::options_description CrashDumpCommand::getVisibleOptions() const +{ + po::options_description d; + + d.add_options()("type", po::value()->default_value("mini"), + "mini|data|full"); + + return d; +} + +Command::Meta CrashDumpCommand::meta() const +{ + return {"crashdump", "writes a crashdump for a running process of MO", "[options]", + ""}; +} + +std::optional CrashDumpCommand::runEarly() +{ + env::Console console; + + const auto typeString = vm()["type"].as(); + const auto type = env::coreDumpTypeFromString(typeString); + + // dump + const auto b = env::coredumpOther(type); + if (!b) { + std::wcerr << L"\n>>>> a minidump file was not written\n\n"; + } + + std::wcerr << L"Press enter to continue..."; + std::wcin.get(); + + return (b ? 0 : 1); +} + +Command::Meta LaunchCommand::meta() const +{ + return {"launch", "(internal, do not use)", "", ""}; +} + +bool LaunchCommand::legacy() const +{ + return true; +} + +std::optional LaunchCommand::runEarly() +{ +#ifdef _WIN32 + // needs at least the working directory and process name + if (untouched().size() < 2) { + return 1; + } + + std::vector arg; + auto args = UntouchedCommandLineArguments(2, arg); + + return SpawnWaitProcess(arg[1].c_str(), args); +#else + // The launch command is a Windows-specific internal command for spawning + // processes from within the virtualized directory; not applicable on Linux + log::error("The 'launch' command is not supported on Linux"); + return 1; +#endif +} + +#ifdef _WIN32 +int LaunchCommand::SpawnWaitProcess(LPCWSTR workingDirectory, LPCWSTR commandLine) +{ + PROCESS_INFORMATION pi{0}; + STARTUPINFO si{0}; + si.cb = sizeof(si); + std::wstring commandLineCopy = commandLine; + + if (!CreateProcessW(NULL, &commandLineCopy[0], NULL, NULL, FALSE, 0, NULL, + workingDirectory, &si, &pi)) { + // A bit of a problem where to log the error message here, at least this way you can + // get the message using a either DebugView or a live debugger: + std::wostringstream ost; + ost << L"CreateProcess failed: " << commandLine << ", " << GetLastError(); + OutputDebugStringW(ost.str().c_str()); + return -1; + } + + WaitForSingleObject(pi.hProcess, INFINITE); + + DWORD exitCode = (DWORD)-1; + ::GetExitCodeProcess(pi.hProcess, &exitCode); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + return static_cast(exitCode); +} + +// Parses the first parseArgCount arguments of the current process command line and +// returns them in parsedArgs, the rest of the command line is returned untouched. +LPCWSTR +LaunchCommand::UntouchedCommandLineArguments(int parseArgCount, + std::vector& parsedArgs) +{ + LPCWSTR cmd = GetCommandLineW(); + LPCWSTR arg = nullptr; // to skip executable name + for (; parseArgCount >= 0 && *cmd; ++cmd) { + if (*cmd == '"') { + int escaped = 0; + for (++cmd; *cmd && (*cmd != '"' || escaped % 2 != 0); ++cmd) + escaped = *cmd == '\\' ? escaped + 1 : 0; + } + if (*cmd == ' ') { + if (arg) + if (cmd - 1 > arg && *arg == '"' && *(cmd - 1) == '"') + parsedArgs.push_back(std::wstring(arg + 1, cmd - 1)); + else + parsedArgs.push_back(std::wstring(arg, cmd)); + arg = cmd + 1; + --parseArgCount; + } + } + return cmd; +} +#endif + +Command::Meta RunCommand::meta() const +{ + return {"run", "runs a program, file or a configured executable", "[options] NAME", + + "Runs a program or a file with the virtual filesystem. If NAME is a path\n" + "to a non-executable file, the program that is associated with the file\n" + "extension is run instead. With -e, NAME must refer to the name of an\n" + "executable in the instance (for example, \"SKSE\")."}; +} + +po::options_description RunCommand::getVisibleOptions() const +{ + po::options_description d; + + d.add_options()("executable,e", + po::value()->default_value(false)->zero_tokens(), + "the name is a configured executable name")( + "arguments,a", po::value(), "override arguments")( + "cwd,c", po::value(), "override working directory"); + + return d; +} + +po::options_description RunCommand::getInternalOptions() const +{ + po::options_description d; + + d.add_options()("NAME", po::value()->required(), + "program or executable name"); + + return d; +} + +po::positional_options_description RunCommand::getPositional() const +{ + po::positional_options_description d; + + d.add("NAME", 1); + + return d; +} + +bool RunCommand::canForwardToPrimary() const +{ + return true; +} + +std::optional RunCommand::runPostOrganizer(OrganizerCore& core) +{ + const auto program = QString::fromStdString(vm()["NAME"].as()); + + try { + // make sure MO doesn't exit even if locking is disabled, ForceWait and + // PreventExit will do that + auto p = core.processRunner(); + + if (vm()["executable"].as()) { + const auto& exes = *core.executablesList(); + + // case sensitive + auto itor = exes.find(program, true); + if (itor == exes.end()) { + // case insensitive + itor = exes.find(program, false); + + if (itor == exes.end()) { + // not found + reportError( + QObject::tr("Executable '%1' not found in instance '%2'.") + .arg(program) + .arg(InstanceManager::singleton().currentInstance()->displayName())); + + return 1; + } + } + + p.setFromExecutable(*itor); + } else { + p.setFromFile(nullptr, QFileInfo(program)); + } + + if (vm().count("arguments")) { + p.setArguments(QString::fromStdString(vm()["arguments"].as())); + } + + if (vm().count("cwd")) { + p.setCurrentDirectory(QString::fromStdString(vm()["cwd"].as())); + } + + p.setWaitForCompletion(ProcessRunner::ForCommandLine, UILocker::PreventExit); + + const auto r = p.run(); + if (r == ProcessRunner::Error) { + reportError( + QObject::tr("Failed to run '%1'. The logs might have more information.") + .arg(program)); + + return 1; + } + + return 0; + } catch (const std::exception& e) { + reportError( + QObject::tr("Failed to run '%1'. The logs might have more information. %2") + .arg(program) + .arg(e.what())); + + return 1; + } +} + +Command::Meta ReloadPluginCommand::meta() const +{ + return {"reload-plugin", "reloads the given plugin", "PLUGIN", ""}; +} + +po::options_description ReloadPluginCommand::getInternalOptions() const +{ + po::options_description d; + + d.add_options()("PLUGIN", po::value()->required(), "plugin name"); + + return d; +} + +po::positional_options_description ReloadPluginCommand::getPositional() const +{ + po::positional_options_description d; + + d.add("PLUGIN", 1); + + return d; +} + +bool ReloadPluginCommand::canForwardToPrimary() const +{ + return true; +} + +std::optional ReloadPluginCommand::runPostOrganizer(OrganizerCore& core) +{ + const QString name = QString::fromStdString(vm()["PLUGIN"].as()); + + QString filepath = + QDir(AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath())) + .absoluteFilePath(name); + + log::debug("reloading plugin from {}", filepath); + core.pluginContainer().reloadPlugin(filepath); + + return {}; +} + +Command::Meta DownloadFileCommand::meta() const +{ + return {"download", "downloads a file", "URL", ""}; +} + +po::options_description DownloadFileCommand::getInternalOptions() const +{ + po::options_description d; + + d.add_options()("URL", po::value()->required(), "file URL"); + + return d; +} + +po::positional_options_description DownloadFileCommand::getPositional() const +{ + po::positional_options_description d; + + d.add("URL", 1); + + return d; +} + +bool DownloadFileCommand::canForwardToPrimary() const +{ + return true; +} + +std::optional DownloadFileCommand::runPostOrganizer(OrganizerCore& core) +{ + const QString url = QString::fromStdString(vm()["URL"].as()); + + if (!url.startsWith("https://")) { + reportError(QObject::tr("Download URL must start with https://")); + return 1; + } + + log::debug("starting direct download from command line: {}", url.toStdString()); + MessageDialog::showMessage(QObject::tr("Download started"), qApp->activeWindow(), + false); + core.downloadManager()->startDownloadURLs(QStringList() << url); + + return {}; +} + +Command::Meta RefreshCommand::meta() const +{ + return {"refresh", "refreshes MO (same as F5)", "", ""}; +} + +bool RefreshCommand::canForwardToPrimary() const +{ + return true; +} + +std::optional RefreshCommand::runPostOrganizer(OrganizerCore& core) +{ + core.refresh(); + return {}; +} + +Command::Meta CreatePortableCommand::meta() const +{ + return {"create-portable", "creates a portable MO2 instance", "[options]", + "Creates a new portable MO2 instance with a generated launch script\n" + "and configuration."}; +} + +po::options_description CreatePortableCommand::getVisibleOptions() const +{ + po::options_description d; + + d.add_options() + ("name", po::value()->required(), "instance name") + ("game", po::value(), "target game (e.g., falloutnv, skyrimse)") + ("game-path", po::value(), "path to the game installation") + ("prefix", po::value(), "Wine prefix path") + ("proton", po::value(), "Proton path") + ("output", po::value()->default_value("."), "output directory"); + + return d; +} + +po::options_description CreatePortableCommand::getInternalOptions() const +{ + return {}; +} + +po::positional_options_description CreatePortableCommand::getPositional() const +{ + return {}; +} + +std::optional CreatePortableCommand::runEarly() +{ + env::Console console; + + const auto name = QString::fromStdString(vm()["name"].as()); + const auto outputDir = QString::fromStdString(vm()["output"].as()); + + const QString instanceDir = QDir(outputDir).filePath(name); + + if (QDir(instanceDir).exists()) { + std::cerr << "Error: directory already exists: " << instanceDir.toStdString() << "\n"; + return 1; + } + + // Create directory structure + const QStringList dirs = { + "mods", + "profiles/Default", + "downloads", + "overwrite" + }; + + for (const auto& dir : dirs) { + if (!QDir().mkpath(QDir(instanceDir).filePath(dir))) { + std::cerr << "Error: failed to create directory: " << dir.toStdString() << "\n"; + return 1; + } + } + + // Create empty profile files + const QStringList profileFiles = {"modlist.txt", "plugins.txt", "loadorder.txt", "initweaks.ini"}; + for (const auto& f : profileFiles) { + QFile file(QDir(instanceDir).filePath("profiles/Default/" + f)); + file.open(QIODevice::WriteOnly); + file.close(); + } + + // Generate ModOrganizer.ini + { + QSettings ini(QDir(instanceDir).filePath("ModOrganizer.ini"), QSettings::IniFormat); + + if (vm().count("game")) { + ini.setValue("General/gameName", QString::fromStdString(vm()["game"].as())); + } + if (vm().count("game-path")) { + ini.setValue("General/gamePath", QString::fromStdString(vm()["game-path"].as())); + } + ini.setValue("General/portable", true); + + ini.setValue("Settings/download_directory", "%BASE_DIR%/downloads"); + ini.setValue("Settings/mod_directory", "%BASE_DIR%/mods"); + ini.setValue("Settings/overwrite_directory", "%BASE_DIR%/overwrite"); + ini.setValue("Settings/profile_local_inis", true); + + if (vm().count("prefix")) { + ini.setValue("fluorine/prefix_path", QString::fromStdString(vm()["prefix"].as())); + } + if (vm().count("proton")) { + ini.setValue("fluorine/proton_path", QString::fromStdString(vm()["proton"].as())); + } + ini.setValue("fluorine/use_umu", true); + + ini.sync(); + } + + // Generate ModOrganizer.sh + { + const bool flatpak = QFileInfo::exists(QStringLiteral("/.flatpak-info")); + const QString launchPath = QDir(instanceDir).filePath("ModOrganizer.sh"); + QFile launchFile(launchPath); + if (launchFile.open(QIODevice::WriteOnly | QIODevice::Text)) { + QTextStream out(&launchFile); + out << "#!/bin/bash\n"; + out << "# Auto-generated by fluorine-manager\n"; + out << "INSTANCE_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n"; + if (flatpak) { + out << "exec flatpak run com.fluorine.manager --instance \"${INSTANCE_DIR}\" \"$@\"\n"; + } else { + out << "MO2_BIN=\"$(which ModOrganizer 2>/dev/null || echo ModOrganizer)\"\n"; + out << "exec \"${MO2_BIN}\" --instance \"${INSTANCE_DIR}\" \"$@\"\n"; + } + launchFile.close(); + QFile::setPermissions(launchPath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner | + QFileDevice::ReadGroup | QFileDevice::ExeGroup | + QFileDevice::ReadOther | QFileDevice::ExeOther); + } + } + + std::cout << "Portable instance created: " << instanceDir.toStdString() << "\n"; + std::cout << " Launch with: " << QDir(instanceDir).filePath("ModOrganizer.sh").toStdString() << "\n"; + + return 0; +} + +Command::Meta ListInstancesCommand::meta() const +{ + return {"list-instances", "lists portable MO2 instances", "", + "Searches for portable MO2 instances in common locations."}; +} + +std::optional ListInstancesCommand::runEarly() +{ + env::Console console; + + // Check common locations for portable instances + const QStringList searchPaths = { + QDir::currentPath(), + QDir::homePath(), + QDir(QDir::homePath()).filePath(".local/share/fluorine"), + }; + + bool found = false; + for (const auto& searchPath : searchPaths) { + QDir dir(searchPath); + if (!dir.exists()) continue; + + for (const auto& entry : dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + const QString iniPath = QDir(dir.filePath(entry)).filePath("ModOrganizer.ini"); + if (QFile::exists(iniPath)) { + QSettings ini(iniPath, QSettings::IniFormat); + if (ini.value("General/portable", false).toBool()) { + if (!found) { + std::cout << "Portable instances:\n"; + found = true; + } + const auto gameName = ini.value("General/gameName", "unknown").toString(); + std::cout << " " << entry.toStdString() + << " (" << gameName.toStdString() << ")" + << " - " << dir.filePath(entry).toStdString() << "\n"; + } + } + } + } + + if (!found) { + std::cout << "No portable instances found.\n"; + } + + return 0; +} + +Command::Meta InfoCommand::meta() const +{ + return {"info", "shows instance configuration", "INSTANCE", + "Shows configuration details for the given portable instance path."}; +} + +po::options_description InfoCommand::getInternalOptions() const +{ + po::options_description d; + + d.add_options()("INSTANCE", po::value()->required(), "instance path"); + + return d; +} + +po::positional_options_description InfoCommand::getPositional() const +{ + po::positional_options_description d; + + d.add("INSTANCE", 1); + + return d; +} + +std::optional InfoCommand::runEarly() +{ + env::Console console; + + const auto instancePath = QString::fromStdString(vm()["INSTANCE"].as()); + const QString iniPath = QDir(instancePath).filePath("ModOrganizer.ini"); + + if (!QFile::exists(iniPath)) { + std::cerr << "Error: no ModOrganizer.ini found at " << instancePath.toStdString() << "\n"; + return 1; + } + + QSettings ini(iniPath, QSettings::IniFormat); + + std::cout << "Instance: " << instancePath.toStdString() << "\n"; + std::cout << " Game: " << ini.value("General/gameName", "not set").toString().toStdString() << "\n"; + std::cout << " Game Path: " << ini.value("General/gamePath", "not set").toString().toStdString() << "\n"; + std::cout << " Portable: " << (ini.value("General/portable", false).toBool() ? "yes" : "no") << "\n"; + std::cout << " Mods Dir: " << ini.value("Settings/mod_directory", "not set").toString().toStdString() << "\n"; + std::cout << " Downloads: " << ini.value("Settings/download_directory", "not set").toString().toStdString() << "\n"; + + const auto prefix = ini.value("fluorine/prefix_path").toString(); + const auto proton = ini.value("fluorine/proton_path").toString(); + if (!prefix.isEmpty()) + std::cout << " Prefix: " << prefix.toStdString() << "\n"; + if (!proton.isEmpty()) + std::cout << " Proton: " << proton.toStdString() << "\n"; + std::cout << " UMU: " << (ini.value("fluorine/use_umu", false).toBool() ? "yes" : "no") << "\n"; + + return 0; +} + +} // namespace cl diff --git a/src/src/commandline.h b/src/src/commandline.h new file mode 100644 index 0000000..b466a51 --- /dev/null +++ b/src/src/commandline.h @@ -0,0 +1,416 @@ +#ifndef MODORGANIZER_COMMANDLINE_INCLUDED +#define MODORGANIZER_COMMANDLINE_INCLUDED +#include "moshortcut.h" +#include +#include + +class OrganizerCore; +class MOApplication; +class MOMultiProcess; + +namespace cl +{ + +namespace po = boost::program_options; + +// base class for all commands +// +class Command +{ +public: + virtual ~Command() = default; + + // command name, used on the command line to invoke it; this is meta().name + // + std::string name() const; + + // command short description, shown in general help; this is + // meta().description + // + std::string description() const; + + // usage line, puts together the name and whatever meta().usage is + // + std::string usageLine() const; + + // shown after the usage line; this is meta().more + // + std::string moreInfo() const; + + // returns all options for this command, including hidden ones; used to parse + // the command line + // + po::options_description allOptions() const; + + // returns visible options, used to display usage + // + po::options_description visibleOptions() const; + + // returns positional arguments + // + po::positional_options_description positional() const; + + // when this returns true, the command line is not parsed at all and doRun() + // is expected to use untouched() + // + virtual bool legacy() const; + + // remembers the given values + // + void set(const std::wstring& originalLine, po::variables_map vm, + std::vector untouched); + + // called as soon as the command line has been parsed; return something to + // exit immediately + // + virtual std::optional runEarly(); + + // called as soon as the MOApplication has been created, which is also the + // first time where Qt stuff is available + // + virtual std::optional runPostApplication(MOApplication& a); + + // called as soon as the multi process checks have confirmed that this is + // a primary instance; return something to exit immediately + // + virtual std::optional runPostMultiProcess(MOMultiProcess& mp); + + // called after the OrganizerCore has been initialized; return something to + // exit immediatley + // + virtual std::optional runPostOrganizer(OrganizerCore& core); + + // whether this command can be forwarded to the primary instance if one is + // already running + // + // if an instance is already running and this returns true, the whole command + // line is sent as a message to the primary instance, which will construct a + // new CommandLine and call runPostOrganizer() with it + // + // if an instance is already running and this returns false, the "an instance + // is already running" error dialog will be shown + // + // if this is the primary instance, this function is not called + // + virtual bool canForwardToPrimary() const; + +protected: + // meta information about this command, returned by derived classes + // + struct Meta + { + std::string name, description, usage, more; + + Meta(std::string name, std::string description, std::string usage, + std::string more); + }; + + // meta + // + virtual Meta meta() const = 0; + + // returns visible options specific to this command + // + virtual po::options_description getVisibleOptions() const; + + // returns hidden options specific to this command + // + virtual po::options_description getInternalOptions() const; + + // returns positional arguments specific to this command + // + virtual po::positional_options_description getPositional() const; + + // returns the original command line + // + const std::wstring& originalCmd() const; + + // variables + // + const po::variables_map& vm() const; + + // returns unparsed options, only used by launch + // + const std::vector& untouched() const; + +private: + std::wstring m_original; + po::variables_map m_vm; + std::vector m_untouched; +}; + +// generates a crash dump for another MO process +// +class CrashDumpCommand : public Command +{ +protected: + po::options_description getVisibleOptions() const override; + Meta meta() const override; + std::optional runEarly() override; +}; + +// this is the `launch` command used when starting a process from within the +// virtualized directory, see processrunner.cpp +// +// it has its own parsing of the command line to extract the argument after +// `launch` and use it as the cwd of the process, but pass the remaining +// arguments verbatim +// +// this is very old code that should probably never be changed +// +// note that it's actually buggy; in particular, it doesn't handle multiple +// whitespace between arguments +// +class LaunchCommand : public Command +{ +public: + bool legacy() const override; + +protected: + Meta meta() const override; + std::optional runEarly() override; + +#ifdef _WIN32 + int SpawnWaitProcess(LPCWSTR workingDirectory, LPCWSTR commandLine); + + LPCWSTR UntouchedCommandLineArguments(int parseArgCount, + std::vector& parsedArgs); +#endif +}; + +// runs a program or an executable +// +class RunCommand : public Command +{ +protected: + Meta meta() const override; + + po::options_description getVisibleOptions() const override; + po::options_description getInternalOptions() const override; + po::positional_options_description getPositional() const override; + + bool canForwardToPrimary() const override; + std::optional runPostOrganizer(OrganizerCore& core) override; +}; + +// reloads the given plugin +// +class ReloadPluginCommand : public Command +{ +protected: + Meta meta() const override; + + po::options_description getInternalOptions() const override; + po::positional_options_description getPositional() const override; + + bool canForwardToPrimary() const override; + std::optional runPostOrganizer(OrganizerCore& core) override; +}; + +// downloads a file +// +class DownloadFileCommand : public Command +{ +protected: + Meta meta() const override; + + po::options_description getInternalOptions() const override; + po::positional_options_description getPositional() const override; + + bool canForwardToPrimary() const override; + std::optional runPostOrganizer(OrganizerCore& core) override; +}; + +// refreshes mo +// +class RefreshCommand : public Command +{ +protected: + Meta meta() const override; + bool canForwardToPrimary() const override; + std::optional runPostOrganizer(OrganizerCore& core) override; +}; + +// creates a portable MO2 instance with directory structure and config +// +class CreatePortableCommand : public Command +{ +protected: + Meta meta() const override; + + po::options_description getVisibleOptions() const override; + po::options_description getInternalOptions() const override; + po::positional_options_description getPositional() const override; + + std::optional runEarly() override; +}; + +// lists all portable instances +// +class ListInstancesCommand : public Command +{ +protected: + Meta meta() const override; + std::optional runEarly() override; +}; + +// shows information about an instance +// +class InfoCommand : public Command +{ +protected: + Meta meta() const override; + + po::options_description getInternalOptions() const override; + po::positional_options_description getPositional() const override; + + std::optional runEarly() override; +}; + +// parses the command line and runs any given command +// +// the command line used to support a few commands but with no real conventions; +// those are mostly preserved for backwards compatibility, but deprecated: +// +// - moshortcut:// for desktop/taskbar shortcuts, may contain an instance name +// and a configured executable or arbitrary binary (still used in MO for +// shortcuts) +// +// - nxm:// links +// +// - the name of a configured executable or path to binary, followed by +// arbitrary parameters, forwarded to the program +// +// any command added CommandLine will unfortunately break any executable with +// the same name: `ModOrganizer.exe run` used to launch a program named "run" +// but will now execute the command "run" +// +// if moshortcut:// is detected and has an instance, it will override -i if both +// are given +// +// +// the command is used in two phases: +// +// 1) run() is called in main() shortly after startup; it parses the command +// line and runs the given command, if any +// +// 2) if the command did not request to exit, the instance is loaded +// in main() et al. and setupCore() is called; it handles moshortcut, +// nxm links and starting executables/binaries +// +// either can return an exit code, which will make MO exit immediately +// +class CommandLine +{ +public: + CommandLine(); + + // parses the given command line and calls runEarly() on the appropriate + // command, if any + // + // returns an empty optional if execution should continue, or a return code + // if MO must quit + // + std::optional process(const std::wstring& line); + + // called as soon as the MOApplication has been created; this handles a few + // global actions and forwards to the command, if any + // + std::optional runPostApplication(MOApplication& a); + + // calls Command::runPostMultiProcess() on the command, if any + // + std::optional runPostMultiProcess(MOMultiProcess& mp); + + // calls Command::runPostOrganizer() on the command, if any + // + // if MO wasn't invoked with a valid command, this also handles moshortcut, + // nxm links and starting processes + // + std::optional runPostOrganizer(OrganizerCore& core); + + // called when this instance is not the primary one + // + // if a command was executed and the command returns true for + // canForwardToPrimary(), this forwards the command line as an external + // message and returns true + // + // if the command returns false for canForwardToPrimary(), returns false + // + // if there was no valid command on the command line, this also forwards + // moshortcut and nxm links + // + // if there was no command, moshortcut or nxm links, this returns false, which + // will end up displaying an error message about an instance already running + // + bool forwardToPrimary(MOMultiProcess& multiProcess); + + // clears parsed options, used when MO is "restarted" so the options aren't + // processed again + // + void clear(); + + // global usage string plus usage for the given command, if any + // + std::string usage(const Command* c = nullptr) const; + + // whether --pick was given + // + bool pick() const; + + // whether --multiple was given + // + bool multiple() const; + + // profile override (-p) + // + std::optional profile() const; + + // instance override (-i) + // + std::optional instance() const; + + // returns the data parsed from an moshortcut:// option, if any + // + const MOShortcut& shortcut() const; + + // returns the nxm:// link, if any + // + std::optional nxmLink() const; + + // returns the executable/binary, if any + // + std::optional executable() const; + + // returns the list of arguments, excluding moshortcut or executable name; + // deprecated, only use with executable() + // + const QStringList& untouched() const; + +private: + po::options_description m_visibleOptions, m_allOptions; + po::positional_options_description m_positional; + std::vector> m_commands; + po::variables_map m_vm; + MOShortcut m_shortcut; + std::optional m_nxmLink; + std::optional m_executable; + QStringList m_untouched; + Command* m_command; + std::wstring m_originalLine; + + void createOptions(); + std::string more() const; + + template + void add() + { + (m_commands.push_back(std::make_unique()), ...); + } + + std::optional runEarly(); +}; + +} // namespace cl + +#endif // MODORGANIZER_COMMANDLINE_INCLUDED diff --git a/src/src/copyeventfilter.cpp b/src/src/copyeventfilter.cpp new file mode 100644 index 0000000..901e797 --- /dev/null +++ b/src/src/copyeventfilter.cpp @@ -0,0 +1,49 @@ +#include "copyeventfilter.h" + +#include +#include +#include + +CopyEventFilter::CopyEventFilter(QAbstractItemView* view, int column, int role) + : CopyEventFilter(view, [=](auto& index) { + return index.sibling(index.row(), column).data(role).toString(); + }) +{} + +CopyEventFilter::CopyEventFilter(QAbstractItemView* view, + std::function format) + : QObject(view), m_view(view), m_format(format) +{} + +void CopyEventFilter::copySelection() const +{ + if (!m_view->selectionModel()->hasSelection()) { + return; + } + + // sort to reflect the visual order + QModelIndexList selectedRows = m_view->selectionModel()->selectedRows(); + std::sort(selectedRows.begin(), selectedRows.end(), + [=](const auto& lidx, const auto& ridx) { + return m_view->visualRect(lidx).top() < m_view->visualRect(ridx).top(); + }); + + QStringList rows; + for (auto& idx : selectedRows) { + rows.append(m_format(idx)); + } + + QGuiApplication::clipboard()->setText(rows.join("\n")); +} + +bool CopyEventFilter::eventFilter(QObject* sender, QEvent* event) +{ + if (sender == m_view && event->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(event); + if (keyEvent->modifiers() == Qt::ControlModifier && keyEvent->key() == Qt::Key_C) { + copySelection(); + return true; + } + } + return QObject::eventFilter(sender, event); +} diff --git a/src/src/copyeventfilter.h b/src/src/copyeventfilter.h new file mode 100644 index 0000000..255dcdd --- /dev/null +++ b/src/src/copyeventfilter.h @@ -0,0 +1,41 @@ +#ifndef COPY_EVENT_FILTER_H +#define COPY_EVENT_FILTER_H + +#include + +#include +#include +#include + +// this small class provides copy on Ctrl+C and also +// exposes a method to actual copy the selection +// +// the way the selection is copied can be customized by +// passing a functor to format each index, by default +// it only extracts the display role +// +// only works for view that selects whole row since it only +// considers the first cell in each row +// +class CopyEventFilter : public QObject +{ + Q_OBJECT + +public: + CopyEventFilter(QAbstractItemView* view, int column = 0, int role = Qt::DisplayRole); + CopyEventFilter(QAbstractItemView* view, + std::function format); + + // copy the selection of the view associated with this + // event filter into the clipboard + // + void copySelection() const; + + bool eventFilter(QObject* sender, QEvent* event) override; + +private: + QAbstractItemView* m_view; + std::function m_format; +}; + +#endif diff --git a/src/src/createinstancedialog.cpp b/src/src/createinstancedialog.cpp new file mode 100644 index 0000000..b01d436 --- /dev/null +++ b/src/src/createinstancedialog.cpp @@ -0,0 +1,538 @@ +#include "createinstancedialog.h" +#include "createinstancedialogpages.h" +#include "instancemanager.h" +#include "settings.h" +#include "shared/appconfig.h" +#include "shared/util.h" +#include "ui_createinstancedialog.h" +#include +#include + +using namespace MOBase; + +class Failed +{}; + +// create() will create all the directories in `target`; if any path component +// fails to create, it will throw Failed +// +// unless commit() is called, all the created directories will be deleted in +// the destructor +// +class DirectoryCreator +{ +public: + DirectoryCreator(const DirectoryCreator&) = delete; + DirectoryCreator& operator=(const DirectoryCreator&) = delete; + + static std::unique_ptr create(const QDir& target, + std::function log) + { + return std::unique_ptr(new DirectoryCreator(target, log)); + } + + ~DirectoryCreator() { rollback(); } + + void commit() { m_created.clear(); } + + void rollback() noexcept + { + try { + // delete each directory starting from the end + for (auto itor = m_created.rbegin(); itor != m_created.rend(); ++itor) { + const auto r = shell::DeleteDirectoryRecursive(*itor); + if (!r) { + m_logger(r.toString()); + } + } + + m_created.clear(); + } catch (...) { + // eat it + } + } + +private: + std::function m_logger; + + DirectoryCreator(const QDir& target, std::function log) : m_logger(log) + { + try { + // split on separators + const QString s = QDir::toNativeSeparators(target.absolutePath()); + const QStringList cs = s.split(QDir::separator()); + + if (cs.empty()) { + return; + } + + // root directory + QDir d(cs[0]); + + // for each directory after the root + for (int i = 1; i < cs.size(); ++i) { + d = d.filePath(cs[i]); + + if (!d.exists()) { + m_logger(QObject::tr("Creating %1").arg(d.path())); + const auto r = shell::CreateDirectories(d); + + if (!r) { + m_logger(r.toString()); + throw Failed(); + } + + m_created.push_back(d); + } + } + } catch (...) { + rollback(); + throw; + } + } + +private: + std::vector m_created; +}; + +CreateInstanceDialog::CreateInstanceDialog(const PluginContainer& pc, Settings* s, + QWidget* parent) + : QDialog(parent), ui(new Ui::CreateInstanceDialog), m_pc(pc), m_settings(s), + m_switching(false), m_singlePage(false) +{ + using namespace cid; + + ui->setupUi(this); + m_originalNext = ui->next->text(); + + m_pages.push_back(std::make_unique(*this)); + m_pages.push_back(std::make_unique(*this)); + m_pages.push_back(std::make_unique(*this)); + m_pages.push_back(std::make_unique(*this)); + m_pages.push_back(std::make_unique(*this)); + m_pages.push_back(std::make_unique(*this)); + m_pages.push_back(std::make_unique(*this)); + m_pages.push_back(std::make_unique(*this)); + m_pages.push_back(std::make_unique(*this)); + + ui->pages->setCurrentIndex(0); + ui->launch->setChecked(true); + + if (!InstanceManager::singleton().hasAnyInstances()) { + // first run of MO, there are no instances yet, force launch + ui->launch->setEnabled(false); + } + + if (m_pages[0]->skip()) { + next(); + } + + ui->next->setFocus(); + + updateNavigation(); + + addShortcutAction(QKeySequence::Find, Actions::Find); + + addShortcut(Qt::ALT + Qt::Key_Left, [&] { + back(); + }); + addShortcut(Qt::ALT + Qt::Key_Right, [&] { + next(false); + }); + addShortcut(Qt::CTRL + Qt::Key_Return, [&] { + next(); + }); + + connect(ui->next, &QPushButton::clicked, [&] { + next(); + }); + connect(ui->back, &QPushButton::clicked, [&] { + back(); + }); + connect(ui->cancel, &QPushButton::clicked, [&] { + reject(); + }); +} + +CreateInstanceDialog::~CreateInstanceDialog() = default; + +Ui::CreateInstanceDialog* CreateInstanceDialog::getUI() +{ + return ui.get(); +} + +const PluginContainer& CreateInstanceDialog::pluginContainer() +{ + return m_pc; +} + +Settings* CreateInstanceDialog::settings() +{ + return m_settings; +} + +bool CreateInstanceDialog::isOnLastPage() const +{ + for (int i = ui->pages->currentIndex() + 1; i < ui->pages->count(); ++i) { + if (!m_pages[i]->skip()) { + return false; + } + } + + return true; +} + +void CreateInstanceDialog::next(bool allowFinish) +{ + if (!canNext()) { + return; + } + + const auto i = ui->pages->currentIndex(); + const auto last = isOnLastPage(); + + if (last) { + if (allowFinish) { + if (m_singlePage) { + // just close the dialog + accept(); + } else { + finish(); + } + } + } else { + changePage(+1); + } +} + +void CreateInstanceDialog::back() +{ + if (!canBack()) { + return; + } + + changePage(-1); +} + +void CreateInstanceDialog::addShortcut(QKeySequence seq, std::function f) +{ + auto* sc = new QShortcut(seq, this); + + sc->setAutoRepeat(false); + sc->setContext(Qt::WidgetWithChildrenShortcut); + + QObject::connect(sc, &QShortcut::activated, f); +} + +void CreateInstanceDialog::addShortcutAction(QKeySequence seq, Actions a) +{ + addShortcut(seq, [this, a] { + doAction(a); + }); +} + +void CreateInstanceDialog::doAction(Actions a) +{ + std::size_t i = static_cast(ui->pages->currentIndex()); + + if (i >= m_pages.size()) { + return; + } + + m_pages[i]->action(a); +} + +void CreateInstanceDialog::setSinglePageImpl(const QString& instanceName) +{ + m_singlePage = true; + + if (m_pages[ui->pages->currentIndex()]->skip()) { + next(); + } + + // don't show the "create a new instance" title for single pages, this is + // when the instance already exists but some info is missing + ui->title->setText(tr("Setting up instance %1").arg(instanceName)); + setWindowTitle(tr("Setting up an instance %1").arg(instanceName)); +} + +void CreateInstanceDialog::changePage(int d) +{ + std::size_t i = static_cast(ui->pages->currentIndex()); + + // goes back or forwards until an unskippable page is reached, or the + // first/last page + + if (d > 0) { + // forwards + for (;;) { + ++i; + + if (i >= m_pages.size()) { + break; + } + + if (!m_pages[i]->skip()) { + break; + } + } + } else { + // backwards + for (;;) { + if (i == 0) { + break; + } + + --i; + + if (!m_pages[i]->skip()) { + break; + } + } + } + + if (i < m_pages.size()) { + selectPage(i); + } +} + +void CreateInstanceDialog::finish() +{ + ui->creationLog->clear(); + logCreation(tr("Creating instance...")); + + const auto& m = InstanceManager::singleton(); + const auto ci = creationInfo(); + + auto logger = [&](QString s) { + logCreation(s); + }; + + auto createDir = [&](QString path) { + return DirectoryCreator::create(path, logger); + }; + + // don't restart if this is the first instance, it'll be selected and opened + const bool mustRestart = InstanceManager::singleton().hasAnyInstances(); + + try { + std::vector> dirs; + + // creating all these directories; if any of them fail, this throws and + // any newly created directory will be deleted in DirectoryCreator's dtor + dirs.push_back(createDir(ci.dataPath)); + dirs.push_back(createDir(ci.paths.base)); + dirs.push_back(createDir(PathSettings::resolve(ci.paths.downloads, ci.paths.base))); + dirs.push_back(createDir(PathSettings::resolve(ci.paths.mods, ci.paths.base))); + dirs.push_back(createDir(PathSettings::resolve(ci.paths.profiles, ci.paths.base))); + dirs.push_back(createDir(PathSettings::resolve(ci.paths.overwrite, ci.paths.base))); + + // creating ini + Settings s(ci.iniPath); + s.game().setName(ci.game->gameName()); + s.game().setDirectory(ci.gameLocation); + + if (!ci.gameVariant.isEmpty()) { + s.game().setEdition(ci.gameVariant); + } + + if (ci.paths.base != ci.dataPath) { + s.paths().setBase(ci.paths.base); + } + + if (ci.paths.downloads != cid::makeDefaultPath(AppConfig::downloadPath())) { + s.paths().setDownloads(ci.paths.downloads); + } + + if (ci.paths.mods != cid::makeDefaultPath(AppConfig::modsPath())) { + s.paths().setMods(ci.paths.mods); + } + + if (ci.paths.profiles != cid::makeDefaultPath(AppConfig::profilesPath())) { + s.paths().setProfiles(ci.paths.profiles); + } + + if (ci.paths.overwrite != cid::makeDefaultPath(AppConfig::overwritePath())) { + s.paths().setOverwrite(ci.paths.overwrite); + } + + s.setProfileLocalInis(ci.profileSettings.localInis); + s.setProfileLocalSaves(ci.profileSettings.localSaves); + s.setProfileArchiveInvalidation(ci.profileSettings.archiveInvalidation); + + logCreation(tr("Writing %1...").arg(ci.iniPath)); + + // writing ini + const auto r = s.sync(); + + if (r != QSettings::NoError) { + switch (r) { + case QSettings::AccessError: + logCreation(formatSystemMessage(ERROR_ACCESS_DENIED)); + break; + + case QSettings::FormatError: + logCreation(tr("Format error.")); + break; + + default: + logCreation(tr("Error %1.").arg(static_cast(r))); + break; + } + + throw Failed(); + } + + // committing all the directories so they don't get deleted + for (auto& d : dirs) { + d->commit(); + } + + logCreation(tr("Done.")); + + // launch the new instance + if (ui->launch->isChecked()) { + InstanceManager::singleton().setCurrentInstance(ci.instanceName); + + if (mustRestart) { + ExitModOrganizer(Exit::Restart); + m_switching = true; + } + } + + // close the dialog + accept(); + } catch (Failed&) { + // if Failed was thrown, all the directories have been deleted + } +} + +void CreateInstanceDialog::logCreation(const QString& s) +{ + ui->creationLog->insertPlainText(s + "\n"); +} + +void CreateInstanceDialog::logCreation(const std::wstring& s) +{ + logCreation(QString::fromStdWString(s)); +} + +void CreateInstanceDialog::selectPage(std::size_t i) +{ + if (i >= m_pages.size()) { + return; + } + + ui->pages->setCurrentIndex(static_cast(i)); + m_pages[i]->activated(); + + updateNavigation(); +} + +void CreateInstanceDialog::updateNavigation() +{ + const auto i = ui->pages->currentIndex(); + const auto last = isOnLastPage(); + + ui->next->setEnabled(canNext()); + ui->back->setEnabled(canBack()); + + if (last) { + ui->next->setText(tr("Finish")); + } else { + ui->next->setText(m_originalNext); + } +} + +bool CreateInstanceDialog::canNext() const +{ + const auto i = ui->pages->currentIndex(); + return m_pages[i]->skip() || m_pages[i]->ready(); +} + +bool CreateInstanceDialog::canBack() const +{ + auto i = ui->pages->currentIndex(); + + for (;;) { + if (i == 0) { + break; + } + + --i; + + if (!m_pages[i]->skip()) { + return true; + } + } + + return false; +} + +bool CreateInstanceDialog::switching() const +{ + return m_switching; +} + +CreateInstanceDialog::CreationInfo CreateInstanceDialog::rawCreationInfo() const +{ + const auto iniFilename = QString::fromStdWString(AppConfig::iniFileName()); + + CreationInfo ci; + + ci.type = getSelected(&cid::Page::selectedInstanceType); + ci.game = getSelected(&cid::Page::selectedGame); + ci.gameLocation = getSelected(&cid::Page::selectedGameLocation); + ci.gameVariant = getSelected(&cid::Page::selectedGameVariant, ci.game); + ci.instanceName = getSelected(&cid::Page::selectedInstanceName); + ci.profileSettings = getSelected(&cid::Page::profileSettings); + ci.paths = getSelected(&cid::Page::selectedPaths); + + if (ci.type == Portable) { + ci.dataPath = QDir(InstanceManager::singleton().portablePath()).absolutePath(); + } else { + ci.dataPath = InstanceManager::singleton().instancePath(ci.instanceName); + } + + ci.dataPath = QDir::toNativeSeparators(ci.dataPath); + ci.iniPath = ci.dataPath + "/" + iniFilename; + + return ci; +} + +CreateInstanceDialog::CreationInfo CreateInstanceDialog::creationInfo() const +{ + auto fixVarDir = [](QString& path, const std::wstring& defaultDir) { + // if the path is empty, it wasn't filled by the user, probably because + // the "Advanced" checkbox wasn't checked, so use the base dir variable + // with the default dir + + if (path.isEmpty()) { + path = cid::makeDefaultPath(defaultDir); + } else if (!path.contains(PathSettings::BaseDirVariable)) { + path = QDir(path).absolutePath(); + } + + path = QDir::toNativeSeparators(path); + }; + + auto fixDirPath = [](QString& path) { + path = QDir::toNativeSeparators(QDir(path).absolutePath()); + }; + + auto fixFilePath = [](QString& path) { + path = QDir::toNativeSeparators(QFileInfo(path).absolutePath()); + }; + + auto ci = rawCreationInfo(); + + fixDirPath(ci.paths.base); + fixFilePath(ci.paths.ini); + + fixVarDir(ci.paths.downloads, AppConfig::downloadPath()); + fixVarDir(ci.paths.mods, AppConfig::modsPath()); + fixVarDir(ci.paths.profiles, AppConfig::profilesPath()); + fixVarDir(ci.paths.overwrite, AppConfig::overwritePath()); + + return ci; +} diff --git a/src/src/createinstancedialog.h b/src/src/createinstancedialog.h new file mode 100644 index 0000000..d1f5656 --- /dev/null +++ b/src/src/createinstancedialog.h @@ -0,0 +1,237 @@ +#ifndef MODORGANIZER_CREATEINSTANCEDIALOG_INCLUDED +#define MODORGANIZER_CREATEINSTANCEDIALOG_INCLUDED + +#include + +namespace MOBase +{ +class IPluginGame; +} +namespace Ui +{ +class CreateInstanceDialog; +}; +namespace cid +{ +class Page; +} + +class PluginContainer; +class Settings; + +// this is a wizard for creating a new instance, it is made out of Page objects, +// see createinstancedialogpages.h +// +// each page can give back one or more pieces of information that is collected +// in creationInfo() and used by finish() to do the actual creation +// +// pages can be disabled if they return true in skip(), which happens globally +// for some (IntroPage has a setting in the registry), depending on context +// (NexusPage is skipped if the API key already exists) or explicitly (when +// only some info about the instance is missing on startup, such as a game +// variant) +// +class CreateInstanceDialog : public QDialog +{ + Q_OBJECT + +public: + enum class Actions + { + Find = 1 + }; + + // instance type + // + enum Types + { + NoType = 0, + Global, + Portable + }; + + // all the paths required by the instance, some may be empty, such as + // basically all of them except for `base` when the user doesn't use the + // "Advanced" part of the paths page + // + struct Paths + { + QString base; + QString downloads; + QString mods; + QString profiles; + QString overwrite; + QString ini; + + auto operator<=>(const Paths&) const = default; + }; + + struct ProfileSettings + { + bool localInis; + bool localSaves; + bool archiveInvalidation; + + auto operator<=>(const ProfileSettings&) const = default; + }; + + // all the info filled in the various pages + // + struct CreationInfo + { + Types type; + MOBase::IPluginGame* game; + QString gameLocation; + QString gameVariant; + QString instanceName; + QString dataPath; + QString iniPath; + Paths paths; + ProfileSettings profileSettings; + }; + + CreateInstanceDialog(const PluginContainer& pc, Settings* s, + QWidget* parent = nullptr); + + ~CreateInstanceDialog(); + + Ui::CreateInstanceDialog* getUI(); + const PluginContainer& pluginContainer(); + Settings* settings(); + + // disables all the pages except for the given one, used on startup when some + // specific info is missing + template + void setSinglePage(const QString& instanceName) + { + for (auto&& p : m_pages) { + if (auto* tp = dynamic_cast(p.get())) { + tp->setSkip(false); + } else { + p->setSkip(true); + } + } + + setSinglePageImpl(instanceName); + } + + // returns the page having the give path, or null + // + template + Page* getPage() + { + for (auto&& p : m_pages) { + if (auto* tp = dynamic_cast(p.get())) { + return tp; + } + } + + return nullptr; + } + + // moves to the next page; if `allowFinish` is true, calls finish() if + // currently on the last page + // + void next(bool allowFinish = true); + + // moves to the previous page, if any + // + void back(); + + // whether the current page reports that it is ready; if this is the last + // page, next() would call finish() + // + bool canNext() const; + + // whether the current page is not the first one and there is an enabled page + // prior + // + bool canBack() const; + + // selects the given page by index; this doesn't check if the page should be + // skipped + // + void selectPage(std::size_t i); + + // moves by `d` pages, can be negative to move back + // + void changePage(int d); + + // creates the instance and closes the dialog + // + void finish(); + + // updates the navigation buttons based on the current page + // + void updateNavigation(); + + // whether this is the last enabled page + // + bool isOnLastPage() const; + + // returns whether the user has requested to switch to the new instance + // + bool switching() const; + + // gathers the info from all the pages as it appears, paths are not fixed; + // see creationInfo() + // + CreationInfo rawCreationInfo() const; + + // gathers the info from all the pages: paths are converted to absolute and + // the base dir variable is expanded everywhere; see rawCreationInfo() + // + CreationInfo creationInfo() const; + +private: + std::unique_ptr ui; + const PluginContainer& m_pc; + Settings* m_settings; + std::vector> m_pages; + QString m_originalNext; + bool m_switching; + bool m_singlePage; + + // creates a shortcut for the given sequence + // + void addShortcut(QKeySequence seq, std::function f); + + // creates a shortcut for the given sequence and executes the action when + // activated + // + void addShortcutAction(QKeySequence seq, Actions a); + + // calls action() with the given action on the selected page, if any + // + void doAction(Actions a); + + // called from setSinglePage(), does whatever doesn't need the T + // + void setSinglePageImpl(const QString& instanceName); + + // adds a line to the creation log + // + void logCreation(const QString& s); + void logCreation(const std::wstring& s); + + // calls the given member function on all pages until one returns an object + // that's not empty; used by gatherInfo() + // + template + auto getSelected(MF mf, Args&&... args) const + { + // return type + using T = decltype((std::declval().*mf)(std::forward(args)...)); + + for (auto&& p : m_pages) { + const auto t = (p.get()->*mf)(std::forward(args)...); + if (t != T()) { + return t; + } + } + + return T(); + } +}; + +#endif // MODORGANIZER_CREATEINSTANCEDIALOG_INCLUDED diff --git a/src/src/createinstancedialog.ui b/src/src/createinstancedialog.ui new file mode 100644 index 0000000..b475075 --- /dev/null +++ b/src/src/createinstancedialog.ui @@ -0,0 +1,1353 @@ + + + CreateInstanceDialog + + + + 0 + 0 + 493 + 444 + + + + Creating an instance + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 14 + true + + + + Creating a new instance + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 10 + + + + + + + + + + + 0 + + + + + + + <h3>What is an instance?</h3> +<p>An instance is a full set of mods, downloads, profiles and configuration for a game. Each game must be managed in its own instance. Mod Organizer can freely switch between instances.</p> + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + + + + <html><head/><body><p><a href="https://github.com/ModOrganizer2/modorganizer/wiki/Instances">More information</a></p></body></html> + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Never show this page again + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + <h3>Select the type of instance to create.</h3> + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + + + Create a global instance + + + true + + + Global instances are stored in %1, but some paths can be changed to be on a different drive if necessary. A single installation of Mod Organizer can manage multiple global instances. + + + + + + + Create a portable instance + + + true + + + A portable instance stores everything in Mod Organizer's installation folder, currently %1. There can only be one portable instance per installation of Mod Organizer. + + + + + + + + + + A portable instance already exists. + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + <h3>Select the game to manage.</h3> + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + + + 0 + 0 + 455 + 275 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Show all supported games + + + + + + + Qt::Horizontal + + + + 175 + 20 + + + + + + + + Filter + + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + <h3>Select the game edition.</h3> + + + + + + + This game has multiple variants. The correct one must be selected or Mod Organizer will not be able to launch the game properly. + + + true + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + + + 0 + 0 + 455 + 265 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Qt::Vertical + + + QDialogButtonBox::NoButton + + + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + <h3>Customize the name for this <span style="white-space: nowrap;">%1</span> instance.</h3> + + + true + + + + + + + + + + + 9 + + + + + Instance name + + + + + + + There is already an instance with this name. + + + + + + + The name contains invalid characters. It must be a valid folder name. + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + <h3>Configure your profile settings.</h3> + + + true + + + + + + + + + + + + + + + + Use profile-specific game INI files + + + + + + + + + + Use profile-specific save games + + + + + + + + + + Automatic archive invalidation + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + <h3>Select a folder where the data should be stored.</h3> + + + true + + + + + + + This includes downloads, mods, profiles and overwrite for your <b>%1</b> instance. If there is enough space on this drive, you should use the default folder. + + + true + + + + + + + + + + + + + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + ... + + + + + + + Location + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Warning: This folder already exists. + + + + + + + Folder + + + + + + + Warning: The folder contains invalid characters. + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + ... + + + + + + + ... + + + + + + + Mods + + + + + + + Folder + + + + + + + Folder + + + + + + + Overwrite + + + + + + + Base directory + + + + + + + ... + + + + + + + Folder + + + + + + + Warning: The folder %1 already exists. + + + true + + + + + + + Downloads + + + + + + + Folder + + + + + + + Folder + + + + + + + Profiles + + + + + + + Use %BASE_DIR% to refer to the Base Directory. + + + + + + + ... + + + + + + + ... + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Warning: The folder %1 contains invalid characters. + + + true + + + + + + + + + + + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 292 + 20 + + + + + + + + Show advanced options + + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + <h3>Link Mod Organizer with your Nexus account</h3> + + + + + + + Linking with Nexus allows you to download mods directly from Mod Organizer and automatically check for updates. This is optional. + + + true + + + + + + + + + + + 0 + 100 + + + + + 0 + + + 9 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Connect to Nexus + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Enter API Key Manually + + + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + <h3>Confirmation</h3> + + + + + + + The instance is about to be created. Review the information below and press 'Finish'. + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QTextEdit::NoWrap + + + true + + + + + + + QTextEdit::NoWrap + + + Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + Instance creation log + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 304 + 20 + + + + + + + + Launch the new instance + + + true + + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + < Back + + + + + + + Next > + + + true + + + + + + + Cancel + + + + + + + + + + + LinkLabel + QLabel +
    linklabel.h
    +
    +
    + + next + cancel + hideIntro + createGlobal + createPortable + scrollArea_3 + showAllGames + gamesFilter + scrollArea_2 + location + browseLocation + advancedPathOptions + base + browseBase + downloads + browseDownloads + mods + browseMods + profiles + browseProfiles + overwrite + browseOverwrite + nexusConnect + nexusManual + nexusLog + review + creationLog + launch + back + + + + + +
    diff --git a/src/src/createinstancedialogpages.cpp b/src/src/createinstancedialogpages.cpp new file mode 100644 index 0000000..72f0a27 --- /dev/null +++ b/src/src/createinstancedialogpages.cpp @@ -0,0 +1,1321 @@ +#include "createinstancedialogpages.h" +#include "filesystemutilities.h" +#include "instancemanager.h" +#include "plugincontainer.h" +#include "settings.h" +#include "settingsdialognexus.h" +#include "shared/appconfig.h" +#include "ui_createinstancedialog.h" +#include +#include +#include + +namespace cid +{ + +using namespace MOBase; +using MOBase::TaskDialog; + +// returns %base_dir%/dir +// +QString makeDefaultPath(const std::wstring& dir) +{ + return QDir::toNativeSeparators( + PathSettings::makeDefaultPath(QString::fromStdWString(dir))); +} + +QString compactPathForDescription(const QString& path) +{ + const QString nativePath = QDir::toNativeSeparators(path); + const QString homePath = QDir::toNativeSeparators(QDir::homePath()); + + if (nativePath.startsWith(homePath, Qt::CaseInsensitive)) { + return "~" + nativePath.mid(homePath.size()); + } + + return nativePath; +} + +void tuneTypeButtonHeight(QCommandLinkButton* button) +{ + // Qt can underestimate command-link height on Linux for long descriptions. + button->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding); + button->setMinimumHeight(std::max(button->minimumSizeHint().height(), 110)); +} + +QString toLocalizedString(CreateInstanceDialog::Types t) +{ + switch (t) { + case CreateInstanceDialog::Global: + return QObject::tr("Global"); + + case CreateInstanceDialog::Portable: + return QObject::tr("Portable"); + + default: + return QObject::tr("Instance type: %1").arg(QObject::tr("?")); + } +} + +PlaceholderLabel::PlaceholderLabel(QLabel* label) + : m_label(label), m_original(label->text()) +{} + +void PlaceholderLabel::setText(const QString& arg) +{ + if (m_original.contains("%1")) { + m_label->setText(m_original.arg(arg)); + } +} + +void PlaceholderLabel::setVisible(bool b) +{ + m_label->setVisible(b); +} + +Page::Page(CreateInstanceDialog& dlg) + : ui(dlg.getUI()), m_dlg(dlg), m_pc(dlg.pluginContainer()), m_skip(false), + m_firstActivation(true) +{} + +bool Page::ready() const +{ + return true; +} + +bool Page::skip() const +{ + // setSkip() overrides this if it's true + return m_skip || doSkip(); +} + +bool Page::doSkip() const +{ + return false; +} + +void Page::doActivated(bool) +{ + // no-op +} + +void Page::activated() +{ + doActivated(m_firstActivation); + m_firstActivation = false; +} + +void Page::setSkip(bool b) +{ + m_skip = b; +} + +void Page::updateNavigation() +{ + m_dlg.updateNavigation(); +} + +void Page::next() +{ + m_dlg.next(); +} + +bool Page::action(CreateInstanceDialog::Actions a) +{ + // no-op + return false; +} + +CreateInstanceDialog::Types Page::selectedInstanceType() const +{ + // no-op + return CreateInstanceDialog::NoType; +} + +IPluginGame* Page::selectedGame() const +{ + // no-op + return nullptr; +} + +QString Page::selectedGameLocation() const +{ + // no-op + return {}; +} + +QString Page::selectedGameVariant(MOBase::IPluginGame*) const +{ + // no-op + return {}; +} + +QString Page::selectedInstanceName() const +{ + // no-op + return {}; +} + +CreateInstanceDialog::Paths Page::selectedPaths() const +{ + // no-op + return {}; +} + +CreateInstanceDialog::ProfileSettings Page::profileSettings() const +{ + // no-op + return {}; +} + +IntroPage::IntroPage(CreateInstanceDialog& dlg) + : Page(dlg), m_skip(GlobalSettings::hideCreateInstanceIntro()) +{ + QObject::connect(ui->hideIntro, &QCheckBox::toggled, [&] { + GlobalSettings::setHideCreateInstanceIntro(ui->hideIntro->isChecked()); + }); +} + +bool IntroPage::doSkip() const +{ + return m_skip; +} + +TypePage::TypePage(CreateInstanceDialog& dlg) + : Page(dlg), m_type(CreateInstanceDialog::NoType) +{ + // replace placeholders with actual paths + ui->createGlobal->setDescription(ui->createGlobal->description().arg( + compactPathForDescription(InstanceManager::singleton().globalInstancesRootPath()))); + + ui->createPortable->setDescription(ui->createPortable->description().arg( + compactPathForDescription(InstanceManager::singleton().portablePath()))); + + tuneTypeButtonHeight(ui->createGlobal); + tuneTypeButtonHeight(ui->createPortable); + + // disable portable button if it already exists + if (InstanceManager::singleton().portableInstanceExists()) { + ui->createPortable->setEnabled(false); + ui->portableExistsLabel->setVisible(true); + } else { + ui->createPortable->setEnabled(true); + ui->portableExistsLabel->setVisible(false); + } + + QObject::connect(ui->createGlobal, &QAbstractButton::clicked, [&] { + global(); + }); + + QObject::connect(ui->createPortable, &QAbstractButton::clicked, [&] { + portable(); + }); +} + +bool TypePage::ready() const +{ + return (m_type != CreateInstanceDialog::NoType); +} + +CreateInstanceDialog::Types TypePage::selectedInstanceType() const +{ + return m_type; +} + +void TypePage::global() +{ + m_type = CreateInstanceDialog::Global; + + ui->createGlobal->setChecked(true); + ui->createPortable->setChecked(false); + + next(); +} + +void TypePage::portable() +{ + m_type = CreateInstanceDialog::Portable; + + ui->createGlobal->setChecked(false); + ui->createPortable->setChecked(true); + + next(); +} + +void TypePage::doActivated(bool firstTime) +{ + if (firstTime) { + ui->createGlobal->setFocus(); + } +} + +GamePage::Game::Game(IPluginGame* g) : game(g), installed(g->isInstalled()) +{ + if (installed) { + dir = game->gameDirectory().path(); + } +} + +GamePage::GamePage(CreateInstanceDialog& dlg) : Page(dlg), m_selection(nullptr) +{ + createGames(); + fillList(); + + m_filter.setEdit(ui->gamesFilter); + + QObject::connect(&m_filter, &FilterWidget::changed, [&] { + fillList(); + }); + QObject::connect(ui->showAllGames, &QCheckBox::clicked, [&] { + fillList(); + }); +} + +bool GamePage::ready() const +{ + return (m_selection != nullptr); +} + +bool GamePage::action(CreateInstanceDialog::Actions a) +{ + using Actions = CreateInstanceDialog::Actions; + + if (a == Actions::Find) { + ui->gamesFilter->setFocus(); + return true; + } + + return false; +} + +IPluginGame* GamePage::selectedGame() const +{ + if (!m_selection) { + return nullptr; + } + + return m_selection->game; +} + +QString GamePage::selectedGameLocation() const +{ + if (!m_selection) { + return {}; + } + + return QDir::toNativeSeparators(m_selection->dir); +} + +void GamePage::select(IPluginGame* game, const QString& dir) +{ + Game* checked = findGame(game); + + if (checked) { + if (!checked->installed || (detectMicrosoftStore(checked->dir) && + !confirmMicrosoftStore(checked->dir, checked->game))) { + if (dir.isEmpty()) { + // the selected game has no installation directory and none was given, + // ask the user + + const auto path = QFileDialog::getExistingDirectory( + &m_dlg, + QObject::tr("Find game installation for %1").arg(game->displayGameName())); + + if (path.isEmpty()) { + // cancelled + checked = nullptr; + } else if (detectMicrosoftStore(path) && !confirmMicrosoftStore(path, game)) { + // cancelled + checked = nullptr; + } else { + // check whether a plugin supports the given directory; this can + // return the same plugin, a different one, or null + checked = checkInstallation(path, checked); + + if (checked) { + // plugin was found, remember this path + checked->dir = path; + checked->installed = true; + } + } + } else { + // the selected game didn't detect anything, but a directory was given, + // so use that + checked->dir = dir; + checked->installed = true; + } + } + } + + // select this plugin, if any + m_selection = checked; + + // update the button associated with it in case the paths have changed + updateButton(checked); + + // toggle it on + selectButton(checked); + + updateNavigation(); + + if (checked) { + // automatically move to the next page when a game is selected + next(); + } +} + +void GamePage::selectCustom() +{ + const auto path = + QFileDialog::getExistingDirectory(&m_dlg, QObject::tr("Find game installation")); + + if (path.isEmpty()) { + // reselect the previous button + selectButton(m_selection); + return; + } + + // Microsoft store games are not supported + if (detectMicrosoftStore(path) && !confirmMicrosoftStore(path, nullptr)) { + // reselect the previous button + selectButton(m_selection); + return; + } + + // try to find a plugin that likes this directory + for (auto& g : m_games) { + if (g->game->looksValid(path)) { + // found one + g->dir = path; + g->installed = true; + + // select it + select(g->game); + + // update the button because the path has changed + updateButton(g.get()); + + return; + } + } + + // warning to the user + warnUnrecognized(path); + + // reselect the previous button + selectButton(m_selection); +} + +void GamePage::warnUnrecognized(const QString& path) +{ + // put the list of supported games in the details textbox + QString supportedGames; + for (auto* game : sortedGamePlugins()) { + supportedGames += game->displayGameName() + "\n"; + } + + QMessageBox dlg(&m_dlg); + + dlg.setWindowTitle(QObject::tr("Unrecognized game")); + dlg.setText( + QObject::tr("The folder %1 does not seem to contain a game Mod Organizer can " + "manage.") + .arg(path)); + dlg.setInformativeText(QObject::tr("See details for the list of supported games.")); + dlg.setDetailedText(supportedGames); + dlg.setIcon(QMessageBox::Warning); + dlg.setStandardButtons(QMessageBox::Ok); + + dlg.exec(); +} + +std::vector GamePage::sortedGamePlugins() const +{ + std::vector v; + + // all game plugins + for (auto* game : m_pc.plugins()) { + v.push_back(game); + } + + // natsort + std::sort(v.begin(), v.end(), [](auto* a, auto* b) { + return (naturalCompare(a->displayGameName(), b->displayGameName()) < 0); + }); + + return v; +} + +void GamePage::createGames() +{ + m_games.clear(); + + for (auto* game : sortedGamePlugins()) { + m_games.push_back(std::make_unique(game)); + } +} + +GamePage::Game* GamePage::findGame(IPluginGame* game) +{ + for (auto& g : m_games) { + if (g->game == game) { + return g.get(); + } + } + + return nullptr; +} + +void GamePage::createGameButton(Game* g) +{ + g->button = new QCommandLinkButton; + g->button->setCheckable(true); + g->button->setIconSize(QSize(24, 24)); + + updateButton(g); + + QObject::connect(g->button, &QAbstractButton::clicked, [g, this] { + select(g->game); + }); +} + +void GamePage::addButton(QAbstractButton* b) +{ + auto* ly = static_cast(ui->games->layout()); + + // insert before the stretch + ly->insertWidget(ly->count() - 1, b); +} + +void GamePage::updateButton(Game* g) +{ + if (!g || !g->button) { + return; + } + + g->button->setText(g->game->displayGameName().replace("&", "&&")); + QIcon icon = g->game->gameIcon(); + if (icon.isNull()) { + icon = QIcon(":/MO/gui/executable"); + } + if (icon.isNull()) { + icon = QApplication::style()->standardIcon(QStyle::SP_ComputerIcon); + } + g->button->setIcon(icon); + + if (g->installed) { + const QString compact = compactPathForDescription(g->dir); + const QFontMetrics fm(g->button->font()); + const QString elided = fm.elidedText(compact, Qt::ElideMiddle, 460); + g->button->setDescription(elided); + g->button->setToolTip(QDir::toNativeSeparators(g->dir)); + } else { + g->button->setDescription(QObject::tr("No installation found")); + g->button->setToolTip({}); + } +} + +void GamePage::selectButton(Game* g) +{ + // go through each game, set the button that is for game `g` as active; + // some button might not exist, which happens when selecting a custom + // folder for a game that was considered uninstalled + + for (const auto& gg : m_games) { + if (!g) { + // nothing should be selected + if (gg->button) { + gg->button->setChecked(false); + } + + continue; + } + + if (gg->game == g->game) { + // this is the button that should be selected + + if (!gg->button) { + // this happens when the button wasn't visible because the game + // was not installed; create it and show it + // and it has a button, just check it + createGameButton(gg.get()); + addButton(gg->button); + } + + gg->button->setChecked(true); + gg->button->setFocus(); + } else { + // this is not the button you're looking for + if (gg->button) { + gg->button->setChecked(false); + } + } + } +} + +void GamePage::clearButtons() +{ + auto* ly = static_cast(ui->games->layout()); + + ui->games->setUpdatesEnabled(false); + + // delete all children + qDeleteAll(ui->games->findChildren("", Qt::FindDirectChildrenOnly)); + + // stretch widgets added with addStretch() are not in the parent widget, + // they have to be deleted from the layout itself + while (auto* child = ly->takeAt(0)) + delete child; + + // add a stretch, buttons will be added before + ly->addStretch(); + + ui->games->setUpdatesEnabled(true); + + for (auto& g : m_games) { + // all buttons have been deleted + g->button = nullptr; + } +} + +QCommandLinkButton* GamePage::createCustomButton() +{ + auto* b = new QCommandLinkButton; + + b->setText(QObject::tr("Browse...")); + b->setDescription(QObject::tr("The folder must contain a valid game installation")); + + QObject::connect(b, &QAbstractButton::clicked, [&] { + selectCustom(); + }); + + return b; +} + +void GamePage::fillList() +{ + const bool showAll = ui->showAllGames->isChecked(); + + clearButtons(); + + Game* firstButton = nullptr; + + for (auto& g : m_games) { + if (!showAll && !g->installed) { + // not installed + continue; + } + + if (!m_filter.matches(g->game->gameName()) && + !m_filter.matches(g->game->displayGameName())) { + // filtered out + continue; + } + + createGameButton(g.get()); + addButton(g->button); + + if (!firstButton) { + firstButton = g.get(); + } + } + + // browse button + addButton(createCustomButton()); + + if (firstButton) { + firstButton->button->setDefault(true); + } +} + +GamePage::Game* GamePage::checkInstallation(const QString& path, Game* g) +{ + if (g->game->looksValid(path)) { + // okay + return g; + } + + if (detectMicrosoftStore(path) && confirmMicrosoftStore(path, g->game)) { + // okay + return g; + } + + // the selected game can't use that folder, find another one + IPluginGame* otherGame = nullptr; + + for (auto* gg : m_pc.plugins()) { + if (gg->looksValid(path)) { + otherGame = gg; + break; + } + } + + if (otherGame == g->game) { + // shouldn't happen, but okay + return g; + } + + if (otherGame) { + // an alternative was found, ask the user about it + auto* confirmedGame = confirmOtherGame(path, g->game, otherGame); + + if (!confirmedGame) { + // cancelled + return nullptr; + } + + // make it look like the user clicked that button instead + g = findGame(confirmedGame); + if (!g) { + return nullptr; + } + } else { + // nothing can manage this, but the user can override + if (!confirmUnknown(path, g->game)) { + // cancelled + return nullptr; + } + } + + // remember this path + g->dir = path; + g->installed = true; + + updateButton(g); + + return g; +} + +bool GamePage::detectMicrosoftStore(const QString& path) +{ + return path.contains("/ModifiableWindowsApps/") || path.contains("/WindowsApps/"); +} + +bool GamePage::confirmMicrosoftStore(const QString& path, IPluginGame* game) +{ + const auto r = + TaskDialog(&m_dlg) + .title(QObject::tr("Microsoft Store game")) + .main(QObject::tr("Microsoft Store game")) + .content( + QObject::tr( + "The folder %1 seems to be a Microsoft Store game install. Games" + " installed through the Microsoft Store are not supported by Mod " + "Organizer" + " and will not work properly.") + .arg(path)) + .button( + {game ? QObject::tr("Use this folder for %1").arg(game->displayGameName()) + : QObject::tr("Use this folder"), + QObject::tr("I know what I'm doing"), QMessageBox::Ignore}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + return (r == QMessageBox::Ignore); +} + +bool GamePage::confirmUnknown(const QString& path, IPluginGame* game) +{ + const auto r = + TaskDialog(&m_dlg) + .title(QObject::tr("Unrecognized game")) + .main(QObject::tr("Unrecognized game")) + .content( + QObject::tr("The folder %1 does not seem to contain an installation for " + "%2 or " + "for any other game Mod Organizer can manage.") + .arg(path) + .arg(game->displayGameName())) + .button({QObject::tr("Use this folder for %1").arg(game->displayGameName()), + QObject::tr("I know what I'm doing"), QMessageBox::Ignore}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + return (r == QMessageBox::Ignore); +} + +IPluginGame* GamePage::confirmOtherGame(const QString& path, IPluginGame* selectedGame, + IPluginGame* guessedGame) +{ + const auto r = + TaskDialog(&m_dlg) + .title(QObject::tr("Incorrect game")) + .main(QObject::tr("Incorrect game")) + .content( + QObject::tr( + "The folder %1 seems to contain an installation for " + "%2, " + "not " + "%3.") + .arg(path) + .arg(guessedGame->displayGameName()) + .arg(selectedGame->displayGameName())) + .button({QObject::tr("Manage %1 instead").arg(guessedGame->displayGameName()), + QMessageBox::Ok}) + .button({QObject::tr("Use this folder for %1") + .arg(selectedGame->displayGameName()), + QObject::tr("I know what I'm doing"), QMessageBox::Ignore}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + switch (r) { + case QMessageBox::Ok: + return guessedGame; + + case QMessageBox::Ignore: + return selectedGame; + + case QMessageBox::Cancel: + default: + return nullptr; + } +} + +VariantsPage::VariantsPage(CreateInstanceDialog& dlg) + : Page(dlg), m_previousGame(nullptr) +{} + +bool VariantsPage::ready() const +{ + // note that this isn't called when doSkip() is true, which happens when + // the game has no variants + + return !m_selection.isEmpty(); +} + +bool VariantsPage::doSkip() const +{ + auto* g = m_dlg.rawCreationInfo().game; + if (!g) { + // shouldn't happen + return true; + } + + return (g->gameVariants().size() < 2); +} + +void VariantsPage::doActivated(bool) +{ + auto* g = m_dlg.rawCreationInfo().game; + + if (m_previousGame != g) { + // recreate the list, the game has changed + m_previousGame = g; + m_selection = ""; + fillList(); + } +} + +void VariantsPage::select(const QString& variant) +{ + m_selection = variant; + + // find the button, set it checked + for (auto* b : m_buttons) { + if (b->text() == variant) { + b->setChecked(true); + } else { + b->setChecked(false); + } + } + + updateNavigation(); + + if (!m_selection.isEmpty()) { + // automatically move to the next page when a variant is selected + next(); + } +} + +QString VariantsPage::selectedGameVariant(MOBase::IPluginGame* game) const +{ + if (!game) { + return {}; + } + + if (game->gameVariants().size() < 2) { + return {}; + } else { + return m_selection; + } +} + +void VariantsPage::fillList() +{ + ui->editions->clear(); + m_buttons.clear(); + + auto* g = m_dlg.rawCreationInfo().game; + if (!g) { + // shouldn't happen + return; + } + + // for each variant, create a checkable button and add it + for (auto& v : g->gameVariants()) { + auto* b = new QCommandLinkButton(v); + b->setCheckable(true); + + QObject::connect(b, &QAbstractButton::clicked, [v, this] { + select(v); + }); + + ui->editions->addButton(b, QDialogButtonBox::AcceptRole); + m_buttons.push_back(b); + } + + if (!m_buttons.empty()) { + m_buttons[0]->setDefault(true); + } +} + +NamePage::NamePage(CreateInstanceDialog& dlg) + : Page(dlg), m_modified(false), m_okay(false), m_label(ui->instanceNameLabel), + m_exists(ui->instanceNameExists), m_invalid(ui->instanceNameInvalid) +{ + QObject::connect(ui->instanceName, &QLineEdit::textEdited, [&] { + onChanged(); + }); + + QObject::connect(ui->instanceName, &QLineEdit::returnPressed, [&] { + next(); + }); +} + +bool NamePage::ready() const +{ + // checked when textboxes change or when the page is activated + return m_okay; +} + +bool NamePage::doSkip() const +{ + // portable instances have no name + return (m_dlg.rawCreationInfo().type == CreateInstanceDialog::Portable); +} + +void NamePage::doActivated(bool) +{ + auto* g = m_dlg.rawCreationInfo().game; + if (!g) { + // shouldn't happen, next should be disabled + return; + } + + m_label.setText(g->gameName()); + + // generate a name if the user hasn't changed the text in case the game + // changed, or if it's empty + if (!m_modified || ui->instanceName->text().isEmpty()) { + const auto n = InstanceManager::singleton().makeUniqueName(g->gameName()); + ui->instanceName->setText(n); + m_modified = false; + } + + verify(); +} + +QString NamePage::selectedInstanceName() const +{ + if (!m_okay) { + return {}; + } + + const auto text = ui->instanceName->text().trimmed(); + return MOBase::sanitizeFileName(text); +} + +void NamePage::onChanged() +{ + m_modified = true; + verify(); +} + +void NamePage::verify() +{ + const auto root = InstanceManager::singleton().globalInstancesRootPath(); + m_okay = checkName(root, ui->instanceName->text()); + updateNavigation(); +} + +bool NamePage::checkName(QString parentDir, QString name) +{ + bool exists = false; + bool invalid = false; + bool empty = false; + + name = name.trimmed(); + + if (name.isEmpty()) { + empty = true; + } else { + if (MOBase::validFileName(name)) { + exists = QDir(parentDir).exists(name); + } else { + invalid = true; + } + } + + bool okay = false; + + if (exists) { + m_exists.setVisible(true); + m_exists.setText(QDir(parentDir).filePath(name)); + m_invalid.setVisible(false); + } else if (invalid) { + m_exists.setVisible(false); + m_invalid.setVisible(true); + m_invalid.setText(name); + } else { + okay = !empty; + m_exists.setVisible(false); + m_invalid.setVisible(false); + } + + return okay; +} + +ProfilePage::ProfilePage(CreateInstanceDialog& dlg) : Page(dlg) {} + +bool ProfilePage::ready() const +{ + return true; +} + +CreateInstanceDialog::ProfileSettings ProfilePage::profileSettings() const +{ + CreateInstanceDialog::ProfileSettings profileSettings; + profileSettings.localInis = ui->profileInisCheckbox->isChecked(); + profileSettings.localSaves = ui->profileSavesCheckbox->isChecked(); + profileSettings.archiveInvalidation = ui->archiveInvalidationCheckbox->isChecked(); + return profileSettings; +} + +PathsPage::PathsPage(CreateInstanceDialog& dlg) + : Page(dlg), m_lastType(CreateInstanceDialog::NoType), m_label(ui->pathsLabel), + m_simpleExists(ui->locationExists), m_simpleInvalid(ui->locationInvalid), + m_advancedExists(ui->advancedDirExists), + m_advancedInvalid(ui->advancedDirInvalid), m_okay(false) +{ + auto setEdit = [&](QLineEdit* e) { + QObject::connect(e, &QLineEdit::textEdited, [&] { + onChanged(); + }); + QObject::connect(e, &QLineEdit::returnPressed, [&] { + next(); + }); + }; + + auto setBrowse = [&](QAbstractButton* b, QLineEdit* e) { + QObject::connect(b, &QAbstractButton::clicked, [this, e] { + browse(e); + }); + }; + + setEdit(ui->location); + setEdit(ui->base); + setEdit(ui->downloads); + setEdit(ui->mods); + setEdit(ui->profiles); + setEdit(ui->overwrite); + + setBrowse(ui->browseLocation, ui->location); + setBrowse(ui->browseBase, ui->base); + setBrowse(ui->browseDownloads, ui->downloads); + setBrowse(ui->browseMods, ui->mods); + setBrowse(ui->browseProfiles, ui->profiles); + setBrowse(ui->browseOverwrite, ui->overwrite); + + QObject::connect(ui->advancedPathOptions, &QCheckBox::clicked, [&] { + onAdvanced(); + }); + + ui->pathPages->setCurrentIndex(0); +} + +bool PathsPage::ready() const +{ + // set when the page is activated, textboxes are changed or the advanced + // checkbox is toggled + return m_okay; +} + +void PathsPage::doActivated(bool firstTime) +{ + const auto name = m_dlg.rawCreationInfo().instanceName; + const auto type = m_dlg.rawCreationInfo().type; + + // if the instance name or type have changed, all the paths must be + // regenerated + const bool changed = (m_lastInstanceName != name) || (m_lastType != type); + + // generating and paths + setPaths(name, changed); + checkPaths(); + + updateNavigation(); + + m_label.setText(m_dlg.rawCreationInfo().game->gameName()); + m_lastInstanceName = name; + m_lastType = type; + + if (firstTime) { + ui->location->setFocus(); + } +} + +CreateInstanceDialog::Paths PathsPage::selectedPaths() const +{ + CreateInstanceDialog::Paths p; + + if (ui->advancedPathOptions->isChecked()) { + p.base = ui->base->text(); + p.downloads = ui->downloads->text(); + p.mods = ui->mods->text(); + p.profiles = ui->profiles->text(); + p.overwrite = ui->overwrite->text(); + } else { + p.base = ui->location->text(); + } + + return p; +} + +void PathsPage::onChanged() +{ + checkPaths(); + updateNavigation(); +} + +void PathsPage::browse(QLineEdit* e) +{ + const auto s = QFileDialog::getExistingDirectory(&m_dlg, {}, e->text()); + if (s.isNull() || s.isEmpty()) { + return; + } + + e->setText(QDir::toNativeSeparators(s)); +} + +void PathsPage::checkPaths() +{ + if (ui->advancedPathOptions->isChecked()) { + // checking advanced paths + m_okay = checkAdvancedPath(ui->base->text()) && + checkAdvancedPath(resolve(ui->downloads->text())) && + checkAdvancedPath(resolve(ui->mods->text())) && + checkAdvancedPath(resolve(ui->profiles->text())) && + checkAdvancedPath(resolve(ui->overwrite->text())); + } else { + // checking simple path + m_okay = checkSimplePath(ui->location->text()); + } +} + +bool PathsPage::checkSimplePath(const QString& path) +{ + return checkPath(path, m_simpleExists, m_simpleInvalid); +} + +bool PathsPage::checkAdvancedPath(const QString& path) +{ + return checkPath(path, m_advancedExists, m_advancedInvalid); +} + +QString PathsPage::resolve(const QString& path) const +{ + return PathSettings::resolve(path, ui->base->text()); +} + +void PathsPage::onAdvanced() +{ + // the base/location textboxes are different widgets but they represent the + // same base path value, so they're synced between pages + + if (ui->advancedPathOptions->isChecked()) { + ui->base->setText(ui->location->text()); + ui->pathPages->setCurrentIndex(1); + } else { + ui->location->setText(ui->base->text()); + ui->pathPages->setCurrentIndex(0); + } + + checkPaths(); +} + +void PathsPage::setPaths(const QString& name, bool force) +{ + QString basePath; + + if (m_dlg.rawCreationInfo().type == CreateInstanceDialog::Portable) { + basePath = InstanceManager::singleton().portablePath(); + } else { + const auto root = InstanceManager::singleton().globalInstancesRootPath(); + basePath = root + "/" + name; + } + + basePath = QDir::toNativeSeparators(QDir::cleanPath(basePath)); + + // all paths are set regardless of advanced checkbox + + setIfEmpty(ui->location, basePath, force); + setIfEmpty(ui->base, basePath, force); + + setIfEmpty(ui->downloads, makeDefaultPath(AppConfig::downloadPath()), force); + setIfEmpty(ui->mods, makeDefaultPath(AppConfig::modsPath()), force); + setIfEmpty(ui->profiles, makeDefaultPath(AppConfig::profilesPath()), force); + setIfEmpty(ui->overwrite, makeDefaultPath(AppConfig::overwritePath()), force); +} + +void PathsPage::setIfEmpty(QLineEdit* e, const QString& path, bool force) +{ + if (e->text().isEmpty() || force) { + e->setText(path); + } +} + +bool PathsPage::checkPath(QString path, PlaceholderLabel& existsLabel, + PlaceholderLabel& invalidLabel) +{ + auto& m = InstanceManager::singleton(); + + bool exists = false; + bool invalid = false; + bool empty = false; + + path = QDir::toNativeSeparators(path.trimmed()); + + if (path.isEmpty()) { + empty = true; + } else { + const QDir d(path); + + if (MOBase::validFileName(d.dirName())) { + if (m_dlg.rawCreationInfo().type == CreateInstanceDialog::Portable) { + // the default data path for a portable instance is the application + // directory, so it's not an error if it exists + if (QDir(path) != m.portablePath()) { + exists = QDir(path).exists(); + } + } else { + exists = QDir(path).exists(); + } + } else { + invalid = true; + } + } + + bool okay = true; + + if (invalid) { + okay = false; + existsLabel.setVisible(false); + invalidLabel.setVisible(true); + invalidLabel.setText(path); + } else if (empty) { + okay = false; + existsLabel.setVisible(false); + invalidLabel.setVisible(false); + } else if (exists) { + // this is just a warning + existsLabel.setVisible(true); + existsLabel.setText(path); + invalidLabel.setVisible(false); + } else { + okay = true; + existsLabel.setVisible(false); + invalidLabel.setVisible(false); + } + + return okay; +} + +NexusPage::NexusPage(CreateInstanceDialog& dlg) : Page(dlg), m_skip(false) +{ + m_connectionUI.reset(new NexusConnectionUI(&m_dlg, dlg.settings(), ui->nexusConnect, + nullptr, ui->nexusManual, ui->nexusLog)); + + // just check it once, or connecting and then going back and forth would skip + // the page, which would be unexpected + m_skip = GlobalSettings::hasNexusApiKey(); +} + +NexusPage::~NexusPage() = default; + +bool NexusPage::ready() const +{ + // this page is optional + return true; +} + +bool NexusPage::doSkip() const +{ + return m_skip; +} + +ConfirmationPage::ConfirmationPage(CreateInstanceDialog& dlg) : Page(dlg) {} + +void ConfirmationPage::doActivated(bool) +{ + ui->review->setPlainText(makeReview()); + ui->creationLog->clear(); +} + +QString ConfirmationPage::makeReview() const +{ + QStringList lines; + + const auto ci = m_dlg.rawCreationInfo(); + + lines.push_back(QObject::tr("Instance type: %1").arg(toLocalizedString(ci.type))); + + lines.push_back(QObject::tr("Instance location: %1").arg(ci.dataPath)); + + if (ci.type != CreateInstanceDialog::Portable) { + lines.push_back(QObject::tr("Instance name: %1").arg(ci.instanceName)); + } + + lines.push_back(QObject::tr("Profile settings:")); + lines.push_back( + QObject::tr(" Local INIs: %1") + .arg(ci.profileSettings.localInis ? QObject::tr("yes") : QObject::tr("no"))); + lines.push_back( + QObject::tr(" Local Saves: %1") + .arg(ci.profileSettings.localSaves ? QObject::tr("yes") : QObject::tr("no"))); + lines.push_back(QObject::tr(" Automatic Archive Invalidation: %1") + .arg(ci.profileSettings.archiveInvalidation ? QObject::tr("yes") + : QObject::tr("no"))); + + if (ci.paths.downloads.isEmpty()) { + // simple settings + if (ci.paths.base != ci.dataPath) { + lines.push_back(QObject::tr("Base directory: %1").arg(ci.paths.base)); + } + } else { + // advanced settings + lines.push_back(QObject::tr("Base directory: %1").arg(ci.paths.base)); + lines.push_back(dirLine(QObject::tr("Downloads"), ci.paths.downloads)); + lines.push_back(dirLine(QObject::tr("Mods"), ci.paths.mods)); + lines.push_back(dirLine(QObject::tr("Profiles"), ci.paths.profiles)); + lines.push_back(dirLine(QObject::tr("Overwrite"), ci.paths.overwrite)); + } + + // game + QString name = ci.game->gameName(); + if (!ci.gameVariant.isEmpty()) { + name += " (" + ci.gameVariant + ")"; + } + + lines.push_back(QObject::tr("Game: %1").arg(name)); + lines.push_back(QObject::tr("Game location: %1").arg(ci.gameLocation)); + + return lines.join("\n"); +} + +QString ConfirmationPage::dirLine(const QString& caption, const QString& path) const +{ + return QString(" - %1: %2").arg(caption).arg(path); +} + +} // namespace cid diff --git a/src/src/createinstancedialogpages.h b/src/src/createinstancedialogpages.h new file mode 100644 index 0000000..2838b38 --- /dev/null +++ b/src/src/createinstancedialogpages.h @@ -0,0 +1,631 @@ +#ifndef MODORGANIZER_CREATEINSTANCEDIALOGPAGES_INCLUDED +#define MODORGANIZER_CREATEINSTANCEDIALOGPAGES_INCLUDED + +#include "createinstancedialog.h" +#include + +#include +#include +#include + +namespace MOBase +{ +class IPluginGame; +} +class NexusConnectionUI; + +namespace cid +{ + +// returns "%base_dir%/dir" +// +QString makeDefaultPath(const std::wstring& dir); + +// remembers the original text of the given label and, if it contains a %1, +// sets it in setText() +// +class PlaceholderLabel +{ +public: + PlaceholderLabel(QLabel* label); + + // if the original label text contained a %1, replaces it by the arg and + // sets that as the new label text + // + void setText(const QString& arg); + + // whether the label is visible + // + void setVisible(bool b); + +private: + QLabel* m_label; + QString m_original; +}; + +// one page in the wizard +// +// each page can implement one or more selected*() below; those are called +// by CreateInstanceDialog to gather data from all pages +// +class Page +{ +public: + Page(CreateInstanceDialog& dlg); + + // whether this page has been filled and is valid; used by the dialog to + // determine if it can move to the next page + // + virtual bool ready() const; + + // called every time a page is shown in the screen + // + void activated(); + + // overrides whether this page should be skipped; this is used by + // CreateInstanceDialog::setSinglePage() to disable all other pages + // + void setSkip(bool b); + + // whether this page should be skipped + // + bool skip() const; + + // asks the dialog to update its navigation buttons, typically used when a + // page changes its ready state without moving to a different page + // + void updateNavigation(); + + // asks the dialog to move to the next page; some pages will automatically + // advance once the user has made the proper selection + // + void next(); + + // called from the dialog when an action is requested on the current page; + // returns true when handled + // + virtual bool action(CreateInstanceDialog::Actions a); + + // returns the instance type + // + virtual CreateInstanceDialog::Types selectedInstanceType() const; + + // returns the game plugin + // + virtual MOBase::IPluginGame* selectedGame() const; + + // returns the game directory + // + virtual QString selectedGameLocation() const; + + // returns the game variant + // + virtual QString selectedGameVariant(MOBase::IPluginGame* game) const; + + // returns the instance name + // + virtual QString selectedInstanceName() const; + + // returns the various paths + // + virtual CreateInstanceDialog::Paths selectedPaths() const; + + // returns the profile settings + // + virtual CreateInstanceDialog::ProfileSettings profileSettings() const; + +protected: + Ui::CreateInstanceDialog* ui; + CreateInstanceDialog& m_dlg; + const PluginContainer& m_pc; + bool m_skip; + bool m_firstActivation; + + // called every time a page is shown in the screen; `firstTime` is true for + // first activation + // + virtual void doActivated(bool firstTime); + + // implemented by derived classes, overridden by setSkip(true) + // + virtual bool doSkip() const; +}; + +// introduction page, can be disabled by a global setting +// +class IntroPage : public Page +{ +public: + IntroPage(CreateInstanceDialog& dlg); + +protected: + bool doSkip() const override; + +private: + // the setting is only checked once when opening the dialog, or going forwards + // then back after checking the box wouldn't show the intro page any more, + // which would be unexpected + bool m_skip; +}; + +// instance type page +// +class TypePage : public Page +{ +public: + TypePage(CreateInstanceDialog& dlg); + + // whether a type has been been selected + // + bool ready() const override; + + // returns the selected type + // + CreateInstanceDialog::Types selectedInstanceType() const override; + + // selects a global instance + // + void global(); + + // selects a portable instance + // + void portable(); + +protected: + // focuses global instance button on first activation + // + void doActivated(bool firstTime) override; + +private: + CreateInstanceDialog::Types m_type; +}; + +// game plugin page, displays a list of command buttons for each game, along +// with a "browse" button for custom directories and filtering stuff +// +// the game list initially only shows plugins that report isInstalled(), and the +// user has two ways of specifying paths for games that were not found: +// +// 1) by clicking the "Browse..." button and selecting an arbitrary directory +// +// all plugins are checked until one returns true for looksValid(); if none +// of them do, this is an error +// +// 2) by checking the "Show all supported games" checkbox and clicking one +// of the games on the list +// +// if the selected plugin doesn't recognize the directory, the user is +// warned, but is allowed to continue; there's also some logic to try to +// find another plugin that can manage this directory and suggest it +// instead +// +class GamePage : public Page +{ +public: + GamePage(CreateInstanceDialog& dlg); + + // whether a game has been selected + // + bool ready() const override; + + // handles find + // + bool action(CreateInstanceDialog::Actions a) override; + + // returns the selected game + // + MOBase::IPluginGame* selectedGame() const override; + + // returns the selected game directory + QString selectedGameLocation() const override; + + // selects the given game and toggles its associated button; the game + // directory can be overridden + // + // pops up a directory selection dialog if `dir` is empty and the plugin + // hasn't detected the game + // + void select(MOBase::IPluginGame* game, const QString& dir = {}); + + // pops up a directory selection dialog and looks for a plugin to manage + // it + // + void selectCustom(); + + // pops up a warning dialog that the game at the given path is not supported + // by any plugin, includes a list of all game plugins in the details section + // of the dialog + // + void warnUnrecognized(const QString& path); + +private: + // a single game, with its button and custom directory, if any + // + struct Game + { + // game plugin + MOBase::IPluginGame* game = nullptr; + + // button on the ui + QCommandLinkButton* button = nullptr; + + // game directory; set in ctor if the plugin has detected the game, or + // set later when the user selects a directory + QString dir; + + // whether a directory has been set for this game, either auto detected + // or by the user + bool installed = false; + + Game(MOBase::IPluginGame* g); + Game(const Game&) = delete; + Game& operator=(const Game&) = delete; + }; + + // list of all game plugins, even if they're not installed; those are filtered + // from the ui if the checkbox isn't checked + std::vector> m_games; + + // current selection + Game* m_selection; + + // filter + MOBase::FilterWidget m_filter; + + // returns a list of all the game plugins sorted with natsort + // + std::vector sortedGamePlugins() const; + + // creates the m_games list + // + void createGames(); + + // finds the game struct associated with the given game + // + Game* findGame(MOBase::IPluginGame* game); + + // creates the ui for the given game button + // + void createGameButton(Game* g); + + // adds the given button to the ui + // + void addButton(QAbstractButton* b); + + // updates the given button on the ui, sets the text, icon, etc. + // + void updateButton(Game* g); + + // game buttons are toggles, this creates the button for the given game if + // it doesn't exist and toggles it on + // + // the button might not exist if, for example: + // 1) this game is currently filtered out (not installed, doesn't match + // filter text, etc) and, + // 2) the user browses to a directory that a hidden plugin can use + // + void selectButton(Game* g); + + // removes all buttons from the ui + // + void clearButtons(); + + // creates the "Browse" button + // + QCommandLinkButton* createCustomButton(); + + // clears the button list and adds all the buttons to it, depending on + // filtering and stuff + // + void fillList(); + + // checks whether the given path looks valid to the given game plugin + // + // if the plugin doesn't like the path, allows the user to override and + // accept, but also attempts to find another plugin that wants it and + // propose that as an alternative, if there's one + // + // returns: + // - if the user selects the alternative plugin, returns that plugin + // instead; + // - if the path is bad but the user overrides, returns the given plugin + // - if the user cancels or if no plugins can manage the directory, returns + // null + // + Game* checkInstallation(const QString& path, Game* g); + + // tells the user that the path cannot be handled by any game plugin, returns + // true if the user decides to accept anyway + // + bool confirmUnknown(const QString& path, MOBase::IPluginGame* game); + + // tells the user that the path can be handled by a different plugin than the + // selected one and allows them to either + // 1) use the alternative, guessedGame is returned; + // 2) use the selection anyway, selectedGame is returned; or + // 3) cancel, null is returned + // + MOBase::IPluginGame* confirmOtherGame(const QString& path, + MOBase::IPluginGame* selectedGame, + MOBase::IPluginGame* guessedGame); + + // detects if the given path likely contains a Microsoft Store game + // + bool detectMicrosoftStore(const QString& path); + + // tells the user that the path probably contains a Microsoft Store game that + // is not supported, returns true if the user decides to accept anyway. + // + bool confirmMicrosoftStore(const QString& path, MOBase::IPluginGame* game); +}; + +// game variants page; displays a list of command buttons for game variants, as +// reported by the game plugin +// +// this page is always skipped if the game plugin reports no variants +// +class VariantsPage : public Page +{ +public: + VariantsPage(CreateInstanceDialog& dlg); + + // whether a variant has been selected or the game plugin reports no variants + // + bool ready() const override; + + // uses the game selected in the previous page to fill the list, this must be + // called every time because the user may go back in forth in the wizard + // + void doActivated(bool firstTime) override; + + // returns the selected variant, if any + // + QString selectedGameVariant(MOBase::IPluginGame* game) const override; + + // selects the given variant + // + void select(const QString& variant); + +protected: + // returns true if the game has no variants + // + bool doSkip() const override; + +private: + // game that was selected the last time this page was active + MOBase::IPluginGame* m_previousGame; + + // buttons + std::vector m_buttons; + + // selected variant + QString m_selection; + + // fills the list with buttons + void fillList(); +}; + +// instance name page; displays a textbox where the user can enter a name and +// does basic checks to make sure the name is valid and not a duplicate +// +// skipped for portable instances +// +class NamePage : public Page +{ +public: + NamePage(CreateInstanceDialog& dlg); + + // whether a valid name has been entered + // + bool ready() const override; + + // returns the instance name + // + QString selectedInstanceName() const override; + +protected: + // uses the selected game to generate an instance name + // + // as long as the user hasn't modified the textbox, this will regenerate a new + // instance name every time the selected game changes + // + void doActivated(bool firstTime) override; + + // returns true for portable instances + // + bool doSkip() const override; + +private: + // game label, replaces %1 with the game name + PlaceholderLabel m_label; + + // "instance already exists" label, replaces %1 with instance name + PlaceholderLabel m_exists; + + // "instance name invalid" label, replaces %1 with instance name + PlaceholderLabel m_invalid; + + // whether the user has modified the text, prevents auto generation when the + // selected game changes + bool m_modified; + + // whether the instance name is valid + bool m_okay; + + // called when the user modifies the textbox, remember that it has changed and + // calls verify() + // + void onChanged(); + + // check if the entered name is valid, sets m_okay and calls checkName() + // + void verify(); + + // updates the ui depending on whether the given instance name is valid in + // the given directory; returns false if the name is invalid + // + bool checkName(QString parentDir, QString name); +}; + +// instance paths page; shows a single textbox for the base directory, or a +// series of textboxes for all the configurable paths if the advanced checkbox +// is checked +// +class PathsPage : public Page +{ +public: + PathsPage(CreateInstanceDialog& dlg); + + // whether all paths make sense + // + bool ready() const override; + + // returns the selected paths + // + CreateInstanceDialog::Paths selectedPaths() const override; + +protected: + // resets all the paths if the instance type or instance name have changed, + // the current values are kept as long as these don't change; also updates the + // game name in the ui + // + void doActivated(bool firstTime) override; + +private: + // instance name the last time this page was active + QString m_lastInstanceName; + + // instance type the last time this page was active + CreateInstanceDialog::Types m_lastType; + + // help label, replaces %1 by the game name + PlaceholderLabel m_label; + + // path exists/is invalid labels for the simple page, replaces %1 with the + // path + PlaceholderLabel m_simpleExists, m_simpleInvalid; + + // path exists/is invalid labels for the advanced page, replaces %1 with the + // path + PlaceholderLabel m_advancedExists, m_advancedInvalid; + + // whether the paths are valid + bool m_okay; + + // called when the user changes any textbox, checks the path and updates nav + // + void onChanged(); + + // opens a browse directory dialog and sets the given textbox + // + void browse(QLineEdit* e); + + // checks the simple or advanced paths, sets m_okay + // + void checkPaths(); + + // checks a simple path, forwards to checkPath() with the simple labels + // + bool checkSimplePath(const QString& path); + + // checks an advanced path, forwards to checkPath() with the advanced labels + // + bool checkAdvancedPath(const QString& path); + + // returns false if the path is invalid or already exists, sets the given + // labels accordingly + // + bool checkPath(QString path, PlaceholderLabel& existsLabel, + PlaceholderLabel& invalidLabel); + + // replaces %base_dir% in the given path by whatever's in the base path + // textbox + // + QString resolve(const QString& path) const; + + // called when the advanced checkbox is toggled, switches the active page + // and checks the paths + // + void onAdvanced(); + + // called whenever the page becomes active + // + // this normally doesn't change the textboxes unless they're empty, but if the + // instance name or type have changed, `force` is true, which forces all paths + // to reset + // + void setPaths(const QString& name, bool force); + + // sets the given textbox to the path if it's empty or if `force` is true + // + void setIfEmpty(QLineEdit* e, const QString& path, bool force); +}; + +// default settings for profiles page; allow the user to set their preferred +// defaults for the profile options +// +class ProfilePage : public Page +{ +public: + ProfilePage(CreateInstanceDialog& dlg); + + // always returns true, options are boolean + // + bool ready() const override; + + CreateInstanceDialog::ProfileSettings profileSettings() const override; +}; + +// nexus connection page; this reuses the ui found in the settings dialog and +// is skipped if there's already an api key in the credentials manager +// +class NexusPage : public Page +{ +public: + NexusPage(CreateInstanceDialog& dlg); + ~NexusPage(); + + // always returns true, this is an optional page + // + bool ready() const override; + +protected: + // returns true if the api key was already detected + // + bool doSkip() const override; + +private: + // connection ui + std::unique_ptr m_connectionUI; + + // set to true only if the api key was detected when opening the dialog, or + // going back and forth would skip the page after the process is completed, + // which would be unexpected + bool m_skip; +}; + +// shows a text log of all the creation parameters +// +class ConfirmationPage : public Page +{ +public: + ConfirmationPage(CreateInstanceDialog& dlg); + + // recreates the log with the latest settings + // + void doActivated(bool firstTime) override; + + // returns the text for the log + // + QString makeReview() const; + +private: + // returns a log line with the given caption and path, something like + // " - caption: path" + // + QString dirLine(const QString& caption, const QString& path) const; +}; + +} // namespace cid + +#endif // MODORGANIZER_CREATEINSTANCEDIALOGPAGES_INCLUDED diff --git a/src/src/credentialsdialog.cpp b/src/src/credentialsdialog.cpp new file mode 100644 index 0000000..fcf34b4 --- /dev/null +++ b/src/src/credentialsdialog.cpp @@ -0,0 +1,42 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "credentialsdialog.h" +#include "ui_credentialsdialog.h" + +CredentialsDialog::CredentialsDialog(QWidget* parent) + : QDialog(parent), ui(new Ui::CredentialsDialog) +{ + ui->setupUi(this); +} + +CredentialsDialog::~CredentialsDialog() +{ + delete ui; +} + +bool CredentialsDialog::store() const +{ + return ui->rememberCheck->isChecked(); +} + +bool CredentialsDialog::neverAsk() const +{ + return ui->dontaskBox->isChecked(); +} diff --git a/src/src/credentialsdialog.h b/src/src/credentialsdialog.h new file mode 100644 index 0000000..7775bc4 --- /dev/null +++ b/src/src/credentialsdialog.h @@ -0,0 +1,45 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef CREDENTIALSDIALOG_H +#define CREDENTIALSDIALOG_H + +#include + +namespace Ui +{ +class CredentialsDialog; +} + +class CredentialsDialog : public QDialog +{ + Q_OBJECT + +public: + explicit CredentialsDialog(QWidget* parent = 0); + ~CredentialsDialog(); + + bool store() const; + bool neverAsk() const; + +private: + Ui::CredentialsDialog* ui; +}; + +#endif // CREDENTIALSDIALOG_H diff --git a/src/src/credentialsdialog.ui b/src/src/credentialsdialog.ui new file mode 100644 index 0000000..918441d --- /dev/null +++ b/src/src/credentialsdialog.ui @@ -0,0 +1,124 @@ + + + CredentialsDialog + + + + 0 + 0 + 294 + 156 + + + + Login + + + + + + This feature may not work unless you're logged in with Nexus + + + true + + + + + + + + + Username + + + + + + + + + + Password + + + + + + + QLineEdit::Password + + + + + + + + + + + Remember + + + + + + + + + + + Never ask again + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + buttonBox + accepted() + CredentialsDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + CredentialsDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/src/csvbuilder.cpp b/src/src/csvbuilder.cpp new file mode 100644 index 0000000..a7819bf --- /dev/null +++ b/src/src/csvbuilder.cpp @@ -0,0 +1,254 @@ +#include "csvbuilder.h" + +CSVBuilder::CSVBuilder(QIODevice* target) + : m_Out(target), m_Separator(','), m_LineBreak(BREAK_CRLF) +{ + m_Out.setEncoding(QStringConverter::Encoding::Utf8); + + m_QuoteMode[TYPE_INTEGER] = QUOTE_NEVER; + m_QuoteMode[TYPE_FLOAT] = QUOTE_NEVER; + m_QuoteMode[TYPE_STRING] = QUOTE_ONDEMAND; +} + +CSVBuilder::~CSVBuilder() {} + +void CSVBuilder::setFieldSeparator(char sep) +{ + char oldSeparator = m_Separator; + m_Separator = sep; + try { + checkFields(m_Fields); + } catch (const CSVException&) { + m_Separator = oldSeparator; + throw; + } +} + +void CSVBuilder::setLineBreak(CSVBuilder::ELineBreak lineBreak) +{ + m_LineBreak = lineBreak; +} + +void CSVBuilder::setEscapeMode(CSVBuilder::EFieldType type, CSVBuilder::EQuoteMode mode) +{ + m_QuoteMode[type] = mode; +} + +void CSVBuilder::setFields(const std::vector>& fields) +{ + std::vector fieldNames; + std::map fieldTypes; + + for (auto iter = fields.begin(); iter != fields.end(); ++iter) { + fieldNames.push_back(iter->first); + fieldTypes[iter->first] = iter->second; + } + + checkFields(fieldNames); + + m_Fields = fieldNames; + m_FieldTypes = fieldTypes; + m_Defaults.clear(); + m_RowBuffer.clear(); +} + +void CSVBuilder::checkValue(const QString& field, const QVariant& value) +{ + auto typeIter = m_FieldTypes.find(field); + if (typeIter == m_FieldTypes.end()) { + throw CSVException(QObject::tr("invalid field name \"%1\"").arg(field)); + } + + switch (typeIter->second) { + case TYPE_INTEGER: { + if (!value.canConvert()) { + throw CSVException( + QObject::tr("invalid type for \"%1\" (should be integer)").arg(field)); + } + } break; + case TYPE_STRING: { + if (!value.canConvert()) { + throw CSVException( + QObject::tr("invalid type for \"%1\" (should be string)").arg(field)); + } + } break; + case TYPE_FLOAT: { + if (!value.canConvert()) { + throw CSVException( + QObject::tr("invalid type for \"%1\" (should be float)").arg(field)); + } + } break; + } +} + +void CSVBuilder::setDefault(const QString& field, const QVariant& value) +{ + checkValue(field, value); + m_Defaults[field] = value; +} + +void CSVBuilder::writeHeader() +{ + if (m_Fields.size() == 0) { + throw CSVException(QObject::tr("no fields set up yet!")); + } + + for (auto iter = m_Fields.begin(); iter != m_Fields.end(); ++iter) { + if (iter != m_Fields.begin()) { + m_Out << m_Separator; + } + m_Out << *iter; + } + m_Out << lineBreak(); + m_Out.flush(); +} + +void CSVBuilder::setRowField(const QString& field, const QVariant& value) +{ + checkValue(field, value); + m_RowBuffer[field] = value; +} + +void CSVBuilder::writeData(const std::map& data, bool check) +{ + QString line; + QTextStream temp(&line); + + for (auto iter = m_Fields.begin(); iter != m_Fields.end(); ++iter) { + if (iter != m_Fields.begin()) { + temp << separator(); + } + + QVariant val; + + auto valIter = data.find(*iter); + if (valIter == data.end()) { + auto defaultIter = m_Defaults.find(*iter); + if (defaultIter == m_Defaults.end()) { + throw CSVException(QObject::tr("field not set \"%1\"").arg(*iter)); + } else { + val = defaultIter->second; + } + } else { + val = valIter->second; + } + + if (check) { + checkValue(*iter, val); + } + + switch (m_FieldTypes[*iter]) { + case TYPE_INTEGER: { + quoteInsert(temp, val.toInt()); + } break; + case TYPE_FLOAT: { + quoteInsert(temp, val.toFloat()); + } break; + case TYPE_STRING: { + quoteInsert(temp, val.toString()); + } break; + } + } + m_Out << line << lineBreak(); + m_Out.flush(); +} + +void CSVBuilder::quoteInsert(QTextStream& stream, int value) +{ + switch (m_QuoteMode[TYPE_INTEGER]) { + case QUOTE_NEVER: + case QUOTE_ONDEMAND: { + stream << value; + } break; + case QUOTE_ALWAYS: { + stream << "\"" << value << "\""; + } break; + } +} + +void CSVBuilder::quoteInsert(QTextStream& stream, float value) +{ + switch (m_QuoteMode[TYPE_FLOAT]) { + case QUOTE_NEVER: + case QUOTE_ONDEMAND: { + stream << value; + } break; + case QUOTE_ALWAYS: { + stream << "\"" << value << "\""; + } break; + } +} + +void CSVBuilder::quoteInsert(QTextStream& stream, const QString& value) +{ + switch (m_QuoteMode[TYPE_STRING]) { + case QUOTE_NEVER: { + stream << value; + } break; + case QUOTE_ONDEMAND: { + if (value.contains("[,\r\n]")) { + stream << "\"" << value.mid(0).replace("\"", "\"\"") << "\""; + } else { + stream << value; + } + } break; + case QUOTE_ALWAYS: { + stream << "\"" << value.mid(0).replace("\"", "\"\"") << "\""; + } break; + } +} + +void CSVBuilder::writeRow() +{ + writeData(m_RowBuffer, false); // data was tested on input + m_RowBuffer.clear(); +} + +void CSVBuilder::addRow(const std::map& data) +{ + writeData(data, true); +} + +void CSVBuilder::checkFields(const std::vector& fields) +{ + for (auto iter = fields.begin(); iter != fields.end(); ++iter) { + if (iter->contains(m_Separator) || iter->contains('\r') || iter->contains('\n') || + iter->contains('"')) { + throw CSVException(QObject::tr("invalid character in field \"%1\"").arg(*iter)); + } + if (iter->length() == 0) { + throw CSVException(QObject::tr("empty field name")); + } + } +} + +/* + +if(cell.contains(KDefaultEscapeChar) || cell.contains(KDefaultNewLine) + || cell.contains(KDefaultDelimiter)) { + m_currentLine->append(cell.replace(KDefaultNewLine, + QString(KDefaultEscapeChar) + KDefaultEscapeChar) + .prepend(KDefaultEscapeChar) + .append(KDefaultEscapeChar)); +} else { +m_currentLine->append(cell); +}*/ + +const char* CSVBuilder::lineBreak() +{ + switch (m_LineBreak) { + case BREAK_CR: + return "\r"; + case BREAK_CRLF: + return "\r\n"; + case BREAK_LF: + return "\n"; + default: + return "\n"; // default shouldn't be necessary + } +} + +const char CSVBuilder::separator() +{ + return m_Separator; +} diff --git a/src/src/csvbuilder.h b/src/src/csvbuilder.h new file mode 100644 index 0000000..36e8eac --- /dev/null +++ b/src/src/csvbuilder.h @@ -0,0 +1,87 @@ +#ifndef CSVBUILDER_H +#define CSVBUILDER_H + +#include +#include +#include +#include + +class CSVException : public std::exception +{ + +public: + CSVException(const QString& text) : std::exception(), m_Message(text.toLocal8Bit()) {} + + virtual const char* what() const throw() { return m_Message.constData(); } + +private: + QByteArray m_Message; +}; + +class CSVBuilder +{ + +public: + enum EFieldType + { + TYPE_INTEGER, + TYPE_STRING, + TYPE_FLOAT + }; + + enum EQuoteMode + { + QUOTE_NEVER, + QUOTE_ONDEMAND, + QUOTE_ALWAYS + }; + + enum ELineBreak + { + BREAK_LF, + BREAK_CRLF, + BREAK_CR + }; + +public: + CSVBuilder(QIODevice* target); + ~CSVBuilder(); + + void setFieldSeparator(char sep); + void setLineBreak(ELineBreak lineBreak); + void setEscapeMode(EFieldType type, EQuoteMode mode); + void setFields(const std::vector>& fields); + void setDefault(const QString& field, const QVariant& value); + + void writeHeader(); + + void setRowField(const QString& field, const QVariant& value); + void writeRow(); + + void addRow(const std::map& data); + +private: + const char* lineBreak(); + const char separator(); + + void fieldValid(); + void checkFields(const std::vector& fields); + void checkValue(const QString& field, const QVariant& value); + void writeData(const std::map& data, bool check); + + void quoteInsert(QTextStream& stream, int value); + void quoteInsert(QTextStream& stream, float value); + void quoteInsert(QTextStream& stream, const QString& value); + +private: + QTextStream m_Out; + char m_Separator; + ELineBreak m_LineBreak; + std::map m_QuoteMode; + std::vector m_Fields; + std::map m_FieldTypes; + std::map m_Defaults; + std::map m_RowBuffer; +}; + +#endif // CSVBUILDER_H diff --git a/src/src/datatab.cpp b/src/src/datatab.cpp new file mode 100644 index 0000000..c36c7c1 --- /dev/null +++ b/src/src/datatab.cpp @@ -0,0 +1,195 @@ +#include "datatab.h" +#include "filetree.h" +#include "filetreemodel.h" +#include "messagedialog.h" +#include "modelutils.h" +#include "organizercore.h" +#include "settings.h" +#include "ui_mainwindow.h" +#include +#include + +using namespace MOShared; +using namespace MOBase; + +// in mainwindow.cpp +QString UnmanagedModName(); + +DataTab::DataTab(OrganizerCore& core, PluginContainer& pc, QWidget* parent, + Ui::MainWindow* mwui) + : m_core(core), m_pluginContainer(pc), m_parent(parent), + ui{mwui->tabWidget, + mwui->dataTab, + mwui->dataTabRefresh, + mwui->dataTree, + mwui->dataTabShowOnlyConflicts, + mwui->dataTabShowFromArchives, + mwui->dataTabShowHiddenFiles}, + m_needUpdate(true) +{ + m_filetree.reset(new FileTree(core, m_pluginContainer, ui.tree)); + m_filter.setUseSourceSort(true); + m_filter.setFilterColumn(FileTreeModel::FileName); + m_filter.setEdit(mwui->dataTabFilter); + m_filter.setList(mwui->dataTree); + m_filter.setUpdateDelay(true); + + if (auto* m = m_filter.proxyModel()) { + m->setDynamicSortFilter(false); + } + + connect(&m_filter, &FilterWidget::aboutToChange, [&] { + ensureFullyLoaded(); + }); + + connect(ui.refresh, &QPushButton::clicked, [&] { + onRefresh(); + }); + + connect(ui.conflicts, &QCheckBox::toggled, [&] { + onConflicts(); + }); + + connect(ui.archives, &QCheckBox::toggled, [&] { + onArchives(); + }); + + connect(ui.hiddenFiles, &QCheckBox::toggled, [&] { + onHiddenFiles(); + }); + + connect(ui.tree->selectionModel(), &QItemSelectionModel::selectionChanged, [=] { + const auto* fileTreeModel = m_filetree->model(); + const auto& selectedIndexList = MOShared::indexViewToModel( + ui.tree->selectionModel()->selectedRows(), fileTreeModel); + std::set mods; + for (auto& idx : selectedIndexList) { + mods.insert(fileTreeModel->itemFromIndex(idx)->mod()); + } + mwui->modList->setHighlightedMods(mods); + }); + + connect(m_filetree.get(), &FileTree::executablesChanged, this, + &DataTab::executablesChanged); + + connect(m_filetree.get(), &FileTree::originModified, this, &DataTab::originModified); + + connect(m_filetree.get(), &FileTree::displayModInformation, this, + &DataTab::displayModInformation); +} + +void DataTab::saveState(Settings& s) const +{ + s.geometry().saveState(ui.tree->header()); + s.widgets().saveChecked(ui.conflicts); + s.widgets().saveChecked(ui.archives); + s.widgets().saveChecked(ui.hiddenFiles); +} + +void DataTab::restoreState(const Settings& s) +{ + s.geometry().restoreState(ui.tree->header()); + + // prior to 2.3, the list was not sortable, and this remembered in the + // widget state, for whatever reason + ui.tree->setSortingEnabled(true); + + s.widgets().restoreChecked(ui.conflicts); + s.widgets().restoreChecked(ui.archives); + s.widgets().restoreChecked(ui.hiddenFiles); +} + +void DataTab::activated() +{ + if (m_needUpdate) { + updateTree(); + } + // update highlighted mods + ui.tree->selectionModel()->selectionChanged({}, {}); +} + +bool DataTab::isActive() const +{ + return ui.tabs->currentWidget() == ui.tab; +} + +void DataTab::onRefresh() +{ + if (QGuiApplication::keyboardModifiers() & Qt::ShiftModifier) { + m_filetree->model()->setEnabled(false); + m_filetree->clear(); + } + + m_core.refreshDirectoryStructure(); +} + +void DataTab::updateTree() +{ + if (isActive()) { + doUpdateTree(); + } else { + m_needUpdate = true; + } +} + +void DataTab::doUpdateTree() +{ + m_filetree->model()->setEnabled(true); + m_filetree->refresh(); + + if (!m_filter.empty()) { + ensureFullyLoaded(); + + if (auto* m = m_filter.proxyModel()) { + m->invalidate(); + } + } + + m_needUpdate = false; +} + +void DataTab::ensureFullyLoaded() +{ + if (!m_filetree->fullyLoaded()) { + m_filter.setFilteringEnabled(false); + m_filetree->ensureFullyLoaded(); + m_filter.setFilteringEnabled(true); + } +} + +void DataTab::onConflicts() +{ + updateOptions(); +} + +void DataTab::onArchives() +{ + updateOptions(); +} + +void DataTab::onHiddenFiles() +{ + updateOptions(); +} + +void DataTab::updateOptions() +{ + using M = FileTreeModel; + + M::Flags flags = M::NoFlags; + + if (ui.conflicts->isChecked()) { + flags |= M::ConflictsOnly | M::PruneDirectories; + } + + if (ui.archives->isChecked()) { + flags |= M::Archives; + } + + if (ui.hiddenFiles->isChecked()) { + flags |= M::HiddenFiles; + } + + m_filetree->model()->setFlags(flags); + updateTree(); +} diff --git a/src/src/datatab.h b/src/src/datatab.h new file mode 100644 index 0000000..35f8bd9 --- /dev/null +++ b/src/src/datatab.h @@ -0,0 +1,80 @@ +#ifndef MODORGANIZER_DATATAB_INCLUDED +#define MODORGANIZER_DATATAB_INCLUDED + +#include "modinfo.h" +#include "modinfodialogfwd.h" +#include +#include +#include +#include + +namespace Ui +{ +class MainWindow; +} +class OrganizerCore; +class Settings; +class PluginContainer; +class FileTree; + +namespace MOShared +{ +class DirectoryEntry; +} + +class DataTab : public QObject +{ + Q_OBJECT; + +public: + DataTab(OrganizerCore& core, PluginContainer& pc, QWidget* parent, + Ui::MainWindow* ui); + + void saveState(Settings& s) const; + void restoreState(const Settings& s); + void activated(); + + // if the data tab is currently visible, trigger an update of the + // tree, otherwise mark the tree has modified and will refresh when + // the tab is activated + // + void updateTree(); + +signals: + void executablesChanged(); + void originModified(int originID); + void displayModInformation(ModInfo::Ptr m, unsigned int i, ModInfoTabIDs tab); + +private: + struct DataTabUi + { + QTabWidget* tabs; + QWidget* tab; + QPushButton* refresh; + QTreeView* tree; + QCheckBox* conflicts; + QCheckBox* archives; + QCheckBox* hiddenFiles; + }; + + OrganizerCore& m_core; + PluginContainer& m_pluginContainer; + QWidget* m_parent; + DataTabUi ui; + std::unique_ptr m_filetree; + std::vector m_removeLater; + MOBase::FilterWidget m_filter; + bool m_needUpdate; + + void onRefresh(); + void onItemExpanded(QTreeWidgetItem* item); + void onConflicts(); + void onArchives(); + void onHiddenFiles(); + void updateOptions(); + void ensureFullyLoaded(); + bool isActive() const; + void doUpdateTree(); +}; + +#endif // MODORGANIZER_DATATAB_INCLUDED diff --git a/src/src/directoryrefresher.cpp b/src/src/directoryrefresher.cpp new file mode 100644 index 0000000..ccba80c --- /dev/null +++ b/src/src/directoryrefresher.cpp @@ -0,0 +1,514 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "directoryrefresher.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" + +#include "envfs.h" +#include "game_features.h" +#include "iplugingame.h" +#include "modinfo.h" +#include "modinfodialogfwd.h" +#include "organizercore.h" +#include "report.h" +#include "settings.h" +#include "shared/util.h" +#include "utility.h" + +#include + +#include +#include +#include + +#include + +using namespace MOBase; +using namespace MOShared; + +DirectoryStats::DirectoryStats() +{ + std::memset(this, 0, sizeof(DirectoryStats)); +} + +DirectoryStats& DirectoryStats::operator+=(const DirectoryStats& o) +{ + dirTimes += o.dirTimes; + fileTimes += o.fileTimes; + sortTimes += o.sortTimes; + + subdirLookupTimes += o.subdirLookupTimes; + addDirectoryTimes += o.addDirectoryTimes; + + filesLookupTimes += o.filesLookupTimes; + addFileTimes += o.addFileTimes; + addOriginToFileTimes += o.addOriginToFileTimes; + addFileToOriginTimes += o.addFileToOriginTimes; + addFileToRegisterTimes += o.addFileToRegisterTimes; + + originExists += o.originExists; + originCreate += o.originCreate; + originsNeededEnabled += o.originsNeededEnabled; + + subdirExists += o.subdirExists; + subdirCreate += o.subdirCreate; + + fileExists += o.fileExists; + fileCreate += o.fileCreate; + filesInsertedInRegister += o.filesInsertedInRegister; + filesAssignedInRegister += o.filesAssignedInRegister; + + return *this; +} + +std::string DirectoryStats::csvHeader() +{ + QStringList sl = {"dirTimes", + "fileTimes", + "sortTimes", + "subdirLookupTimes", + "addDirectoryTimes", + "filesLookupTimes", + "addFileTimes", + "addOriginToFileTimes", + "addFileToOriginTimes", + "addFileToRegisterTimes", + "originExists", + "originCreate", + "originsNeededEnabled", + "subdirExists", + "subdirCreate", + "fileExists", + "fileCreate", + "filesInsertedInRegister", + "filesAssignedInRegister"}; + + return sl.join(",").toStdString(); +} + +std::string DirectoryStats::toCsv() const +{ + QStringList oss; + + auto s = [](auto ns) { + return ns.count() / 1000.0 / 1000.0 / 1000.0; + }; + + oss << QString::number(s(dirTimes)) << QString::number(s(fileTimes)) + << QString::number(s(sortTimes)) + + << QString::number(s(subdirLookupTimes)) << QString::number(s(addDirectoryTimes)) + + << QString::number(s(filesLookupTimes)) << QString::number(s(addFileTimes)) + << QString::number(s(addOriginToFileTimes)) + << QString::number(s(addFileToOriginTimes)) + << QString::number(s(addFileToRegisterTimes)) + + << QString::number(originExists) << QString::number(originCreate) + << QString::number(originsNeededEnabled) + + << QString::number(subdirExists) << QString::number(subdirCreate) + + << QString::number(fileExists) << QString::number(fileCreate) + << QString::number(filesInsertedInRegister) + << QString::number(filesAssignedInRegister); + + return oss.join(",").toStdString(); +} + +void dumpStats(std::vector& stats) +{ + static int run = 0; + static const std::string file("c:\\tmp\\data.csv"); + + if (run == 0) { + std::ofstream out(file, std::ios::out | std::ios::trunc); + out << std::format("what,run,{}", DirectoryStats::csvHeader()) << "\n"; + } + + std::sort(stats.begin(), stats.end(), [](auto&& a, auto&& b) { + return (naturalCompare(QString::fromStdString(a.mod), + QString::fromStdString(b.mod)) < 0); + }); + + std::ofstream out(file, std::ios::app); + + DirectoryStats total; + for (const auto& s : stats) { + out << std::format("{},{},{}", s.mod, run, s.toCsv()) << "\n"; + total += s; + } + + out << std::format("total,{},{}", run, total.toCsv()) << "\n"; + + ++run; +} + +DirectoryRefresher::DirectoryRefresher(OrganizerCore* core, std::size_t threadCount) + : m_Core(*core), m_threadCount(threadCount), m_lastFileCount(0) +{} + +DirectoryEntry* DirectoryRefresher::stealDirectoryStructure() +{ + QMutexLocker locker(&m_RefreshLock); + return m_Root.release(); +} + +void DirectoryRefresher::setMods( + const std::vector>& mods, + const std::set& managedArchives) +{ + QMutexLocker locker(&m_RefreshLock); + + m_Mods.clear(); + for (auto mod = mods.begin(); mod != mods.end(); ++mod) { + QString name = std::get<0>(*mod); + ModInfo::Ptr info = ModInfo::getByIndex(ModInfo::getIndex(name)); + QString path = std::get<1>(*mod); + QString modDataDir = m_Core.managedGame()->modDataDirectory(); + path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; + m_Mods.push_back( + EntryInfo(name, path, info->stealFiles(), info->archives(), std::get<2>(*mod))); + } + + m_EnabledArchives = managedArchives; +} + +void DirectoryRefresher::cleanStructure(DirectoryEntry* structure) +{ + static const wchar_t* files[] = {L"meta.ini", L"readme.txt"}; + for (int i = 0; i < sizeof(files) / sizeof(wchar_t*); ++i) { + structure->removeFile(files[i]); + } + + static const wchar_t* dirs[] = {L"fomod"}; + for (int i = 0; i < sizeof(dirs) / sizeof(wchar_t*); ++i) { + structure->removeDir(std::wstring(dirs[i])); + } +} + +void DirectoryRefresher::addModBSAToStructure(DirectoryEntry* root, + const QString& modName, int priority, + const QString& directory, + const QStringList& archives) +{ + QStringList loadOrder; + + auto gamePlugins = m_Core.gameFeatures().gameFeature(); + if (gamePlugins) { + loadOrder = gamePlugins->getLoadOrder(); + } + + std::vector lo; + for (auto&& s : loadOrder) { + lo.push_back(s.toStdWString()); + } + + std::vector archivesW; + for (auto&& a : archives) { + archivesW.push_back(a.toStdWString()); + } + + std::set enabledArchives; + for (auto&& a : m_EnabledArchives) { + enabledArchives.insert(a.toStdWString()); + } + + DirectoryStats dummy; + + root->addFromAllBSAs(modName.toStdWString(), + QDir::toNativeSeparators(directory).toStdWString(), priority, + archivesW, enabledArchives, lo, dummy); +} + +void DirectoryRefresher::stealModFilesIntoStructure(DirectoryEntry* directoryStructure, + const QString& modName, + int priority, + const QString& directory, + const QStringList& stealFiles) +{ + std::wstring directoryW = ToWString(QDir::toNativeSeparators(directory)); + + // instead of adding all the files of the target directory, we just change the root of + // the specified files to this mod + DirectoryStats dummy; + FilesOrigin& origin = + directoryStructure->createOrigin(ToWString(modName), directoryW, priority, dummy); + + for (const QString& filename : stealFiles) { + if (filename.isEmpty()) { + log::warn("Trying to find file with no name"); + log::warn(" . modName: {}", modName); + log::warn(" . directory: {}", directory); + log::warn(" . priority: {}", priority); + for (int i = 0; i < stealFiles.length(); ++i) + log::warn(" . stealFiles[{}]: {}", i, stealFiles[i]); + continue; + } + QFileInfo fileInfo(filename); + FileEntryPtr file = directoryStructure->findFile(ToWString(fileInfo.fileName())); + if (file.get() != nullptr) { + if (file->getOrigin() == 0) { + // replace data as the origin on this bsa + file->removeOrigin(0); + } + origin.addFile(file->getIndex()); + file->addOrigin(origin.getID(), file->getFileTime(), L"", -1); + } else { + QString warnStr = fileInfo.absolutePath(); + if (warnStr.isEmpty()) + warnStr = filename; + log::warn("file not found: {}", warnStr); + } + } +} + +void DirectoryRefresher::addModFilesToStructure(DirectoryEntry* directoryStructure, + const QString& modName, int priority, + const QString& directory, + const QStringList& stealFiles) +{ + TimeThis tt("DirectoryRefresher::addModFilesToStructure()"); + + std::wstring directoryW = ToWString(QDir::toNativeSeparators(directory)); + DirectoryStats dummy; + + if (stealFiles.length() > 0) { + stealModFilesIntoStructure(directoryStructure, modName, priority, directory, + stealFiles); + } else { + directoryStructure->addFromOrigin(ToWString(modName), directoryW, priority, dummy); + } +} + +void DirectoryRefresher::addModToStructure(DirectoryEntry* directoryStructure, + const QString& modName, int priority, + const QString& directory, + const QStringList& stealFiles, + const QStringList& archives) +{ + TimeThis tt("DirectoryRefresher::addModToStructure()"); + + DirectoryStats dummy; + + if (stealFiles.length() > 0) { + stealModFilesIntoStructure(directoryStructure, modName, priority, directory, + stealFiles); + } else { + std::wstring directoryW = ToWString(QDir::toNativeSeparators(directory)); + directoryStructure->addFromOrigin(ToWString(modName), directoryW, priority, dummy); + } + + if (Settings::instance().archiveParsing()) { + addModBSAToStructure(directoryStructure, modName, priority, directory, archives); + } +} + +struct ModThread +{ + GameFeatures* gameFeatures; + DirectoryRefreshProgress* progress = nullptr; + DirectoryEntry* ds = nullptr; + std::wstring modName; + std::wstring path; + int prio = -1; + std::vector archives; + std::set enabledArchives; + std::vector* loadOrder = nullptr; + DirectoryStats* stats = nullptr; + env::DirectoryWalker walker; + + std::condition_variable cv; + std::mutex mutex; + bool ready = false; + + void wakeup() + { + { + std::scoped_lock lock(mutex); + ready = true; + } + + cv.notify_one(); + } + + void run() + { + std::unique_lock lock(mutex); + cv.wait(lock, [&] { + return ready; + }); + + SetThisThreadName(QString::fromStdWString(modName + L" refresher")); + ds->addFromOrigin(walker, modName, path, prio, *stats); + + if (Settings::instance().archiveParsing()) { + ds->addFromAllBSAs(modName, path, prio, archives, enabledArchives, *loadOrder, + *stats); + } + + if (progress) { + progress->addDone(); + } + + SetThisThreadName(QString::fromStdWString(L"idle refresher")); + ready = false; + } +}; + +env::ThreadPool g_threads; + +void DirectoryRefresher::updateProgress(const DirectoryRefreshProgress* p) +{ + // careful: called from multiple threads + emit progress(p); +} + +void DirectoryRefresher::addMultipleModsFilesToStructure( + MOShared::DirectoryEntry* directoryStructure, const std::vector& entries, + DirectoryRefreshProgress* progress) +{ + std::vector stats(entries.size()); + + if (progress) { + progress->start(entries.size()); + } + + log::debug("refresher: using {} threads", m_threadCount); + g_threads.setMax(m_threadCount); + + std::vector loadOrder; + if (Settings::instance().archiveParsing()) { + auto gamePlugins = m_Core.gameFeatures().gameFeature(); + if (gamePlugins) { + QStringList lo = gamePlugins->getLoadOrder(); + loadOrder.reserve(lo.size()); + for (auto&& s : lo) { + loadOrder.push_back(s.toStdWString()); + } + } + } + + for (std::size_t i = 0; i < entries.size(); ++i) { + const auto& e = entries[i]; + const int prio = e.priority + 1; + + if constexpr (DirectoryStats::EnableInstrumentation) { + stats[i].mod = entries[i].modName.toStdString(); + } + + try { + if (e.stealFiles.length() > 0) { + stealModFilesIntoStructure(directoryStructure, e.modName, prio, e.absolutePath, + e.stealFiles); + + if (progress) { + progress->addDone(); + } + } else { + auto& mt = g_threads.request(); + + mt.gameFeatures = &m_Core.gameFeatures(); + mt.progress = progress; + mt.ds = directoryStructure; + mt.modName = e.modName.toStdWString(); + mt.path = QDir::toNativeSeparators(e.absolutePath).toStdWString(); + mt.prio = prio; + + mt.archives.clear(); + for (auto&& a : e.archives) { + mt.archives.push_back(a.toStdWString()); + } + + mt.enabledArchives.clear(); + for (auto&& a : m_EnabledArchives) { + mt.enabledArchives.insert(a.toStdWString()); + } + + mt.loadOrder = &loadOrder; + mt.stats = &stats[i]; + + mt.wakeup(); + } + } catch (const std::exception& ex) { + emit error(tr("failed to read mod (%1): %2").arg(e.modName, ex.what())); + } + } + + g_threads.waitForAll(); + + if constexpr (DirectoryStats::EnableInstrumentation) { + dumpStats(stats); + } +} + +void DirectoryRefresher::refresh() +{ + SetThisThreadName("DirectoryRefresher"); + TimeThis tt("DirectoryRefresher::refresh()"); + auto* p = new DirectoryRefreshProgress(this); + + { + QMutexLocker locker(&m_RefreshLock); + + m_Root.reset(new DirectoryEntry(L"data", nullptr, 0)); + + IPluginGame* game = qApp->property("managed_game").value(); + + const QString dataPath = game->dataDirectory().absolutePath(); + std::wstring dataDirectory = + QDir::toNativeSeparators(dataPath).toStdWString(); + + log::debug("refresher: game data directory = '{}'", dataPath); + log::debug("refresher: data directory exists = {}", + QDir(dataPath).exists() ? "yes" : "no"); + + { + DirectoryStats dummy; + m_Root->addFromOrigin(L"data", dataDirectory, 0, dummy); + } + + for (auto directory : game->secondaryDataDirectories().toStdMap()) { + DirectoryStats dummy; + m_Root->addFromOrigin( + directory.first.toStdWString(), + QDir::toNativeSeparators(directory.second.absolutePath()).toStdWString(), 0, + dummy); + } + + std::sort(m_Mods.begin(), m_Mods.end(), [](auto lhs, auto rhs) { + return lhs.priority < rhs.priority; + }); + + addMultipleModsFilesToStructure(m_Root.get(), m_Mods, p); + + m_Root->getFileRegister()->sortOrigins(); + + cleanStructure(m_Root.get()); + + m_lastFileCount = m_Root->getFileRegister()->highestCount(); + log::debug("refresher saw {} files", m_lastFileCount); + } + + p->finish(); + + emit progress(p); + emit refreshed(); +} diff --git a/src/src/directoryrefresher.h b/src/src/directoryrefresher.h new file mode 100644 index 0000000..1e71966 --- /dev/null +++ b/src/src/directoryrefresher.h @@ -0,0 +1,211 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef DIRECTORYREFRESHER_H +#define DIRECTORYREFRESHER_H + +#include "profile.h" +#include "shared/directoryentry.h" +#include "shared/fileregisterfwd.h" +#include +#include +#include +#include +#include +#include + +class OrganizerCore; + +/** + * @brief used to asynchronously generate the virtual view of the combined data + *directory + **/ +class DirectoryRefresher : public QObject +{ + Q_OBJECT; + +public: + struct EntryInfo + { + EntryInfo(const QString& modName, const QString& absolutePath, + const QStringList& stealFiles, const QStringList& archives, int priority) + : modName(modName), absolutePath(absolutePath), stealFiles(stealFiles), + archives(archives), priority(priority) + {} + + QString modName; + QString absolutePath; + QStringList stealFiles; + QStringList archives; + int priority; + }; + + DirectoryRefresher(OrganizerCore* core, std::size_t threadCount); + + /** + * @brief retrieve the updated directory structure + * + * returns a pointer to the updated directory structure. DirectoryRefresher + * deletes its own pointer and the caller takes custody of the pointer + * + * @return updated directory structure + **/ + MOShared::DirectoryEntry* stealDirectoryStructure(); + + /** + * @brief sets up the mods to be included in the directory structure + * + * @param mods list of the mods to include + **/ + void setMods(const std::vector>& mods, + const std::set& managedArchives); + + /** + * @brief sets up the directory where mods are stored + * @param modDirectory the mod directory + * @note this function could be obsoleted easily by storing absolute paths in the + * parameter to setMods. This is legacy + */ + // void setModDirectory(const QString &modDirectory); + + /** + * @brief remove files from the directory structure that are known to be irrelevant to + * the game + * @param the structure to clean + */ + static void cleanStructure(MOShared::DirectoryEntry* structure); + + /** + * @brief add files for a mod to the directory structure, including bsas + * @param directoryStructure + * @param modName + * @param priority + * @param directory + * @param stealFiles + * @param archives + */ + void addModToStructure(MOShared::DirectoryEntry* directoryStructure, + const QString& modName, int priority, const QString& directory, + const QStringList& stealFiles, const QStringList& archives); + + /** + * @brief add only the bsas of a mod to the directory structure + * @param directoryStructure + * @param modName + * @param priority + * @param directory + * @param archives + */ + void addModBSAToStructure(MOShared::DirectoryEntry* directoryStructure, + const QString& modName, int priority, + const QString& directory, const QStringList& archives); + + /** + * @brief add only regular files ofr a mod to the directory structure + * @param directoryStructure + * @param modName + * @param priority + * @param directory + * @param stealFiles + */ + void addModFilesToStructure(MOShared::DirectoryEntry* directoryStructure, + const QString& modName, int priority, + const QString& directory, const QStringList& stealFiles); + + void addMultipleModsFilesToStructure(MOShared::DirectoryEntry* directoryStructure, + const std::vector& entries, + DirectoryRefreshProgress* progress = nullptr); + + void updateProgress(const DirectoryRefreshProgress* p); + +public slots: + + /** + * @brief generate a directory structure from the mods set earlier + **/ + void refresh(); + +signals: + + void progress(const DirectoryRefreshProgress* p); + void error(const QString& error); + void refreshed(); + +private: + OrganizerCore& m_Core; + + std::vector m_Mods; + std::set m_EnabledArchives; + std::unique_ptr m_Root; + QMutex m_RefreshLock; + std::size_t m_threadCount; + std::size_t m_lastFileCount; + + void stealModFilesIntoStructure(MOShared::DirectoryEntry* directoryStructure, + const QString& modName, int priority, + const QString& directory, + const QStringList& stealFiles); +}; + +class DirectoryRefreshProgress : public QObject +{ + Q_OBJECT + +public: + DirectoryRefreshProgress(DirectoryRefresher* r) + : QObject(r), m_refresher(r), m_modCount(0), m_modDone(0), m_finished(false) + {} + + void start(std::size_t modCount) + { + m_modCount = modCount; + m_modDone = 0; + m_finished = false; + } + + bool finished() const { return m_finished; } + + int percentDone() const + { + int percent = 100; + + if (m_modCount > 0) { + const double d = static_cast(m_modDone) / m_modCount; + percent = static_cast(d * 100); + } + + return percent; + } + + void finish() { m_finished = true; } + + void addDone() + { + ++m_modDone; + m_refresher->updateProgress(this); + } + +private: + DirectoryRefresher* m_refresher; + std::size_t m_modCount; + std::atomic m_modDone; + bool m_finished; +}; + +#endif // DIRECTORYREFRESHER_H diff --git a/src/src/disableproxyplugindialog.cpp b/src/src/disableproxyplugindialog.cpp new file mode 100644 index 0000000..10609f8 --- /dev/null +++ b/src/src/disableproxyplugindialog.cpp @@ -0,0 +1,34 @@ +#include "disableproxyplugindialog.h" + +#include "ui_disableproxyplugindialog.h" + +using namespace MOBase; + +DisableProxyPluginDialog::DisableProxyPluginDialog( + MOBase::IPlugin* proxyPlugin, std::vector const& required, + QWidget* parent) + : QDialog(parent), ui(new Ui::DisableProxyPluginDialog) +{ + ui->setupUi(this); + + ui->topLabel->setText(QObject::tr("Disabling the '%1' plugin will prevent the " + "following %2 plugin(s) from working:", + "", required.size()) + .arg(proxyPlugin->localizedName()) + .arg(required.size())); + + connect(ui->noBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(ui->yesBtn, &QPushButton::clicked, this, &QDialog::accept); + + ui->requiredPlugins->setSelectionMode(QAbstractItemView::NoSelection); + ui->requiredPlugins->setRowCount(required.size()); + for (int i = 0; i < required.size(); ++i) { + ui->requiredPlugins->setItem(i, 0, + new QTableWidgetItem(required[i]->localizedName())); + ui->requiredPlugins->setItem(i, 1, + new QTableWidgetItem(required[i]->description())); + ui->requiredPlugins->setRowHeight(i, 9); + } + ui->requiredPlugins->verticalHeader()->setVisible(false); + ui->requiredPlugins->sortByColumn(0, Qt::AscendingOrder); +} diff --git a/src/src/disableproxyplugindialog.h b/src/src/disableproxyplugindialog.h new file mode 100644 index 0000000..249e074 --- /dev/null +++ b/src/src/disableproxyplugindialog.h @@ -0,0 +1,44 @@ +/* +Copyright (C) 2020 Mikaël Capelle. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef DISABLEPROXYPLUGINDIALOG_H +#define DISABLEPROXYPLUGINDIALOG_H + +#include + +#include "ipluginproxy.h" + +namespace Ui +{ +class DisableProxyPluginDialog; +} + +class DisableProxyPluginDialog : public QDialog +{ +public: + DisableProxyPluginDialog(MOBase::IPlugin* proxyPlugin, + std::vector const& required, + QWidget* parent = nullptr); + +private slots: + + Ui::DisableProxyPluginDialog* ui; +}; + +#endif diff --git a/src/src/disableproxyplugindialog.ui b/src/src/disableproxyplugindialog.ui new file mode 100644 index 0000000..9f06878 --- /dev/null +++ b/src/src/disableproxyplugindialog.ui @@ -0,0 +1,174 @@ + + + DisableProxyPluginDialog + + + + 0 + 0 + 522 + 417 + + + + Really disable plugin? + + + + + + + 0 + 0 + + + + + + + + 0 + 0 + + + + + + + Qt::PlainText + + + :/MO/gui/remove + + + Qt::AlignCenter + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 10 + 20 + + + + + + + + Disabling the '%1' plugin will prevent the following plugins from working: + + + + + + + + + + 2 + + + true + + + + Plugin + + + + + Description + + + + + + + + Do you want to continue? You will need to restart Mod Organizer for the change to take effect. + + + + + + + + 0 + 0 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 80 + 0 + + + + Yes + + + + :/MO/gui/remove:/MO/gui/remove + + + + + + + + 0 + 0 + + + + + 80 + 0 + + + + No + + + true + + + false + + + + + + + + + + + + + diff --git a/src/src/dlls.manifest.debug.qt6 b/src/src/dlls.manifest.debug.qt6 new file mode 100644 index 0000000..cd0842c --- /dev/null +++ b/src/src/dlls.manifest.debug.qt6 @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/src/dlls.manifest.qt6 b/src/src/dlls.manifest.qt6 new file mode 100644 index 0000000..a5bd11a --- /dev/null +++ b/src/src/dlls.manifest.qt6 @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/src/downloadlist.cpp b/src/src/downloadlist.cpp new file mode 100644 index 0000000..dd35ac2 --- /dev/null +++ b/src/src/downloadlist.cpp @@ -0,0 +1,332 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "downloadlist.h" +#include "downloadmanager.h" +#include "modlistdropinfo.h" +#include "organizercore.h" +#include "settings.h" +#include +#include +#include +#include +#include +#include + +using namespace MOBase; + +DownloadList::DownloadList(OrganizerCore& core, QObject* parent) + : QAbstractTableModel(parent), m_manager(*core.downloadManager()), + m_settings(core.settings()) +{ + connect(&m_manager, SIGNAL(update(int)), this, SLOT(update(int))); + connect(&m_manager, SIGNAL(aboutToUpdate()), this, SLOT(aboutToUpdate())); +} + +int DownloadList::rowCount(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + // root item + return m_manager.numTotalDownloads() + m_manager.numPendingDownloads(); + } else { + return 0; + } +} + +int DownloadList::columnCount(const QModelIndex&) const +{ + return COL_COUNT; +} + +QModelIndex DownloadList::index(int row, int column, const QModelIndex&) const +{ + return createIndex(row, column, row); +} + +QModelIndex DownloadList::parent(const QModelIndex&) const +{ + return QModelIndex(); +} + +QVariant DownloadList::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if ((role == Qt::DisplayRole) && (orientation == Qt::Horizontal)) { + switch (section) { + case COL_NAME: + return tr("Name"); + case COL_MODNAME: + return tr("Mod name"); + case COL_VERSION: + return tr("Version"); + case COL_ID: + return tr("Nexus ID"); + case COL_SIZE: + return tr("Size"); + case COL_STATUS: + return tr("Status"); + case COL_FILETIME: + return tr("Filetime"); + case COL_SOURCEGAME: + return tr("Source Game"); + default: + return QVariant(); + } + } else { + return QAbstractItemModel::headerData(section, orientation, role); + } +} + +Qt::ItemFlags DownloadList::flags(const QModelIndex& idx) const +{ + return QAbstractTableModel::flags(idx) | Qt::ItemIsDragEnabled; +} + +QMimeData* DownloadList::mimeData(const QModelIndexList& indexes) const +{ + QMimeData* result = QAbstractItemModel::mimeData(indexes); + result->setData("text/plain", ModListDropInfo::DownloadText); + return result; +} + +QVariant DownloadList::data(const QModelIndex& index, int role) const +{ + bool pendingDownload = index.row() >= m_manager.numTotalDownloads(); + if (role == Qt::DisplayRole) { + if (pendingDownload) { + std::tuple nexusids = + m_manager.getPendingDownload(index.row() - m_manager.numTotalDownloads()); + switch (index.column()) { + case COL_NAME: + return tr("< game %1 mod %2 file %3 >") + .arg(std::get<0>(nexusids)) + .arg(std::get<1>(nexusids)) + .arg(std::get<2>(nexusids)); + case COL_SIZE: + return tr("Unknown"); + case COL_STATUS: + return tr("Pending"); + } + } else { + switch (index.column()) { + case COL_NAME: + return m_settings.interface().metaDownloads() + ? m_manager.getDisplayName(index.row()) + : m_manager.getFileName(index.row()); + case COL_MODNAME: { + if (m_manager.isInfoIncomplete(index.row())) { + return {}; + } else { + const MOBase::ModRepositoryFileInfo* info = + m_manager.getFileInfo(index.row()); + return info->modName; + } + } + case COL_VERSION: { + if (m_manager.isInfoIncomplete(index.row())) { + return {}; + } else { + const MOBase::ModRepositoryFileInfo* info = + m_manager.getFileInfo(index.row()); + return info->version.canonicalString(); + } + } + case COL_ID: { + if (m_manager.isInfoIncomplete(index.row())) { + return {}; + } else { + return QString("%1").arg(m_manager.getModID(index.row())); + } + } + case COL_SOURCEGAME: { + if (m_manager.isInfoIncomplete(index.row())) { + return {}; + } else { + return QString("%1").arg(m_manager.getDisplayGameName(index.row())); + } + } + case COL_SIZE: + return MOBase::localizedByteSize(m_manager.getFileSize(index.row())); + case COL_FILETIME: + return m_manager.getFileTime(index.row()); + case COL_STATUS: + switch (m_manager.getState(index.row())) { + // STATE_DOWNLOADING handled by DownloadProgressDelegate + case DownloadManager::STATE_STARTED: + return tr("Started"); + case DownloadManager::STATE_CANCELING: + return tr("Canceling"); + case DownloadManager::STATE_PAUSING: + return tr("Pausing"); + case DownloadManager::STATE_CANCELED: + return tr("Canceled"); + case DownloadManager::STATE_PAUSED: + return tr("Paused"); + case DownloadManager::STATE_ERROR: + return tr("Error"); + case DownloadManager::STATE_FETCHINGMODINFO: + return tr("Fetching Info"); + case DownloadManager::STATE_FETCHINGFILEINFO: + return tr("Fetching Info"); + case DownloadManager::STATE_FETCHINGMODINFO_MD5: + return tr("Fetching Info"); + case DownloadManager::STATE_READY: + return tr("Downloaded"); + case DownloadManager::STATE_INSTALLED: + return tr("Installed"); + case DownloadManager::STATE_UNINSTALLED: + return tr("Uninstalled"); + } + } + } + } else if (role == Qt::ForegroundRole && index.column() == COL_STATUS) { + if (pendingDownload) { + return QColor(Qt::darkBlue); + } else { + DownloadManager::DownloadState state = m_manager.getState(index.row()); + if (state == DownloadManager::STATE_READY) + return QColor(Qt::darkGreen); + else if (state == DownloadManager::STATE_UNINSTALLED) + return QColor(Qt::darkYellow); + else if (state == DownloadManager::STATE_PAUSED) + return QColor(Qt::darkRed); + } + } else if (role == Qt::ToolTipRole) { + if (pendingDownload) { + return tr("Pending download"); + } else { + QString text = m_manager.getFileName(index.row()) + "\n"; + if (m_manager.isInfoIncomplete(index.row())) { + text += tr("Information missing, please select \"Query Info\" from the context " + "menu to re-retrieve."); + } else { + const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(index.row()); + return QString("%1 (ID %2) %3
    %4") + .arg(info->modName) + .arg(m_manager.getModID(index.row())) + .arg(info->version.canonicalString()) + .arg(info->description.mid(0, 4096)); + } + return text; + } + } else if (role == Qt::DecorationRole && index.column() == COL_NAME) { + if (!pendingDownload && + m_manager.getState(index.row()) >= DownloadManager::STATE_READY && + m_manager.isInfoIncomplete(index.row())) + return QIcon(":/MO/gui/warning_16"); + } else if (role == Qt::TextAlignmentRole) { + if (index.column() == COL_SIZE) + return QVariant(Qt::AlignVCenter | Qt::AlignRight); + else + return QVariant(Qt::AlignVCenter | Qt::AlignLeft); + } + return QVariant(); +} + +void DownloadList::aboutToUpdate() +{ + emit beginResetModel(); +} + +void DownloadList::update(int row) +{ + if (row < 0) + emit endResetModel(); + else if (row < this->rowCount()) + emit dataChanged( + this->index(row, 0, QModelIndex()), + this->index(row, this->columnCount(QModelIndex()) - 1, QModelIndex())); + else + log::error("invalid row {} in download list, update failed", row); +} + +bool DownloadList::lessThanPredicate(const QModelIndex& left, const QModelIndex& right) +{ + int leftIndex = left.row(); + int rightIndex = right.row(); + if ((leftIndex < m_manager.numTotalDownloads()) && + (rightIndex < m_manager.numTotalDownloads())) { + if (left.column() == DownloadList::COL_NAME) { + return left.data(Qt::DisplayRole) + .toString() + .compare(right.data(Qt::DisplayRole).toString(), Qt::CaseInsensitive) < + 0; + } else if (left.column() == DownloadList::COL_MODNAME) { + QString leftName, rightName; + + if (!m_manager.isInfoIncomplete(left.row())) { + const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(left.row()); + leftName = info->modName; + } + + if (!m_manager.isInfoIncomplete(right.row())) { + const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(right.row()); + rightName = info->modName; + } + + return leftName.compare(rightName, Qt::CaseInsensitive) < 0; + } else if (left.column() == DownloadList::COL_VERSION) { + MOBase::VersionInfo versionLeft, versionRight; + + if (!m_manager.isInfoIncomplete(left.row())) { + const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(left.row()); + versionLeft = info->version; + } + + if (!m_manager.isInfoIncomplete(right.row())) { + const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(right.row()); + versionRight = info->version; + } + + return versionLeft < versionRight; + } else if (left.column() == DownloadList::COL_ID) { + int leftID = 0, rightID = 0; + + if (!m_manager.isInfoIncomplete(left.row())) { + const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(left.row()); + leftID = info->modID; + } + + if (!m_manager.isInfoIncomplete(right.row())) { + const MOBase::ModRepositoryFileInfo* info = m_manager.getFileInfo(right.row()); + rightID = info->modID; + } + + return leftID < rightID; + } else if (left.column() == DownloadList::COL_STATUS) { + DownloadManager::DownloadState leftState = m_manager.getState(left.row()); + DownloadManager::DownloadState rightState = m_manager.getState(right.row()); + if (leftState == rightState) + return m_manager.getFileTime(left.row()) > m_manager.getFileTime(right.row()); + else + return leftState < rightState; + } else if (left.column() == DownloadList::COL_SIZE) { + return m_manager.getFileSize(left.row()) < m_manager.getFileSize(right.row()); + } else if (left.column() == DownloadList::COL_FILETIME) { + return m_manager.getFileTime(left.row()) < m_manager.getFileTime(right.row()); + } else if (left.column() == DownloadList::COL_SOURCEGAME) { + return m_manager.getDisplayGameName(left.row()) < + m_manager.getDisplayGameName(right.row()); + } else { + return leftIndex < rightIndex; + } + } else { + return leftIndex < rightIndex; + } +} diff --git a/src/src/downloadlist.h b/src/src/downloadlist.h new file mode 100644 index 0000000..73e499d --- /dev/null +++ b/src/src/downloadlist.h @@ -0,0 +1,102 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef DOWNLOADLIST_H +#define DOWNLOADLIST_H + +#include + +class OrganizerCore; +class DownloadManager; +class Settings; + +/** + * @brief model of the list of active and completed downloads + **/ +class DownloadList : public QAbstractTableModel +{ + + Q_OBJECT + +public: + enum EColumn + { + COL_NAME = 0, + COL_STATUS, + COL_SIZE, + COL_FILETIME, + COL_MODNAME, + COL_VERSION, + COL_ID, + COL_SOURCEGAME, + + // number of columns + COL_COUNT + }; + +public: + explicit DownloadList(OrganizerCore& core, QObject* parent = 0); + + /** + * @brief retrieve the number of rows to display. Invoked by Qt + * + * @param parent not relevant for this implementation + * @return number of rows to display + **/ + virtual int rowCount(const QModelIndex& parent = QModelIndex()) const; + virtual int columnCount(const QModelIndex& parent) const; + + QModelIndex index(int row, int column, const QModelIndex& parent) const; + QModelIndex parent(const QModelIndex& child) const; + Qt::ItemFlags flags(const QModelIndex& idx) const override; + QMimeData* mimeData(const QModelIndexList& indexes) const override; + + virtual QVariant headerData(int section, Qt::Orientation orientation, int role) const; + + /** + * @brief retrieve the data to display in a specific row. Invoked by Qt + * + * @param index location to look up + * @param role ... Defaults to Qt::DisplayRole. + * @return this implementation only returns the row, the QItemDelegate implementation + *is expected to fetch its information from the DownloadManager + **/ + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const; + + // used in DownloadsTab as the sorting predicate for the filter widget + // + bool lessThanPredicate(const QModelIndex& left, const QModelIndex& right); + +public slots: + + /** + * @brief used to inform the model that data has changed + * + * @param row the row that changed. This can be negative to update the whole view + **/ + void update(int row); + + void aboutToUpdate(); + +private: + DownloadManager& m_manager; + Settings& m_settings; +}; + +#endif // DOWNLOADLIST_H diff --git a/src/src/downloadlistview.cpp b/src/src/downloadlistview.cpp new file mode 100644 index 0000000..5b9b9e4 --- /dev/null +++ b/src/src/downloadlistview.cpp @@ -0,0 +1,511 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "downloadlistview.h" +#include "downloadlist.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; + +DownloadProgressDelegate::DownloadProgressDelegate(DownloadManager* manager, + DownloadListView* list) + : QStyledItemDelegate(list), m_Manager(manager), m_List(list) +{} + +void DownloadProgressDelegate::paint(QPainter* painter, + const QStyleOptionViewItem& option, + const QModelIndex& index) const +{ + QModelIndex sourceIndex; + + if (auto* proxy = dynamic_cast(m_List->model())) { + sourceIndex = proxy->mapToSource(index); + } else { + sourceIndex = index; + } + + bool pendingDownload = (sourceIndex.row() >= m_Manager->numTotalDownloads()); + if (sourceIndex.column() == DownloadList::COL_STATUS && !pendingDownload && + m_Manager->getState(sourceIndex.row()) == DownloadManager::STATE_DOWNLOADING) { + QProgressBar progressBar; + progressBar.setProperty("downloadView", option.widget->property("downloadView")); + progressBar.setProperty("downloadProgress", true); + progressBar.resize(option.rect.width(), option.rect.height()); + progressBar.setTextVisible(true); + progressBar.setAlignment(Qt::AlignCenter); + progressBar.setMinimum(0); + progressBar.setMaximum(100); + progressBar.setValue(m_Manager->getProgress(sourceIndex.row()).first); + progressBar.setFormat(m_Manager->getProgress(sourceIndex.row()).second); + progressBar.setStyle(QApplication::style()); + + // paint the background with default delegate first to preserve table cell styling + QStyledItemDelegate::paint(painter, option, index); + + painter->save(); + painter->translate(option.rect.topLeft()); + progressBar.render(painter); + painter->restore(); + } else { + QStyledItemDelegate::paint(painter, option, index); + } +} + +void DownloadListHeader::customResizeSections() +{ + // find the rightmost column that is not hidden + int rightVisible = count() - 1; + while (isSectionHidden(rightVisible) && rightVisible > 0) + rightVisible--; + + // if that column is already squashed, squash others to the right side -- + // otherwise to the left + if (sectionSize(rightVisible) == minimumSectionSize()) { + for (int idx = rightVisible; idx >= 0; idx--) { + if (!isSectionHidden(idx)) { + if (length() != width()) + resizeSection(idx, std::max(sectionSize(idx) + width() - length(), + minimumSectionSize())); + else + break; + } + } + } else { + for (int idx = 0; idx <= rightVisible; idx++) { + if (!isSectionHidden(idx)) { + if (length() != width()) + resizeSection(idx, std::max(sectionSize(idx) + width() - length(), + minimumSectionSize())); + else + break; + } + } + } +} + +void DownloadListHeader::mouseReleaseEvent(QMouseEvent* event) +{ + QHeaderView::mouseReleaseEvent(event); + customResizeSections(); +} + +DownloadListView::DownloadListView(QWidget* parent) : QTreeView(parent) +{ + setHeader(new DownloadListHeader(Qt::Horizontal, this)); + + header()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter); + header()->setSectionsMovable(true); + header()->setContextMenuPolicy(Qt::CustomContextMenu); + header()->setCascadingSectionResizes(true); + header()->setStretchLastSection(false); + header()->setSectionResizeMode(QHeaderView::Interactive); + header()->setDefaultSectionSize(100); + + setUniformRowHeights(true); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + sortByColumn(1, Qt::DescendingOrder); + + connect(header(), SIGNAL(customContextMenuRequested(QPoint)), this, + SLOT(onHeaderCustomContextMenu(QPoint))); + connect(this, SIGNAL(doubleClicked(QModelIndex)), this, + SLOT(onDoubleClick(QModelIndex))); + connect(this, SIGNAL(customContextMenuRequested(QPoint)), this, + SLOT(onCustomContextMenu(QPoint))); +} + +DownloadListView::~DownloadListView() {} + +void DownloadListView::setManager(DownloadManager* manager) +{ + m_Manager = manager; + + // hide these columns by default + // + // note that this is overridden by the ini if MO has been started at least + // once before, which is handled in MainWindow::processUpdates() for older + // versions + header()->hideSection(DownloadList::COL_MODNAME); + header()->hideSection(DownloadList::COL_VERSION); + header()->hideSection(DownloadList::COL_ID); + header()->hideSection(DownloadList::COL_SOURCEGAME); +} + +void DownloadListView::setSourceModel(DownloadList* sourceModel) +{ + m_SourceModel = sourceModel; +} + +void DownloadListView::onDoubleClick(const QModelIndex& index) +{ + QModelIndex sourceIndex = + qobject_cast(model())->mapToSource(index); + if (m_Manager->getState(sourceIndex.row()) >= DownloadManager::STATE_READY) + emit installDownload(sourceIndex.row()); + else if ((m_Manager->getState(sourceIndex.row()) == DownloadManager::STATE_PAUSED) || + (m_Manager->getState(sourceIndex.row()) == DownloadManager::STATE_PAUSING)) + emit resumeDownload(sourceIndex.row()); +} + +void DownloadListView::onHeaderCustomContextMenu(const QPoint& point) +{ + QMenu menu; + + // display a list of all headers as checkboxes + QAbstractItemModel* model = header()->model(); + for (int i = 1; i < model->columnCount(); ++i) { + QString columnName = model->headerData(i, Qt::Horizontal).toString(); + QCheckBox* checkBox = new QCheckBox(&menu); + checkBox->setText(columnName); + checkBox->setChecked(!header()->isSectionHidden(i)); + QWidgetAction* checkableAction = new QWidgetAction(&menu); + checkableAction->setDefaultWidget(checkBox); + menu.addAction(checkableAction); + } + + menu.exec(header()->viewport()->mapToGlobal(point)); + + // view/hide columns depending on check-state + int i = 1; + for (const QAction* action : menu.actions()) { + const QWidgetAction* widgetAction = qobject_cast(action); + if (widgetAction != nullptr) { + const QCheckBox* checkBox = + qobject_cast(widgetAction->defaultWidget()); + if (checkBox != nullptr) { + header()->setSectionHidden(i, !checkBox->isChecked()); + } + } + ++i; + } + + qobject_cast(header())->customResizeSections(); +} + +void DownloadListView::resizeEvent(QResizeEvent* event) +{ + QTreeView::resizeEvent(event); + qobject_cast(header())->customResizeSections(); +} + +void DownloadListView::onCustomContextMenu(const QPoint& point) +{ + QMenu menu(this); + QModelIndex index = indexAt(point); + bool hidden = false; + + try { + if (index.row() >= 0) { + const int row = + qobject_cast(model())->mapToSource(index).row(); + DownloadManager::DownloadState state = m_Manager->getState(row); + + hidden = m_Manager->isHidden(row); + + if (state >= DownloadManager::STATE_READY) { + menu.addAction(tr("Install"), [=] { + issueInstall(row); + }); + if (m_Manager->isInfoIncomplete(row)) { + menu.addAction(tr("Query Info"), [=] { + issueQueryInfoMd5(row); + }); + } else { + menu.addAction(tr("Visit on Nexus"), [=] { + issueVisitOnNexus(row); + }); + menu.addAction(tr("Visit the uploader's profile"), [=] { + issueVisitUploaderProfile(row); + }); + } + menu.addAction(tr("Open File"), [=] { + issueOpenFile(row); + }); + menu.addAction(tr("Open Meta File"), [=] { + issueOpenMetaFile(row); + }); + menu.addAction(tr("Reveal in Explorer"), [=] { + issueOpenInDownloadsFolder(row); + }); + + menu.addSeparator(); + + menu.addAction(tr("Delete..."), [=] { + issueDelete(row); + }); + if (hidden) + menu.addAction(tr("Un-Hide"), [=] { + issueRestoreToView(row); + }); + else + menu.addAction(tr("Hide"), [=] { + issueRemoveFromView(row); + }); + } else if (state == DownloadManager::STATE_DOWNLOADING) { + menu.addAction(tr("Cancel"), [=] { + issueCancel(row); + }); + menu.addAction(tr("Pause"), [=] { + issuePause(row); + }); + menu.addAction(tr("Reveal in Explorer"), [=] { + issueOpenInDownloadsFolder(row); + }); + } else if ((state == DownloadManager::STATE_PAUSED) || + (state == DownloadManager::STATE_ERROR) || + (state == DownloadManager::STATE_PAUSING)) { + menu.addAction(tr("Delete..."), [=] { + issueDelete(row); + }); + menu.addAction(tr("Resume"), [=] { + issueResume(row); + }); + menu.addAction(tr("Reveal in Explorer"), [=] { + issueOpenInDownloadsFolder(row); + }); + } + + menu.addSeparator(); + } + } catch (std::exception&) { + // this happens when the download index is not found, ignore it and don't + // display download-specific actions + } + + menu.addAction(tr("Delete Installed Downloads..."), [=] { + issueDeleteCompleted(); + }); + menu.addAction(tr("Delete Uninstalled Downloads..."), [=] { + issueDeleteUninstalled(); + }); + menu.addAction(tr("Delete All Downloads..."), [=] { + issueDeleteAll(); + }); + + menu.addSeparator(); + if (!hidden) { + menu.addAction(tr("Hide Installed..."), [=] { + issueRemoveFromViewCompleted(); + }); + menu.addAction(tr("Hide Uninstalled..."), [=] { + issueRemoveFromViewUninstalled(); + }); + menu.addAction(tr("Hide All..."), [=] { + issueRemoveFromViewAll(); + }); + } else { + menu.addAction(tr("Un-Hide All..."), [=] { + issueRestoreToViewAll(); + }); + } + + menu.exec(viewport()->mapToGlobal(point)); +} + +void DownloadListView::keyPressEvent(QKeyEvent* event) +{ + if (selectionModel()->hasSelection()) { + const int row = qobject_cast(model()) + ->mapToSource(currentIndex()) + .row(); + auto state = m_Manager->getState(row); + if (state >= DownloadManager::STATE_READY) { + if (event->key() == Qt::Key_Enter || event->key() == Qt::Key_Return) { + issueInstall(row); + } else if (event->key() == Qt::Key_Delete) { + issueDelete(row); + } + } else if (state == DownloadManager::STATE_DOWNLOADING) { + if (event->key() == Qt::Key_Delete) { + issueCancel(row); + } else if (event->key() == Qt::Key_Space) { + issuePause(event->key()); + } + } else if (state == DownloadManager::STATE_PAUSED || + state == DownloadManager::STATE_ERROR || + state == DownloadManager::STATE_PAUSING) { + if (event->key() == Qt::Key_Delete) { + issueDelete(row); + } else if (event->key() == Qt::Key_Space) { + issueResume(row); + } + } + } + QTreeView::keyPressEvent(event); +} + +void DownloadListView::issueInstall(int index) +{ + emit installDownload(index); +} + +void DownloadListView::issueQueryInfo(int index) +{ + emit queryInfo(index); +} + +void DownloadListView::issueQueryInfoMd5(int index) +{ + emit queryInfoMd5(index); +} + +void DownloadListView::issueDelete(int index) +{ + const auto r = MOBase::TaskDialog(this, tr("Delete download")) + .main("Are you sure you want to delete this download?") + .content(m_Manager->getFilePath(index)) + .icon(QMessageBox::Question) + .button({tr("Move to the Recycle Bin"), QMessageBox::Yes}) + .button({tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + if (r != QMessageBox::Yes) { + return; + } + + emit removeDownload(index, true); +} + +void DownloadListView::issueRemoveFromView(int index) +{ + log::debug("removing from view: {}", index); + emit removeDownload(index, false); +} + +void DownloadListView::issueRestoreToView(int index) +{ + emit restoreDownload(index); +} + +void DownloadListView::issueRestoreToViewAll() +{ + emit restoreDownload(-1); +} + +void DownloadListView::issueVisitOnNexus(int index) +{ + emit visitOnNexus(index); +} + +void DownloadListView::issueVisitUploaderProfile(int index) +{ + emit visitUploaderProfile(index); +} + +void DownloadListView::issueOpenFile(int index) +{ + emit openFile(index); +} + +void DownloadListView::issueOpenMetaFile(int index) +{ + emit openMetaFile(index); +} + +void DownloadListView::issueOpenInDownloadsFolder(int index) +{ + emit openInDownloadsFolder(index); +} + +void DownloadListView::issueCancel(int index) +{ + emit cancelDownload(index); +} + +void DownloadListView::issuePause(int index) +{ + emit pauseDownload(index); +} + +void DownloadListView::issueResume(int index) +{ + emit resumeDownload(index); +} + +void DownloadListView::issueDeleteAll() +{ + if (QMessageBox::warning( + nullptr, tr("Delete Files?"), + tr("This will remove all finished downloads from this list and from " + "disk.\n\nAre you absolutely sure you want to proceed?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + emit removeDownload(-1, true); + } +} + +void DownloadListView::issueDeleteCompleted() +{ + if (QMessageBox::warning( + nullptr, tr("Delete Files?"), + tr("This will remove all installed downloads from this list and from " + "disk.\n\nAre you absolutely sure you want to proceed?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + emit removeDownload(-2, true); + } +} + +void DownloadListView::issueDeleteUninstalled() +{ + if (QMessageBox::warning( + nullptr, tr("Delete Files?"), + tr("This will remove all uninstalled downloads from this list and from " + "disk.\n\nAre you absolutely sure you want to proceed?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + emit removeDownload(-3, true); + } +} + +void DownloadListView::issueRemoveFromViewAll() +{ + if (QMessageBox::question(nullptr, tr("Hide Files?"), + tr("This will remove all finished downloads from this list " + "(but NOT from disk)."), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + emit removeDownload(-1, false); + } +} + +void DownloadListView::issueRemoveFromViewCompleted() +{ + if (QMessageBox::question(nullptr, tr("Hide Files?"), + tr("This will remove all installed downloads from this " + "list (but NOT from disk)."), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + emit removeDownload(-2, false); + } +} + +void DownloadListView::issueRemoveFromViewUninstalled() +{ + if (QMessageBox::question(nullptr, tr("Hide Files?"), + tr("This will remove all uninstalled downloads from this " + "list (but NOT from disk)."), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + emit removeDownload(-3, false); + } +} diff --git a/src/src/downloadlistview.h b/src/src/downloadlistview.h new file mode 100644 index 0000000..f527995 --- /dev/null +++ b/src/src/downloadlistview.h @@ -0,0 +1,132 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef DOWNLOADLISTWIDGET_H +#define DOWNLOADLISTWIDGET_H + +#include "downloadlist.h" +#include "downloadmanager.h" +#include +#include +#include +#include +#include +#include +#include + +namespace Ui +{ +class DownloadListView; +} + +class DownloadListView; + +class DownloadProgressDelegate : public QStyledItemDelegate +{ + Q_OBJECT + +public: + DownloadProgressDelegate(DownloadManager* manager, DownloadListView* list); + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + +private: + DownloadManager* m_Manager; + DownloadListView* m_List; +}; + +class DownloadListHeader : public QHeaderView +{ + Q_OBJECT + +public: + explicit DownloadListHeader(Qt::Orientation orientation, QWidget* parent = nullptr) + : QHeaderView(orientation, parent) + {} + void customResizeSections(); + +private: + void mouseReleaseEvent(QMouseEvent* event) override; +}; + +class DownloadListView : public QTreeView +{ + Q_OBJECT + +public: + explicit DownloadListView(QWidget* parent = 0); + ~DownloadListView(); + + void setManager(DownloadManager* manager); + void setSourceModel(DownloadList* sourceModel); + +signals: + void installDownload(int index); + void queryInfo(int index); + void queryInfoMd5(int index); + void removeDownload(int index, bool deleteFile); + void restoreDownload(int index); + void cancelDownload(int index); + void pauseDownload(int index); + void resumeDownload(int index); + void visitOnNexus(int index); + void visitUploaderProfile(int index); + void openFile(int index); + void openMetaFile(int index); + void openInDownloadsFolder(int index); + +protected: + void keyPressEvent(QKeyEvent* event) override; + +private slots: + void onDoubleClick(const QModelIndex& index); + void onCustomContextMenu(const QPoint& point); + void onHeaderCustomContextMenu(const QPoint& point); + + void issueInstall(int index); + void issueDelete(int index); + void issueRemoveFromView(int index); + void issueRestoreToView(int index); + void issueRestoreToViewAll(); + void issueVisitOnNexus(int index); + void issueVisitUploaderProfile(int index); + void issueOpenFile(int index); + void issueOpenMetaFile(int index); + void issueOpenInDownloadsFolder(int index); + void issueCancel(int index); + void issuePause(int index); + void issueResume(int index); + void issueDeleteAll(); + void issueDeleteCompleted(); + void issueDeleteUninstalled(); + void issueRemoveFromViewAll(); + void issueRemoveFromViewCompleted(); + void issueRemoveFromViewUninstalled(); + void issueQueryInfo(int index); + void issueQueryInfoMd5(int index); + +private: + DownloadManager* m_Manager; + DownloadList* m_SourceModel = 0; + + void resizeEvent(QResizeEvent* event); +}; + +#endif // DOWNLOADLISTWIDGET_H diff --git a/src/src/downloadmanager.cpp b/src/src/downloadmanager.cpp new file mode 100644 index 0000000..875bcd9 --- /dev/null +++ b/src/src/downloadmanager.cpp @@ -0,0 +1,2449 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "downloadmanager.h" + +#include "bbcode.h" +#include "envfs.h" +#include "filesystemutilities.h" +#include "iplugingame.h" +#include "nexusinterface.h" +#include "nxmaccessmanager.h" +#include "nxmurl.h" +#include "organizercore.h" +#include "selectiondialog.h" +#include "shared/util.h" +#include "utility.h" +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace MOBase; + +// TODO limit number of downloads, also display download during nxm requests, store +// modid/fileid with downloads + +static const char UNFINISHED[] = ".unfinished"; + +unsigned int DownloadManager::DownloadInfo::s_NextDownloadID = 1U; +int DownloadManager::m_DirWatcherDisabler = 0; + +DownloadManager::DownloadInfo* +DownloadManager::DownloadInfo::createNew(const ModRepositoryFileInfo* fileInfo, + const QStringList& URLs) +{ + DownloadInfo* info = new DownloadInfo; + info->m_DownloadID = s_NextDownloadID++; + info->m_StartTime.start(); + info->m_PreResumeSize = 0LL; + info->m_Progress = std::make_pair(0, "0.0 B/s "); + info->m_ResumePos = 0; + info->m_FileInfo = new ModRepositoryFileInfo(*fileInfo); + info->m_Urls = URLs; + info->m_CurrentUrl = 0; + info->m_Tries = AUTOMATIC_RETRIES; + info->m_State = STATE_STARTED; + info->m_TaskProgressId = TaskProgressManager::instance().getId(); + info->m_Reply = nullptr; + + return info; +} + +DownloadManager::DownloadInfo* +DownloadManager::DownloadInfo::createFromMeta(const QString& filePath, bool showHidden, + const QString outputDirectory, + std::optional fileSize) +{ + DownloadInfo* info = new DownloadInfo; + + QString metaFileName = filePath + ".meta"; + QFileInfo metaFileInfo(metaFileName); + if (QDir::fromNativeSeparators(metaFileInfo.path()) + .compare(QDir::fromNativeSeparators(outputDirectory), Qt::CaseInsensitive) != + 0) + return nullptr; + QSettings metaFile(metaFileName, QSettings::IniFormat); + if (!showHidden && metaFile.value("removed", false).toBool()) { + return nullptr; + } else { + info->m_Hidden = metaFile.value("removed", false).toBool(); + } + + QString fileName = QFileInfo(filePath).fileName(); + + if (fileName.endsWith(UNFINISHED)) { + info->m_FileName = + fileName.mid(0, fileName.length() - static_cast(strlen(UNFINISHED))); + info->m_State = STATE_PAUSED; + } else { + info->m_FileName = fileName; + + if (metaFile.value("paused", false).toBool()) { + info->m_State = STATE_PAUSED; + } else if (metaFile.value("uninstalled", false).toBool()) { + info->m_State = STATE_UNINSTALLED; + } else if (metaFile.value("installed", false).toBool()) { + info->m_State = STATE_INSTALLED; + } else { + info->m_State = STATE_READY; + } + } + + info->m_DownloadID = s_NextDownloadID++; + info->m_Output.setFileName(filePath); + info->m_TotalSize = fileSize ? *fileSize : QFileInfo(filePath).size(); + info->m_PreResumeSize = info->m_TotalSize; + info->m_CurrentUrl = 0; + info->m_Urls = metaFile.value("url", "").toString().split(";"); + info->m_Tries = 0; + info->m_TaskProgressId = TaskProgressManager::instance().getId(); + QString gameName = metaFile.value("gameName", "").toString(); + int modID = metaFile.value("modID", 0).toInt(); + int fileID = metaFile.value("fileID", 0).toInt(); + info->m_FileInfo = new ModRepositoryFileInfo(gameName, modID, fileID); + info->m_FileInfo->name = metaFile.value("name", "").toString(); + if (info->m_FileInfo->name == "0") { + // bug in earlier version + info->m_FileInfo->name = ""; + } + info->m_FileInfo->modName = metaFile.value("modName", "").toString(); + info->m_FileInfo->gameName = gameName; + info->m_FileInfo->modID = modID; + info->m_FileInfo->fileID = fileID; + info->m_FileInfo->description = metaFile.value("description").toString(); + info->m_FileInfo->version.parse(metaFile.value("version", "0").toString()); + info->m_FileInfo->newestVersion.parse( + metaFile.value("newestVersion", "0").toString()); + info->m_FileInfo->categoryID = metaFile.value("category", 0).toInt(); + info->m_FileInfo->fileCategory = metaFile.value("fileCategory", 0).toInt(); + info->m_FileInfo->repository = metaFile.value("repository", "Nexus").toString(); + info->m_FileInfo->userData = metaFile.value("userData").toMap(); + info->m_FileInfo->author = metaFile.value("author", "").toString(); + info->m_FileInfo->uploader = metaFile.value("uploader", "").toString(); + info->m_FileInfo->uploaderUrl = metaFile.value("uploaderUrl", "").toString(); + info->m_Reply = nullptr; + + return info; +} + +ScopedDisableDirWatcher::ScopedDisableDirWatcher(DownloadManager* downloadManager) +{ + m_downloadManager = downloadManager; + m_downloadManager->startDisableDirWatcher(); + log::debug("Scoped Disable DirWatcher: Started"); +} + +ScopedDisableDirWatcher::~ScopedDisableDirWatcher() +{ + m_downloadManager->endDisableDirWatcher(); + m_downloadManager = nullptr; + log::debug("Scoped Disable DirWatcher: Stopped"); +} + +void DownloadManager::startDisableDirWatcher() +{ + DownloadManager::m_DirWatcherDisabler++; +} + +void DownloadManager::endDisableDirWatcher() +{ + if (DownloadManager::m_DirWatcherDisabler > 0) { + if (DownloadManager::m_DirWatcherDisabler == 1) + QCoreApplication::processEvents(); + DownloadManager::m_DirWatcherDisabler--; + } else { + DownloadManager::m_DirWatcherDisabler = 0; + } +} + +void DownloadManager::DownloadInfo::setName(QString newName, bool renameFile) +{ + QString oldMetaFileName = QString("%1.meta").arg(m_FileName); + m_FileName = QFileInfo(newName).fileName(); + if ((m_State == DownloadManager::STATE_STARTED) || + (m_State == DownloadManager::STATE_DOWNLOADING) || + (m_State == DownloadManager::STATE_PAUSED)) { + newName.append(UNFINISHED); + oldMetaFileName = QString("%1%2.meta").arg(m_FileName).arg(UNFINISHED); + } + if (renameFile) { + if ((newName != m_Output.fileName()) && !m_Output.rename(newName)) { + reportError(tr("failed to rename \"%1\" to \"%2\"") + .arg(m_Output.fileName()) + .arg(newName)); + return; + } + + QFile metaFile(QFileInfo(newName).path() + "/" + oldMetaFileName); + if (metaFile.exists()) + metaFile.rename(newName.mid(0).append(".meta")); + } + if (!m_Output.isOpen()) { + // can't set file name if it's open + m_Output.setFileName(newName); + } +} + +bool DownloadManager::DownloadInfo::isPausedState() +{ + return m_State == STATE_PAUSED || m_State == STATE_ERROR; +} + +QString DownloadManager::DownloadInfo::currentURL() +{ + return m_Urls[m_CurrentUrl]; +} + +DownloadManager::DownloadManager(NexusInterface* nexusInterface, QObject* parent) + : m_NexusInterface(nexusInterface), m_DirWatcher(), m_ShowHidden(false), + m_ParentWidget(nullptr) +{ + m_OrganizerCore = dynamic_cast(parent); + connect(&m_DirWatcher, SIGNAL(directoryChanged(QString)), this, + SLOT(directoryChanged(QString))); + m_TimeoutTimer.setSingleShot(false); + // connect(&m_TimeoutTimer, SIGNAL(timeout()), this, SLOT(checkDownloadTimeout())); + m_TimeoutTimer.start(5 * 1000); +} + +DownloadManager::~DownloadManager() +{ + for (QVector::iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end(); ++iter) { + delete *iter; + } + m_ActiveDownloads.clear(); +} + +void DownloadManager::setParentWidget(QWidget* w) +{ + m_ParentWidget = w; +} + +bool DownloadManager::downloadsInProgress() +{ + for (QVector::iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end(); ++iter) { + if ((*iter)->m_State < STATE_READY) { + return true; + } + } + return false; +} + +bool DownloadManager::downloadsInProgressNoPause() +{ + for (QVector::iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end(); ++iter) { + if ((*iter)->m_State < STATE_READY && (*iter)->m_State != STATE_PAUSED) { + return true; + } + } + return false; +} + +void DownloadManager::pauseAll() +{ + + // first loop: pause all downloads + for (int i = 0; i < m_ActiveDownloads.count(); ++i) { + if (m_ActiveDownloads[i]->m_State < STATE_READY) { + pauseDownload(i); + } + } + + QThread::msleep(100); + + bool done = false; + QTime startTime = QTime::currentTime(); + // further loops: busy waiting for all downloads to complete. This could be neater... + while (!done && (startTime.secsTo(QTime::currentTime()) < 5)) { + QCoreApplication::processEvents(); + done = true; + foreach (DownloadInfo* info, m_ActiveDownloads) { + if ((info->m_State < STATE_CANCELED) || + (info->m_State == STATE_FETCHINGFILEINFO) || + (info->m_State == STATE_FETCHINGMODINFO) || + (info->m_State == STATE_FETCHINGMODINFO_MD5)) { + done = false; + break; + } + } + if (!done) { + QThread::msleep(100); + } + } +} + +void DownloadManager::setOutputDirectory(const QString& outputDirectory, + const bool refresh) +{ + QStringList directories = m_DirWatcher.directories(); + if (directories.length() != 0) { + m_DirWatcher.removePaths(directories); + } + m_OutputDirectory = QDir::fromNativeSeparators(outputDirectory); + if (refresh) { + refreshList(); + } + m_DirWatcher.addPath(m_OutputDirectory); +} + +void DownloadManager::setShowHidden(bool showHidden) +{ + m_ShowHidden = showHidden; + refreshList(); +} + +void DownloadManager::setPluginContainer(PluginContainer* pluginContainer) +{ + m_NexusInterface->setPluginContainer(pluginContainer); +} + +void DownloadManager::refreshList() +{ + TimeThis tt("DownloadManager::refreshList()"); + + try { + emit aboutToUpdate(); + // avoid triggering other refreshes + ScopedDisableDirWatcher scopedDirWatcher(this); + + int downloadsBefore = m_ActiveDownloads.size(); + + // remove finished downloads + for (QVector::iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end();) { + if (((*iter)->m_State == STATE_READY) || ((*iter)->m_State == STATE_INSTALLED) || + ((*iter)->m_State == STATE_UNINSTALLED)) { + delete *iter; + iter = m_ActiveDownloads.erase(iter); + } else { + ++iter; + } + } + + const QStringList supportedExtensions = + m_OrganizerCore->installationManager()->getSupportedExtensions(); + std::vector nameFilters; + for (const auto& extension : supportedExtensions) { + nameFilters.push_back(L"." + extension.toLower().toStdWString()); + } + + nameFilters.push_back(QString(UNFINISHED).toLower().toStdWString()); + + QDir dir(QDir::fromNativeSeparators(m_OutputDirectory)); + + // find orphaned meta files and delete them (sounds cruel but it's better for + // everyone) + QStringList orphans; + QStringList metaFiles = dir.entryList(QStringList() << "*.meta"); + foreach (const QString& metaFile, metaFiles) { + QString baseFile = metaFile.left(metaFile.length() - 5); + if (!QFile::exists(dir.absoluteFilePath(baseFile))) { + orphans.append(dir.absoluteFilePath(metaFile)); + } + } + if (orphans.size() > 0) { + log::debug("{} orphaned meta files will be deleted", orphans.size()); + shellDelete(orphans, true); + } + + std::set seen; + + struct Context + { + DownloadManager& self; + std::set& seen; + std::vector& extensions; + }; + + Context cx = {*this, seen, nameFilters}; + + for (auto&& d : m_ActiveDownloads) { + cx.seen.insert(d->m_FileName.toLower().toStdWString()); + cx.seen.insert( + QFileInfo(d->m_Output.fileName()).fileName().toLower().toStdWString()); + } + + env::forEachEntry( + QDir::toNativeSeparators(m_OutputDirectory).toStdWString(), &cx, nullptr, + nullptr, [](void* data, std::wstring_view f, FILETIME, uint64_t size) { + auto& cx = *static_cast(data); + + std::wstring lc = MOShared::ToLowerCopy(f); + + bool interestingExt = false; + for (auto&& ext : cx.extensions) { + if (lc.ends_with(ext)) { + interestingExt = true; + break; + } + } + + if (!interestingExt) { + return; + } + + if (cx.seen.contains(lc)) { + return; + } + + QString fileName = QDir::fromNativeSeparators(cx.self.m_OutputDirectory) + + "/" + QString::fromWCharArray(f.data(), f.size()); + + DownloadInfo* info = DownloadInfo::createFromMeta( + fileName, cx.self.m_ShowHidden, cx.self.m_OutputDirectory, size); + + if (info == nullptr) { + return; + } + + cx.self.m_ActiveDownloads.push_front(info); + cx.seen.insert(std::move(lc)); + cx.seen.insert( + QFileInfo(info->m_Output.fileName()).fileName().toLower().toStdWString()); + }); + + log::debug("saw {} downloads", m_ActiveDownloads.size()); + + emit update(-1); + + } catch (const std::bad_alloc&) { + reportError(tr("Memory allocation error (in refreshing directory).")); + } +} + +void DownloadManager::queryDownloadListInfo() +{ + int incompleteCount = 0; + for (size_t i = 0; i < m_ActiveDownloads.size(); i++) { + if (isInfoIncomplete(i)) { + incompleteCount++; + } + } + + if (incompleteCount <= 5 || + QMessageBox::question( + m_ParentWidget, tr("Query Metadata"), + tr("There are %1 downloads with incomplete metadata.\n\n" + "Do you want to fetch all incomplete metadata?\n" + "API requests will be consumed, and Mod Organizer may stutter.") + .arg(incompleteCount), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + TimeThis tt("DownloadManager::queryDownloadListInfo()"); + log::info("Querying metadata for every download with incomplete info..."); + startDisableDirWatcher(); + for (size_t i = 0; i < m_ActiveDownloads.size(); i++) { + if (isInfoIncomplete(i)) { + queryInfoMd5(i, false); + } + } + endDisableDirWatcher(); + log::info("Metadata has been retrieved successfully!"); + } +} + +bool DownloadManager::addDownload(const QStringList& URLs, QString gameName, int modID, + int fileID, const ModRepositoryFileInfo* fileInfo) +{ + QString fileName = QFileInfo(URLs.first()).fileName(); + if (fileName.isEmpty()) { + fileName = "unknown"; + } else { + fileName = QUrl::fromPercentEncoding(fileName.toUtf8()); + } + + // Temporary URLs for S3-compatible storage are signed for a single method, removing + // the ability to make HEAD requests to such URLs. We can use the + // response-content-disposition GET parameter, setting the Content-Disposition header, + // to predetermine intended file name without a subrequest. + if (fileName.contains("response-content-disposition=")) { + std::regex exp("filename=\"(.+)\""); + std::cmatch result; + if (std::regex_search(fileName.toStdString().c_str(), result, exp)) { + fileName = MOBase::sanitizeFileName(QString::fromUtf8(result.str(1).c_str())); + if (fileName.isEmpty()) { + fileName = "unknown"; + } + } + } + + QUrl preferredUrl = QUrl::fromEncoded(URLs.first().toLocal8Bit()); + log::debug("selected download url: {}", preferredUrl.toString()); + QHttp2Configuration h2Conf; + h2Conf.setSessionReceiveWindowSize( + 16777215); // 16 MiB, based on Chrome and Firefox values + h2Conf.setStreamReceiveWindowSize(16777215); + QNetworkRequest request(preferredUrl); + request.setHeader(QNetworkRequest::UserAgentHeader, + m_NexusInterface->getAccessManager()->userAgent()); + request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, + QNetworkRequest::AlwaysNetwork); + request.setHttp2Configuration(h2Conf); + return addDownload(m_NexusInterface->getAccessManager()->get(request), URLs, fileName, + gameName, modID, fileID, fileInfo); +} + +bool DownloadManager::addDownload(QNetworkReply* reply, + const ModRepositoryFileInfo* fileInfo) +{ + QString fileName = getFileNameFromNetworkReply(reply); + if (fileName.isEmpty()) { + fileName = "unknown"; + } + + return addDownload(reply, QStringList(reply->url().toString()), fileName, + fileInfo->gameName, fileInfo->modID, fileInfo->fileID, fileInfo); +} + +bool DownloadManager::addDownload(QNetworkReply* reply, const QStringList& URLs, + const QString& fileName, QString gameName, int modID, + int fileID, const ModRepositoryFileInfo* fileInfo) +{ + // download invoked from an already open network reply (i.e. download link in the + // browser) + DownloadInfo* newDownload = DownloadInfo::createNew(fileInfo, URLs); + + QString baseName = fileName; + if (!fileInfo->fileName.isEmpty()) { + baseName = fileInfo->fileName; + } else { + QString dispoName = getFileNameFromNetworkReply(reply); + + if (!dispoName.isEmpty()) { + baseName = dispoName; + } + } + + // baseName could be a URL at this point so strip out the URL query + int queryIndex = baseName.indexOf("?"); + if (queryIndex >= 0) { + baseName.truncate(queryIndex); + } + + startDisableDirWatcher(); + newDownload->setName(getDownloadFileName(baseName), false); + endDisableDirWatcher(); + + startDownload(reply, newDownload, false); + // emit update(-1); + return true; +} + +void DownloadManager::removePending(QString gameName, int modID, int fileID) +{ + QString gameShortName = gameName; + QStringList games(m_ManagedGame->validShortNames()); + games += m_ManagedGame->gameShortName(); + for (auto game : games) { + MOBase::IPluginGame* gamePlugin = m_OrganizerCore->getGame(game); + if (gamePlugin != nullptr && + gamePlugin->gameNexusName().compare(gameName, Qt::CaseInsensitive) == 0) { + gameShortName = gamePlugin->gameShortName(); + break; + } + } + emit aboutToUpdate(); + for (auto iter : m_PendingDownloads) { + if (gameShortName.compare(std::get<0>(iter), Qt::CaseInsensitive) == 0 && + (std::get<1>(iter) == modID) && (std::get<2>(iter) == fileID)) { + m_PendingDownloads.removeAt(m_PendingDownloads.indexOf(iter)); + break; + } + } + emit update(-1); +} + +void DownloadManager::startDownload(QNetworkReply* reply, DownloadInfo* newDownload, + bool resume) +{ + reply->setReadBufferSize( + 1024 * 1024); // don't read more than 1MB at once to avoid memory troubles + newDownload->m_Reply = reply; + setState(newDownload, STATE_DOWNLOADING); + if (newDownload->m_Urls.count() == 0) { + newDownload->m_Urls = QStringList(reply->url().toString()); + } + + QIODevice::OpenMode mode = QIODevice::WriteOnly; + if (resume) { + mode |= QIODevice::Append; + } + + newDownload->m_StartTime.start(); + createMetaFile(newDownload); + + if (!newDownload->m_Output.open(mode)) { + reportError(tr("failed to download %1: could not open output file: %2") + .arg(reply->url().toString()) + .arg(newDownload->m_Output.fileName())); + return; + } + + connect(newDownload->m_Reply, SIGNAL(downloadProgress(qint64, qint64)), this, + SLOT(downloadProgress(qint64, qint64))); + connect(newDownload->m_Reply, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), + this, SLOT(downloadError(QNetworkReply::NetworkError))); + connect(newDownload->m_Reply, SIGNAL(readyRead()), this, SLOT(downloadReadyRead())); + connect(newDownload->m_Reply, SIGNAL(metaDataChanged()), this, + SLOT(metaDataChanged())); + + if (!resume) { + newDownload->m_PreResumeSize = newDownload->m_Output.size(); + removePending(newDownload->m_FileInfo->gameName, newDownload->m_FileInfo->modID, + newDownload->m_FileInfo->fileID); + + emit aboutToUpdate(); + m_ActiveDownloads.append(newDownload); + + emit update(-1); + emit downloadAdded(); + + if (QFile::exists(m_OutputDirectory + "/" + newDownload->m_FileName)) { + setState(newDownload, STATE_PAUSING); + QCoreApplication::processEvents(); + if (QMessageBox::question( + m_ParentWidget, tr("Download again?"), + tr("A file with the same name \"%1\" has already been downloaded. " + "Do you want to download it again? The new file will receive a " + "different name.") + .arg(newDownload->m_FileName), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::No) { + if (reply->isFinished()) + setState(newDownload, STATE_CANCELED); + else + setState(newDownload, STATE_CANCELING); + } else { + startDisableDirWatcher(); + newDownload->setName(getDownloadFileName(newDownload->m_FileName, true), true); + endDisableDirWatcher(); + if (newDownload->m_State == STATE_PAUSED) + resumeDownload(indexByInfo(newDownload)); + else + setState(newDownload, STATE_DOWNLOADING); + } + } else + connect(newDownload->m_Reply, SIGNAL(finished()), this, SLOT(downloadFinished())); + + QCoreApplication::processEvents(); + + if (newDownload->m_State != STATE_DOWNLOADING && + newDownload->m_State != STATE_READY && + newDownload->m_State != STATE_FETCHINGMODINFO && reply->isFinished()) { + int index = indexByInfo(newDownload); + if (index >= 0) { + downloadFinished(index); + } + return; + } + } else + connect(newDownload->m_Reply, SIGNAL(finished()), this, SLOT(downloadFinished())); +} + +void DownloadManager::addNXMDownload(const QString& url) +{ + NXMUrl nxmInfo(url); + + QStringList validGames; + MOBase::IPluginGame* foundGame = nullptr; + validGames.append(m_ManagedGame->gameShortName()); + validGames.append(m_ManagedGame->validShortNames()); + for (auto game : validGames) { + MOBase::IPluginGame* gamePlugin = m_OrganizerCore->getGame(game); + + // some game plugins give names in validShortNames() that may refer to other + // plugins, like ttw returning "FalloutNV" and "Fallout3"; if these plugins + // are not loaded, getGame() might return null + if (!gamePlugin) { + // log an error, it's most probably not normal + log::error("no plugin for game '{}', an antivirus might have deleted it", game); + continue; + } + + if (nxmInfo.game().compare(gamePlugin->gameShortName(), Qt::CaseInsensitive) == 0 || + nxmInfo.game().compare(gamePlugin->gameNexusName(), Qt::CaseInsensitive) == 0) { + foundGame = gamePlugin; + break; + } + } + log::debug("add nxm download: {}", url); + if (foundGame == nullptr) { + log::debug("download requested for wrong game (game: {}, url: {})", + m_ManagedGame->gameShortName(), nxmInfo.game()); + QMessageBox::information( + m_ParentWidget, tr("Wrong Game"), + tr("The download link is for a mod for \"%1\" but this instance of MO " + "has been set up for \"%2\".") + .arg(nxmInfo.game()) + .arg(m_ManagedGame->gameShortName()), + QMessageBox::Ok); + return; + } + + for (auto tuple : m_PendingDownloads) { + if (std::get<0>(tuple).compare(foundGame->gameShortName(), Qt::CaseInsensitive) == + 0, + std::get<1>(tuple) == nxmInfo.modId() && + std::get<2>(tuple) == nxmInfo.fileId()) { + const auto infoStr = + tr("There is already a download queued for this file.\n\nMod %1\nFile %2") + .arg(nxmInfo.modId()) + .arg(nxmInfo.fileId()); + + log::debug("download requested is already queued (mod: {}, file: {})", + nxmInfo.modId(), nxmInfo.fileId()); + + QMessageBox::information(m_ParentWidget, tr("Already Queued"), infoStr, + QMessageBox::Ok); + return; + } + } + + for (DownloadInfo* download : m_ActiveDownloads) { + if (download->m_FileInfo->modID == nxmInfo.modId() && + download->m_FileInfo->fileID == nxmInfo.fileId()) { + if (download->m_State == STATE_DOWNLOADING || download->m_State == STATE_PAUSED || + download->m_State == STATE_STARTED) { + QString debugStr( + "download requested is already started (mod %1: %2, file %3: %4)"); + QString infoStr(tr("There is already a download started for this file.\n\nMod " + "%1:\t%2\nFile %3:\t%4")); + + // %1 + debugStr = debugStr.arg(download->m_FileInfo->modID); + infoStr = infoStr.arg(download->m_FileInfo->modID); + + // %2 + if (!download->m_FileInfo->name.isEmpty()) { + debugStr = debugStr.arg(download->m_FileInfo->name); + infoStr = infoStr.arg(download->m_FileInfo->name); + } else if (!download->m_FileInfo->modName.isEmpty()) { + debugStr = debugStr.arg(download->m_FileInfo->modName); + infoStr = infoStr.arg(download->m_FileInfo->modName); + } else { + debugStr = debugStr.arg(QStringLiteral("")); + infoStr = infoStr.arg(QStringLiteral("")); + } + + // %3 + debugStr = debugStr.arg(download->m_FileInfo->fileID); + infoStr = infoStr.arg(download->m_FileInfo->fileID); + + // %4 + if (!download->m_FileInfo->fileName.isEmpty()) { + debugStr = debugStr.arg(download->m_FileInfo->fileName); + infoStr = infoStr.arg(download->m_FileInfo->fileName); + } else if (!download->m_FileName.isEmpty()) { + debugStr = debugStr.arg(download->m_FileName); + infoStr = infoStr.arg(download->m_FileName); + } else { + debugStr = debugStr.arg(QStringLiteral("")); + infoStr = infoStr.arg(QStringLiteral("")); + } + + log::debug("{}", debugStr); + QMessageBox::information(m_ParentWidget, tr("Already Started"), infoStr, + QMessageBox::Ok); + return; + } + } + } + + emit aboutToUpdate(); + + m_PendingDownloads.append( + std::make_tuple(foundGame->gameShortName(), nxmInfo.modId(), nxmInfo.fileId())); + + emit update(-1); + emit downloadAdded(); + ModRepositoryFileInfo* info = new ModRepositoryFileInfo(); + + info->nexusKey = nxmInfo.key(); + info->nexusExpires = nxmInfo.expires(); + info->nexusDownloadUser = nxmInfo.userId(); + + QObject* test = info; + m_RequestIDs.insert(m_NexusInterface->requestFileInfo( + foundGame->gameShortName(), nxmInfo.modId(), nxmInfo.fileId(), this, + QVariant::fromValue(test), "")); +} + +void DownloadManager::removeFile(int index, bool deleteFile) +{ + // Avoid triggering refreshes from DirWatcher + ScopedDisableDirWatcher scopedDirWatcher(this); + + if (index >= m_ActiveDownloads.size()) { + throw MyException(tr("remove: invalid download index %1").arg(index)); + } + + DownloadInfo* download = m_ActiveDownloads.at(index); + QString filePath = m_OutputDirectory + "/" + download->m_FileName; + if ((download->m_State == STATE_STARTED) || + (download->m_State == STATE_DOWNLOADING)) { + // shouldn't have been possible + log::error("tried to remove active download"); + return; + } + + if ((download->m_State == STATE_PAUSED) || (download->m_State == STATE_ERROR)) { + filePath = download->m_Output.fileName(); + } + + if (deleteFile) { + if (!shellDelete(QStringList(filePath), true)) { + reportError(tr("failed to delete %1").arg(filePath)); + return; + } + + QFile metaFile(filePath.append(".meta")); + if (metaFile.exists() && !shellDelete(QStringList(filePath), true)) { + reportError(tr("failed to delete meta file for %1").arg(filePath)); + } + } else { + QSettings metaSettings(filePath.append(".meta"), QSettings::IniFormat); + if (!download->m_Hidden) + metaSettings.setValue("removed", true); + } + m_DownloadRemoved(index); +} + +class LessThanWrapper +{ +public: + LessThanWrapper(DownloadManager* manager) : m_Manager(manager) {} + bool operator()(int LHS, int RHS) + { + return m_Manager->getFileName(LHS).compare(m_Manager->getFileName(RHS), + Qt::CaseInsensitive) < 0; + } + +private: + DownloadManager* m_Manager; +}; + +bool DownloadManager::ByName(int LHS, int RHS) +{ + return m_ActiveDownloads[LHS]->m_FileName < m_ActiveDownloads[RHS]->m_FileName; +} + +void DownloadManager::refreshAlphabeticalTranslation() +{ + m_AlphabeticalTranslation.clear(); + int pos = 0; + for (QVector::iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end(); ++iter, ++pos) { + m_AlphabeticalTranslation.push_back(pos); + } + + std::sort(m_AlphabeticalTranslation.begin(), m_AlphabeticalTranslation.end(), + LessThanWrapper(this)); +} + +void DownloadManager::restoreDownload(int index) +{ + + if (index < 0) { + DownloadState minState = STATE_READY; + index = 0; + + for (QVector::const_iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end(); ++iter) { + + if ((*iter)->m_State >= minState) { + restoreDownload(index); + } + index++; + } + } else { + if (index >= m_ActiveDownloads.size()) { + throw MyException(tr("restore: invalid download index: %1").arg(index)); + } + + DownloadInfo* download = m_ActiveDownloads.at(index); + if (download->m_Hidden) { + download->m_Hidden = false; + + QString filePath = m_OutputDirectory + "/" + download->m_FileName; + + // avoid dirWatcher triggering refreshes + startDisableDirWatcher(); + QSettings metaSettings(filePath.append(".meta"), QSettings::IniFormat); + metaSettings.setValue("removed", false); + + endDisableDirWatcher(); + } + } +} + +void DownloadManager::removeDownload(int index, bool deleteFile) +{ + try { + // avoid dirWatcher triggering refreshes + ScopedDisableDirWatcher scopedDirWatcher(this); + + emit aboutToUpdate(); + + if (index < 0) { + bool removeAll = (index == -1); + DownloadState removeState = (index == -2 ? STATE_INSTALLED : STATE_UNINSTALLED); + + index = 0; + for (QVector::iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end();) { + DownloadState downloadState = (*iter)->m_State; + if ((removeAll && (downloadState >= STATE_READY)) || + (removeState == downloadState)) { + removeFile(index, deleteFile); + delete *iter; + iter = m_ActiveDownloads.erase(iter); + } else { + ++iter; + ++index; + } + } + } else { + if (index >= m_ActiveDownloads.size()) { + reportError(tr("remove: invalid download index %1").arg(index)); + // emit update(-1); + return; + } + + removeFile(index, deleteFile); + delete m_ActiveDownloads.at(index); + m_ActiveDownloads.erase(m_ActiveDownloads.begin() + index); + } + emit update(-1); + } catch (const std::exception& e) { + log::error("failed to remove download: {}", e.what()); + } + refreshList(); +} + +void DownloadManager::cancelDownload(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("cancel: invalid download index %1").arg(index)); + return; + } + + if (m_ActiveDownloads.at(index)->m_State == STATE_DOWNLOADING) { + setState(m_ActiveDownloads.at(index), STATE_CANCELING); + } +} + +void DownloadManager::pauseDownload(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("pause: invalid download index %1").arg(index)); + return; + } + + DownloadInfo* info = m_ActiveDownloads.at(index); + + if (info->m_State == STATE_DOWNLOADING) { + if ((info->m_Reply != nullptr) && (info->m_Reply->isRunning())) { + setState(info, STATE_PAUSING); + } else { + setState(info, STATE_PAUSED); + } + } else if ((info->m_State == STATE_FETCHINGMODINFO) || + (info->m_State == STATE_FETCHINGFILEINFO) || + (info->m_State == STATE_FETCHINGMODINFO_MD5)) { + setState(info, STATE_READY); + } +} + +void DownloadManager::resumeDownload(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("resume: invalid download index %1").arg(index)); + return; + } + DownloadInfo* info = m_ActiveDownloads[index]; + info->m_Tries = AUTOMATIC_RETRIES; + resumeDownloadInt(index); +} + +void DownloadManager::resumeDownloadInt(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("resume (int): invalid download index %1").arg(index)); + return; + } + DownloadInfo* info = m_ActiveDownloads[index]; + + // Check for finished download; + if (info->m_TotalSize <= info->m_Output.size() && info->m_Reply != nullptr && + info->m_Reply->isFinished() && info->m_State != STATE_ERROR) { + setState(info, STATE_DOWNLOADING); + downloadFinished(index); + return; + } + + if (info->isPausedState() || info->m_State == STATE_PAUSING) { + if (info->m_State == STATE_PAUSING) { + if (info->m_Output.isOpen()) { + writeData(info); + if (info->m_State == STATE_PAUSING) { + setState(info, STATE_PAUSED); + } + } + } + if ((info->m_Urls.size() == 0) || + ((info->m_Urls.size() == 1) && (info->m_Urls[0].size() == 0))) { + emit showMessage( + tr("No known download urls. Sorry, this download can't be resumed.")); + return; + } + if (info->m_State == STATE_ERROR) { + info->m_CurrentUrl = (info->m_CurrentUrl + 1) % info->m_Urls.count(); + } + log::debug("request resume from url {}", info->currentURL()); + QNetworkRequest request(QUrl::fromEncoded(info->currentURL().toLocal8Bit())); + request.setHeader(QNetworkRequest::UserAgentHeader, + m_NexusInterface->getAccessManager()->userAgent()); + if (info->m_State != STATE_ERROR) { + info->m_ResumePos = info->m_Output.size(); + QByteArray rangeHeader = "bytes=" + QByteArray::number(info->m_ResumePos) + "-"; + request.setRawHeader("Range", rangeHeader); + } + info->m_DownloadLast = 0; + info->m_DownloadTimeLast = 0; + info->m_DownloadAcc = accumulator_set>( + tag::rolling_window::window_size = 200); + info->m_DownloadTimeAcc = accumulator_set>( + tag::rolling_window::window_size = 200); + log::debug("resume at {} bytes", info->m_ResumePos); + startDownload(m_NexusInterface->getAccessManager()->get(request), info, true); + } + emit update(index); +} + +DownloadManager::DownloadInfo* DownloadManager::downloadInfoByID(unsigned int id) +{ + auto iter = std::find_if(m_ActiveDownloads.begin(), m_ActiveDownloads.end(), + [id](DownloadInfo* info) { + return info->m_DownloadID == id; + }); + if (iter != m_ActiveDownloads.end()) { + return *iter; + } else { + return nullptr; + } +} + +void DownloadManager::queryInfo(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("query: invalid download index %1").arg(index)); + return; + } + DownloadInfo* info = m_ActiveDownloads[index]; + + if (info->m_FileInfo->repository != "Nexus") { + log::warn("re-querying file info is currently only possible with Nexus"); + return; + } + + if (info->m_State < DownloadManager::STATE_READY) { + // UI shouldn't allow this + return; + } + + if (info->m_FileInfo->modID <= 0) { + QString fileName = getFileName(index); + QString ignore; + NexusInterface::interpretNexusFileName(fileName, ignore, info->m_FileInfo->modID, + info->m_AskIfNotFound); + if (info->m_FileInfo->modID < 0) { + bool ok = false; + int modId = QInputDialog::getInt(nullptr, tr("Please enter the Nexus mod ID"), + tr("Mod ID:"), 1, 1, + std::numeric_limits::max(), 1, &ok); + // careful now: while the dialog was displayed, events were processed. + // the download list might have changed and our info-ptr invalidated. + if (ok) + m_ActiveDownloads[index]->m_FileInfo->modID = modId; + return; + } + } + + if (info->m_FileInfo->gameName.isEmpty()) { + if (!info->m_AskIfNotFound) { + // in case the info couldn't be retrieved from Nexus due to connection issues, + // don't create a meta file so you can query again after the issues are resolved. + return; + } + + SelectionDialog selection( + tr("Please select the source game code for %1").arg(getFileName(index))); + + std::vector> choices = + m_NexusInterface->getGameChoices(m_ManagedGame); + if (choices.size() == 1) { + info->m_FileInfo->gameName = choices[0].first; + } else { + for (auto choice : choices) { + selection.addChoice(choice.first, choice.second, choice.first); + } + if (selection.exec() == QDialog::Accepted) { + info->m_FileInfo->gameName = selection.getChoiceData().toString(); + } else { + info->m_FileInfo->gameName = m_ManagedGame->gameShortName(); + } + } + } + info->m_ReQueried = true; + setState(info, STATE_FETCHINGMODINFO); +} + +void DownloadManager::queryInfoMd5(int index, bool askIfNotFound) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("query: invalid download index %1").arg(index)); + return; + } + DownloadInfo* info = m_ActiveDownloads[index]; + + if (info->m_FileInfo->repository != "Nexus") { + log::warn("re-querying file info is currently only possible with Nexus"); + return; + } + + if (info->m_State < DownloadManager::STATE_READY) { + // UI shouldn't allow this + return; + } + + info->m_GamesToQuery << m_ManagedGame->gameShortName(); + info->m_GamesToQuery << m_ManagedGame->validShortNames(); + + QFile downloadFile(info->m_FileName); + if (!downloadFile.exists()) { + downloadFile.setFileName(m_OrganizerCore->downloadsPath() + "/" + + info->m_FileName); + } + if (!downloadFile.exists()) { + log::error("Can't find download file '{}'", info->m_FileName); + return; + } + if (!downloadFile.open(QIODevice::ReadOnly)) { + log::error("Can't open download file '{}'", info->m_FileName); + return; + } + + QCryptographicHash hash(QCryptographicHash::Md5); + const qint64 progressStep = 10 * 1024 * 1024; + QProgressDialog progress(tr("Hashing download file '%1'").arg(info->m_FileName), + tr("Cancel"), 0, downloadFile.size() / progressStep); + progress.setWindowModality(Qt::WindowModal); + progress.setMinimumDuration(1000); + + for (qint64 i = 0; i < downloadFile.size(); i += progressStep) { + progress.setValue(progress.value() + 1); + if (progress.wasCanceled()) { + break; + } + hash.addData(downloadFile.read(progressStep)); + } + if (progress.wasCanceled()) { + downloadFile.close(); + return; + } + + progress.close(); + downloadFile.close(); + + info->m_Hash = hash.result(); + info->m_ReQueried = true; + info->m_AskIfNotFound = askIfNotFound; + setState(info, STATE_FETCHINGMODINFO_MD5); +} + +void DownloadManager::visitOnNexus(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("VisitNexus: invalid download index %1").arg(index)); + return; + } + DownloadInfo* info = m_ActiveDownloads[index]; + + if (info->m_FileInfo->repository != "Nexus") { + log::warn("Visiting mod page is currently only possible with Nexus"); + return; + } + + if (info->m_State < DownloadManager::STATE_READY) { + // UI shouldn't allow this + return; + } + int modID = info->m_FileInfo->modID; + + QString gameName = info->m_FileInfo->gameName; + if (modID > 0) { + shell::Open(QUrl(m_NexusInterface->getModURL(modID, gameName))); + } else { + emit showMessage(tr("Nexus ID for this Mod is unknown")); + } +} + +void DownloadManager::visitUploaderProfile(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("VisitUploaderProfile: invalid download index %1").arg(index)); + return; + } + DownloadInfo* info = m_ActiveDownloads[index]; + + if (info->m_State < DownloadManager::STATE_READY) { + // UI shouldn't allow this + return; + } + + const auto& uploaderUrl = info->m_FileInfo->uploaderUrl; + if (!uploaderUrl.isEmpty()) { + shell::Open(QUrl(uploaderUrl)); + } else { + emit showMessage(tr("Uploader for this Mod is unknown")); + } +} + +void DownloadManager::openFile(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("OpenFile: invalid download index %1").arg(index)); + return; + } + + QDir path = QDir(m_OutputDirectory); + if (path.exists(getFileName(index))) { + shell::Open(getFilePath(index)); + return; + } + + shell::Explore(m_OutputDirectory); + return; +} + +void DownloadManager::openMetaFile(int index) +{ + if (index < 0 || index >= m_ActiveDownloads.size()) { + log::error("OpenMetaFile: invalid download index {}", index); + return; + } + + const auto path = QDir(m_OutputDirectory); + const auto filePath = getFilePath(index); + const auto metaPath = filePath + ".meta"; + + if (path.exists(metaPath)) { + shell::Open(metaPath); + return; + } else { + QSettings metaFile(metaPath, QSettings::IniFormat); + metaFile.setValue("removed", false); + } + + if (path.exists(metaPath)) { + shell::Open(metaPath); + return; + } + + shell::Explore(filePath); +} + +void DownloadManager::openInDownloadsFolder(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + reportError(tr("OpenFileInDownloadsFolder: invalid download index %1").arg(index)); + return; + } + + const auto path = getFilePath(index); + + if (QFile::exists(path)) { + shell::Explore(path); + return; + } else { + const auto unfinished = path + ".unfinished"; + if (QFile::exists(unfinished)) { + shell::Explore(unfinished); + return; + } + } + + shell::Explore(m_OutputDirectory); +} + +int DownloadManager::numTotalDownloads() const +{ + return m_ActiveDownloads.size(); +} + +int DownloadManager::numPendingDownloads() const +{ + return m_PendingDownloads.size(); +} + +std::tuple DownloadManager::getPendingDownload(int index) +{ + if ((index < 0) || (index >= m_PendingDownloads.size())) { + throw MyException(tr("get pending: invalid download index %1").arg(index)); + } + + return m_PendingDownloads.at(index); +} + +QString DownloadManager::getFilePath(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("get path: invalid download index %1").arg(index)); + } + + return m_OutputDirectory + "/" + m_ActiveDownloads.at(index)->m_FileName; +} + +QString DownloadManager::getFileTypeString(int fileType) +{ + switch (fileType) { + case 1: + return tr("Main"); + case 2: + return tr("Update"); + case 3: + return tr("Optional"); + case 4: + return tr("Old"); + case 5: + return tr("Miscellaneous"); + case 6: + return tr("Deleted"); + case 7: + return tr("Archived"); + default: + return tr("Unknown"); + } +} + +QString DownloadManager::getDisplayName(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("display name: invalid download index %1").arg(index)); + } + + DownloadInfo* info = m_ActiveDownloads.at(index); + + QTextDocument doc; + if (!info->m_FileInfo->name.isEmpty()) { + doc.setHtml(info->m_FileInfo->name); + return QString("%1 (%2, v%3)") + .arg(doc.toPlainText()) + .arg(getFileTypeString(info->m_FileInfo->fileCategory)) + .arg(info->m_FileInfo->version.displayString()); + } else { + doc.setHtml(info->m_FileName); + return doc.toPlainText(); + } +} + +QString DownloadManager::getFileName(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("file name: invalid download index %1").arg(index)); + } + + return m_ActiveDownloads.at(index)->m_FileName; +} + +QDateTime DownloadManager::getFileTime(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("file time: invalid download index %1").arg(index)); + } + + DownloadInfo* info = m_ActiveDownloads.at(index); + if (!info->m_Created.isValid()) { + QFileInfo fileInfo(info->m_Output); + info->m_Created = fileInfo.birthTime(); + if (!info->m_Created.isValid()) + info->m_Created = fileInfo.metadataChangeTime(); + if (!info->m_Created.isValid()) + info->m_Created = fileInfo.lastModified(); + } + + return info->m_Created; +} + +qint64 DownloadManager::getFileSize(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("file size: invalid download index %1").arg(index)); + } + + return m_ActiveDownloads.at(index)->m_TotalSize; +} + +std::pair DownloadManager::getProgress(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("progress: invalid download index %1").arg(index)); + } + + return m_ActiveDownloads.at(index)->m_Progress; +} + +DownloadManager::DownloadState DownloadManager::getState(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("state: invalid download index %1").arg(index)); + } + + return m_ActiveDownloads.at(index)->m_State; +} + +bool DownloadManager::isInfoIncomplete(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("infocomplete: invalid download index %1").arg(index)); + } + + DownloadInfo* info = m_ActiveDownloads.at(index); + if (info->m_FileInfo->repository != "Nexus") { + // other repositories currently don't support re-querying info anyway + return false; + } + return (info->m_FileInfo->fileID == 0) || (info->m_FileInfo->modID == 0); +} + +int DownloadManager::getModID(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("mod id: invalid download index %1").arg(index)); + } + return m_ActiveDownloads.at(index)->m_FileInfo->modID; +} + +QString DownloadManager::getDisplayGameName(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("mod id: invalid download index %1").arg(index)); + } + QString gameName = m_ActiveDownloads.at(index)->m_FileInfo->gameName; + IPluginGame* gamePlugin = m_OrganizerCore->getGame(gameName); + if (gamePlugin) { + gameName = gamePlugin->gameName(); + } + return gameName; +} + +QString DownloadManager::getGameName(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("mod id: invalid download index %1").arg(index)); + } + return m_ActiveDownloads.at(index)->m_FileInfo->gameName; +} + +bool DownloadManager::isHidden(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("ishidden: invalid download index %1").arg(index)); + } + return m_ActiveDownloads.at(index)->m_Hidden; +} + +const ModRepositoryFileInfo* DownloadManager::getFileInfo(int index) const +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("file info: invalid download index %1").arg(index)); + } + + return m_ActiveDownloads.at(index)->m_FileInfo; +} + +void DownloadManager::markInstalled(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("mark installed: invalid download index %1").arg(index)); + } + + // Avoid triggering refreshes from DirWatcher + ScopedDisableDirWatcher scopedDirWatcher(this); + + DownloadInfo* info = m_ActiveDownloads.at(index); + QSettings metaFile(info->m_Output.fileName() + ".meta", QSettings::IniFormat); + metaFile.setValue("installed", true); + metaFile.setValue("uninstalled", false); + + setState(m_ActiveDownloads.at(index), STATE_INSTALLED); +} + +void DownloadManager::markInstalled(QString fileName) +{ + int index = indexByName(fileName); + if (index >= 0) { + markInstalled(index); + } else { + DownloadInfo* info = getDownloadInfo(fileName); + if (info != nullptr) { + // Avoid triggering refreshes from DirWatcher + ScopedDisableDirWatcher scopedDirWatcher(this); + + QSettings metaFile(info->m_Output.fileName() + ".meta", QSettings::IniFormat); + metaFile.setValue("installed", true); + metaFile.setValue("uninstalled", false); + delete info; + } + } +} + +DownloadManager::DownloadInfo* DownloadManager::getDownloadInfo(QString fileName) +{ + return DownloadInfo::createFromMeta(fileName, true, m_OutputDirectory); +} + +void DownloadManager::markUninstalled(int index) +{ + if ((index < 0) || (index >= m_ActiveDownloads.size())) { + throw MyException(tr("mark uninstalled: invalid download index %1").arg(index)); + } + + // Avoid triggering refreshes from DirWatcher + ScopedDisableDirWatcher scopedDirWatcher(this); + + DownloadInfo* info = m_ActiveDownloads.at(index); + QSettings metaFile(info->m_Output.fileName() + ".meta", QSettings::IniFormat); + metaFile.setValue("uninstalled", true); + + setState(m_ActiveDownloads.at(index), STATE_UNINSTALLED); +} + +void DownloadManager::markUninstalled(QString fileName) +{ + int index = indexByName(fileName); + if (index >= 0) { + markUninstalled(index); + } else { + QString filePath = QDir::fromNativeSeparators(m_OutputDirectory) + "/" + fileName; + DownloadInfo* info = getDownloadInfo(filePath); + if (info != nullptr) { + + // Avoid triggering refreshes from DirWatcher + ScopedDisableDirWatcher scopedDirWatcher(this); + + QSettings metaFile(info->m_Output.fileName() + ".meta", QSettings::IniFormat); + metaFile.setValue("uninstalled", true); + delete info; + } + } +} + +QString DownloadManager::getDownloadFileName(const QString& baseName, bool rename) const +{ + QString fullPath = m_OutputDirectory + "/" + MOBase::sanitizeFileName(baseName); + if (QFile::exists(fullPath) && rename) { + int i = 1; + while (QFile::exists( + QString("%1/%2_%3").arg(m_OutputDirectory).arg(i).arg(baseName))) { + ++i; + } + + fullPath = QString("%1/%2_%3").arg(m_OutputDirectory).arg(i).arg(baseName); + } + return fullPath; +} + +QString DownloadManager::getFileNameFromNetworkReply(QNetworkReply* reply) +{ + if (reply->hasRawHeader("Content-Disposition")) { + std::regex exp("filename=\"(.+)\""); + + std::cmatch result; + if (std::regex_search(reply->rawHeader("Content-Disposition").constData(), result, + exp)) { + return MOBase::sanitizeFileName(QString::fromUtf8(result.str(1).c_str())); + } + } + + return QString(); +} + +void DownloadManager::setState(DownloadManager::DownloadInfo* info, + DownloadManager::DownloadState state) +{ + int row = 0; + for (int i = 0; i < m_ActiveDownloads.size(); ++i) { + if (m_ActiveDownloads[i] == info) { + row = i; + break; + } + } + info->m_State = state; + switch (state) { + case STATE_PAUSED: { + info->m_Reply->abort(); + info->m_Output.close(); + m_DownloadPaused(row); + } break; + case STATE_ERROR: { + info->m_Reply->abort(); + info->m_Output.close(); + m_DownloadFailed(row); + } break; + case STATE_CANCELED: { + info->m_Reply->abort(); + m_DownloadFailed(row); + } break; + case STATE_FETCHINGMODINFO: { + m_RequestIDs.insert(m_NexusInterface->requestDescription( + info->m_FileInfo->gameName, info->m_FileInfo->modID, this, info->m_DownloadID, + QString())); + } break; + case STATE_FETCHINGFILEINFO: { + m_RequestIDs.insert(m_NexusInterface->requestFiles(info->m_FileInfo->gameName, + info->m_FileInfo->modID, this, + info->m_DownloadID, QString())); + } break; + case STATE_FETCHINGMODINFO_MD5: { + log::debug("Searching {} for MD5 of {}", info->m_GamesToQuery[0], + QString(info->m_Hash.toHex())); + m_RequestIDs.insert(m_NexusInterface->requestInfoFromMd5( + info->m_GamesToQuery[0], info->m_Hash, this, info->m_DownloadID, QString())); + } break; + case STATE_READY: { + createMetaFile(info); + m_DownloadComplete(row); + } break; + default: /* NOP */ + break; + } + emit stateChanged(row, state); +} + +DownloadManager::DownloadInfo* DownloadManager::findDownload(QObject* reply, + int* index) const +{ + // reverse search as newer, thus more relevant, downloads are at the end + for (int i = m_ActiveDownloads.size() - 1; i >= 0; --i) { + if (m_ActiveDownloads[i]->m_Reply == reply) { + if (index != nullptr) { + *index = i; + } + return m_ActiveDownloads[i]; + } + } + return nullptr; +} + +void DownloadManager::downloadProgress(qint64 bytesReceived, qint64 bytesTotal) +{ + if (bytesTotal == 0) { + return; + } + int index = 0; + try { + DownloadInfo* info = findDownload(this->sender(), &index); + if (info != nullptr) { + info->m_HasData = true; + if (info->m_State == STATE_CANCELING) { + setState(info, STATE_CANCELED); + } else if (info->m_State == STATE_PAUSING) { + setState(info, STATE_PAUSED); + } else { + if (bytesTotal > info->m_TotalSize) { + info->m_TotalSize = bytesTotal; + } + int oldProgress = info->m_Progress.first; + info->m_Progress.first = ((info->m_ResumePos + bytesReceived) * 100) / + (info->m_ResumePos + bytesTotal); + + qint64 elapsed = info->m_StartTime.elapsed(); + info->m_DownloadAcc(bytesReceived - info->m_DownloadLast); + info->m_DownloadLast = bytesReceived; + info->m_DownloadTimeAcc(elapsed - info->m_DownloadTimeLast); + info->m_DownloadTimeLast = elapsed; + + // calculate the download speed + const double speed = rolling_mean(info->m_DownloadAcc) / + (rolling_mean(info->m_DownloadTimeAcc) / 1000.0); + ; + + const qint64 remaining = (bytesTotal - bytesReceived) / speed * 1000; + + info->m_Progress.second = tr("%1% - %2 - ~%3") + .arg(info->m_Progress.first) + .arg(MOBase::localizedByteSpeed(speed)) + .arg(MOBase::localizedTimeRemaining(remaining)); + + TaskProgressManager::instance().updateProgress(info->m_TaskProgressId, + bytesReceived, bytesTotal); + emit update(index); + } + } + } catch (const std::bad_alloc&) { + reportError(tr("Memory allocation error (in processing progress event).")); + } +} + +void DownloadManager::downloadReadyRead() +{ + try { + writeData(findDownload(this->sender())); + } catch (const std::bad_alloc&) { + reportError(tr("Memory allocation error (in processing downloaded data).")); + } +} + +void DownloadManager::createMetaFile(DownloadInfo* info) +{ + // Avoid triggering refreshes from DirWatcher + ScopedDisableDirWatcher scopedDirWatcher(this); + + QSettings metaFile(QString("%1.meta").arg(info->m_Output.fileName()), + QSettings::IniFormat); + metaFile.setValue("gameName", info->m_FileInfo->gameName); + metaFile.setValue("modID", info->m_FileInfo->modID); + metaFile.setValue("fileID", info->m_FileInfo->fileID); + metaFile.setValue("url", info->m_Urls.join(";")); + metaFile.setValue("name", info->m_FileInfo->name); + metaFile.setValue("description", info->m_FileInfo->description); + metaFile.setValue("modName", info->m_FileInfo->modName); + metaFile.setValue("version", info->m_FileInfo->version.canonicalString()); + metaFile.setValue("newestVersion", info->m_FileInfo->newestVersion.canonicalString()); + metaFile.setValue("fileTime", info->m_FileInfo->fileTime); + metaFile.setValue("fileCategory", info->m_FileInfo->fileCategory); + metaFile.setValue("category", info->m_FileInfo->categoryID); + metaFile.setValue("repository", info->m_FileInfo->repository); + metaFile.setValue("userData", info->m_FileInfo->userData); + metaFile.setValue("author", info->m_FileInfo->author); + metaFile.setValue("uploader", info->m_FileInfo->uploader); + metaFile.setValue("uploaderUrl", info->m_FileInfo->uploaderUrl); + metaFile.setValue("installed", info->m_State == DownloadManager::STATE_INSTALLED); + metaFile.setValue("uninstalled", info->m_State == DownloadManager::STATE_UNINSTALLED); + metaFile.setValue("paused", (info->m_State == DownloadManager::STATE_PAUSED) || + (info->m_State == DownloadManager::STATE_ERROR)); + metaFile.setValue("removed", info->m_Hidden); + + // slightly hackish... + for (int i = 0; i < m_ActiveDownloads.size(); ++i) { + if (m_ActiveDownloads[i] == info) { + emit update(i); + } + } +} + +void DownloadManager::nxmDescriptionAvailable(QString, int, QVariant userData, + QVariant resultData, int requestID) +{ + std::set::iterator idIter = m_RequestIDs.find(requestID); + if (idIter == m_RequestIDs.end()) { + return; + } else { + m_RequestIDs.erase(idIter); + } + + QVariantMap result = resultData.toMap(); + + DownloadInfo* info = downloadInfoByID(userData.toInt()); + if (info == nullptr) + return; + info->m_FileInfo->categoryID = result["category_id"].toInt(); + info->m_FileInfo->author = result["author"].toString(); + info->m_FileInfo->uploader = result["uploaded_by"].toString(); + info->m_FileInfo->uploaderUrl = result["uploaded_users_profile_url"].toString(); + QTextDocument doc; + doc.setHtml(result["name"].toString().trimmed()); + info->m_FileInfo->modName = doc.toPlainText(); + if (info->m_FileInfo->fileID != 0) { + setState(info, STATE_READY); + } else { + setState(info, STATE_FETCHINGFILEINFO); + } +} + +void DownloadManager::nxmFilesAvailable(QString, int, QVariant userData, + QVariant resultData, int requestID) +{ + std::set::iterator idIter = m_RequestIDs.find(requestID); + if (idIter == m_RequestIDs.end()) { + return; + } else { + m_RequestIDs.erase(idIter); + } + + DownloadInfo* info = downloadInfoByID(userData.toInt()); + if (info == nullptr) + return; + + QVariantMap result = resultData.toMap(); + QVariantList files = result["files"].toList(); + + // MO sometimes prepends _ to the filename in case of duplicate downloads. + // this may muck up the file name comparison + QString alternativeLocalName = info->m_FileName; + + QRegularExpression expression("^\\d_(.*)$"); + auto match = expression.match(alternativeLocalName); + if (match.hasMatch()) { + alternativeLocalName = match.captured(1); + } + + bool found = false; + + for (QVariant file : files) { + QVariantMap fileInfo = file.toMap(); + QString fileName = fileInfo["file_name"].toString(); + QString fileNameVariant = fileName.mid(0).replace(' ', '_'); + if ((fileName == info->m_RemoteFileName) || + (fileNameVariant == info->m_RemoteFileName) || (fileName == info->m_FileName) || + (fileNameVariant == info->m_FileName) || (fileName == alternativeLocalName) || + (fileNameVariant == alternativeLocalName)) { + info->m_FileInfo->name = fileInfo["name"].toString(); + info->m_FileInfo->version.parse(fileInfo["version"].toString()); + if (!info->m_FileInfo->version.isValid()) { + info->m_FileInfo->version = info->m_FileInfo->newestVersion; + } + info->m_FileInfo->fileCategory = fileInfo["category_id"].toInt(); + info->m_FileInfo->fileTime = + QDateTime::fromMSecsSinceEpoch(fileInfo["uploaded_timestamp"].toLongLong()); + info->m_FileInfo->fileID = fileInfo["file_id"].toInt(); + info->m_FileInfo->fileName = fileInfo["file_name"].toString(); + info->m_FileInfo->description = + BBCode::convertToHTML(fileInfo["description"].toString()); + info->m_FileInfo->author = fileInfo["author"].toString(); + info->m_FileInfo->uploader = fileInfo["uploaded_by"].toString(); + info->m_FileInfo->uploaderUrl = fileInfo["uploaded_users_profile_url"].toString(); + found = true; + break; + } + } + + if (info->m_ReQueried) { + if (found) { + emit showMessage(tr("Information updated")); + } else if (result.count() == 0) { + emit showMessage(tr("No matching file found on Nexus! Maybe this file is no " + "longer available or it was renamed?")); + } else { + SelectionDialog selection(tr("No file on Nexus matches the selected file by " + "name. Please manually choose the correct one.")); + std::sort(files.begin(), files.end(), + [](const QVariant& lhs, const QVariant& rhs) { + return lhs.toMap()["uploaded_timestamp"].toInt() > + rhs.toMap()["uploaded_timestamp"].toInt(); + }); + for (QVariant file : files) { + QVariantMap fileInfo = file.toMap(); + if (fileInfo["category_id"].toInt() != NexusInterface::FileStatus::REMOVED && + fileInfo["category_id"].toInt() != NexusInterface::FileStatus::ARCHIVED) + selection.addChoice(fileInfo["file_name"].toString(), "", file); + } + if (selection.exec() == QDialog::Accepted) { + QVariantMap fileInfo = selection.getChoiceData().toMap(); + info->m_FileInfo->name = fileInfo["name"].toString(); + info->m_FileInfo->version.parse(fileInfo["version"].toString()); + info->m_FileInfo->fileCategory = fileInfo["category_id"].toInt(); + info->m_FileInfo->fileID = fileInfo["file_id"].toInt(); + } else { + emit showMessage(tr("No matching file found on Nexus! Maybe this file is no " + "longer available or it was renamed?")); + } + } + } else { + if (info->m_FileInfo->fileID == 0) { + log::warn("could not determine file id for {} (state {})", info->m_FileName, + info->m_State); + } + } + + setState(info, STATE_READY); +} + +void DownloadManager::nxmFileInfoAvailable(QString gameName, int modID, int fileID, + QVariant userData, QVariant resultData, + int requestID) +{ + std::set::iterator idIter = m_RequestIDs.find(requestID); + if (idIter == m_RequestIDs.end()) { + return; + } else { + m_RequestIDs.erase(idIter); + } + + ModRepositoryFileInfo* info = + qobject_cast(qvariant_cast(userData)); + + QVariantMap result = resultData.toMap(); + info->name = result["name"].toString(); + info->version.parse(result["version"].toString()); + if (!info->version.isValid()) { + info->version = info->newestVersion; + } + info->fileName = result["file_name"].toString(); + info->fileCategory = result["category_id"].toInt(); + info->fileTime = + QDateTime::fromMSecsSinceEpoch(result["uploaded_timestamp"].toLongLong()); + info->description = BBCode::convertToHTML(result["description"].toString()); + + info->repository = "Nexus"; + + QStringList games(m_ManagedGame->validShortNames()); + games += m_ManagedGame->gameShortName(); + for (auto game : games) { + MOBase::IPluginGame* gamePlugin = m_OrganizerCore->getGame(game); + if (gamePlugin != nullptr && + gamePlugin->gameNexusName().compare(gameName, Qt::CaseInsensitive) == 0) { + info->gameName = gamePlugin->gameShortName(); + } + } + + info->modID = modID; + info->fileID = fileID; + + QObject* test = info; + m_RequestIDs.insert( + m_NexusInterface->requestDownloadURL(info->gameName, info->modID, info->fileID, + this, QVariant::fromValue(test), QString())); +} + +static int evaluateFileInfoMap(const QVariantMap& map, + const ServerList::container& preferredServers) +{ + int preference = 0; + bool found = false; + const auto name = map["short_name"].toString(); + + for (const auto& server : preferredServers) { + if (server.name() == name) { + preference = server.preferred(); + found = true; + break; + } + } + + if (!found) { + return 0; + } + + return 100 + preference * 20; +} + +// sort function to sort by best download server +// +bool ServerByPreference(const ServerList::container& preferredServers, + const QVariant& LHS, const QVariant& RHS) +{ + const auto a = evaluateFileInfoMap(LHS.toMap(), preferredServers); + const auto b = evaluateFileInfoMap(RHS.toMap(), preferredServers); + return (a > b); +} + +int DownloadManager::startDownloadURLs(const QStringList& urls) +{ + ModRepositoryFileInfo info; + addDownload(urls, "", -1, -1, &info); + return m_ActiveDownloads.size() - 1; +} + +int DownloadManager::startDownloadNexusFile(const QString& gameName, int modID, + int fileID) +{ + int newID = m_ActiveDownloads.size(); + addNXMDownload( + QString("nxm://%1/mods/%2/files/%3").arg(gameName).arg(modID).arg(fileID)); + return newID; +} + +QString DownloadManager::downloadPath(int id) +{ + return getFilePath(id); +} + +boost::signals2::connection +DownloadManager::onDownloadComplete(const std::function& callback) +{ + return m_DownloadComplete.connect(callback); +} + +boost::signals2::connection +DownloadManager::onDownloadPaused(const std::function& callback) +{ + return m_DownloadPaused.connect(callback); +} + +boost::signals2::connection +DownloadManager::onDownloadFailed(const std::function& callback) +{ + return m_DownloadFailed.connect(callback); +} + +boost::signals2::connection +DownloadManager::onDownloadRemoved(const std::function& callback) +{ + return m_DownloadRemoved.connect(callback); +} + +int DownloadManager::indexByName(const QString& fileName) const +{ + for (int i = 0; i < m_ActiveDownloads.size(); ++i) { + if (m_ActiveDownloads[i]->m_FileName == fileName) { + return i; + } + } + return -1; +} + +int DownloadManager::indexByInfo(const DownloadInfo* info) const +{ + for (int i = 0; i < m_ActiveDownloads.size(); ++i) { + if (m_ActiveDownloads[i] == info) { + return i; + } + } + return -1; +} + +void DownloadManager::nxmDownloadURLsAvailable(QString gameName, int modID, int fileID, + QVariant userData, QVariant resultData, + int requestID) +{ + using namespace boost::placeholders; + + std::set::iterator idIter = m_RequestIDs.find(requestID); + if (idIter == m_RequestIDs.end()) { + return; + } else { + m_RequestIDs.erase(idIter); + } + + ModRepositoryFileInfo* info = + qobject_cast(qvariant_cast(userData)); + QVariantList resultList = resultData.toList(); + if (resultList.length() == 0) { + removePending(gameName, modID, fileID); + emit showMessage(tr("No download server available. Please try again later.")); + return; + } + + const auto servers = m_OrganizerCore->settings().network().servers(); + + std::sort(resultList.begin(), resultList.end(), + boost::bind(&ServerByPreference, servers.getPreferred(), _1, _2)); + + info->userData["downloadMap"] = resultList; + + QStringList URLs; + + foreach (const QVariant& server, resultList) { + URLs.append(server.toMap()["URI"].toString()); + } + addDownload(URLs, gameName, modID, fileID, info); +} + +void DownloadManager::nxmFileInfoFromMd5Available(QString gameName, QVariant userData, + QVariant resultData, int requestID) +{ + std::set::iterator idIter = m_RequestIDs.find(requestID); + if (idIter == m_RequestIDs.end()) { + return; + } else { + m_RequestIDs.erase(idIter); + } + + DownloadInfo* info = downloadInfoByID(userData.toInt()); + + // This can come back with multiple results with the same file was uploaded multiple + // times (for whatever reason) + auto resultlist = resultData.toList(); + int chosenIdx = resultlist.count() > 1 ? -1 : 0; + + // Look for the exact file name + if (chosenIdx < 0) { + for (int i = 0; i < resultlist.count(); i++) { + auto results = resultlist[i].toMap(); + auto fileDetails = results["file_details"].toMap(); + + if (fileDetails["file_name"].toString().compare(info->m_FileName, + Qt::CaseInsensitive) == 0) { + if (chosenIdx < 0) { + chosenIdx = i; // intentional to not break in order to check other results + } else { + log::debug("Multiple files with same name found during MD5 search."); + chosenIdx = -1; + break; + } + } + } + } + + // Look for the only active one + if (chosenIdx < 0) { + for (int i = 0; i < resultlist.count(); i++) { + auto results = resultlist[i].toMap(); + auto fileDetails = results["file_details"].toMap(); + + if (fileDetails["category_id"].toInt() != NexusInterface::FileStatus::REMOVED && + fileDetails["category_id"].toInt() != NexusInterface::FileStatus::ARCHIVED) { + if (chosenIdx < 0) { + chosenIdx = i; // intentional to not break in order to check other results + } else { + log::debug("Multiple active files found during MD5 search."); + chosenIdx = -1; + break; + } + } + } + } + + // Unable to determine the correct mod / file. Revert to the old method + if (chosenIdx < 0) { + // don't use the normal state set function as we don't want to create a meta file + info->m_State = DownloadManager::STATE_READY; + queryInfo(m_ActiveDownloads.indexOf(info)); + return; + } + + auto results = resultlist[chosenIdx].toMap(); + auto fileDetails = results["file_details"].toMap(); + auto modDetails = results["mod"].toMap(); + + info->m_FileInfo->name = fileDetails["name"].toString(); + info->m_FileInfo->fileID = fileDetails["file_id"].toInt(); + info->m_FileInfo->description = + BBCode::convertToHTML(fileDetails["description"].toString()); + info->m_FileInfo->version.parse(fileDetails["version"].toString()); + if (!info->m_FileInfo->version.isValid()) + info->m_FileInfo->version.parse(fileDetails["mod_version"].toString()); + info->m_FileInfo->fileCategory = fileDetails["category_id"].toInt(); + + info->m_FileInfo->modID = modDetails["mod_id"].toInt(); + info->m_FileInfo->modName = modDetails["name"].toString(); + info->m_FileInfo->categoryID = modDetails["category_id"].toInt(); + info->m_FileInfo->author = modDetails["author"].toString(); + info->m_FileInfo->uploader = modDetails["uploaded_by"].toString(); + info->m_FileInfo->uploaderUrl = modDetails["uploaded_users_profile_url"].toString(); + + QString gameShortName = gameName; + QStringList games(m_ManagedGame->validShortNames()); + games += m_ManagedGame->gameShortName(); + for (auto game : games) { + MOBase::IPluginGame* gamePlugin = m_OrganizerCore->getGame(game); + if (gamePlugin != nullptr && + gamePlugin->gameNexusName().compare(gameName, Qt::CaseInsensitive) == 0) { + gameShortName = gamePlugin->gameShortName(); + break; + } + } + + info->m_FileInfo->gameName = gameShortName; + + // If the file description is not present, send another query to get it + if (!fileDetails["description"].isValid()) { + info->m_RemoteFileName = fileDetails["file_name"].toString(); + setState(info, STATE_FETCHINGFILEINFO); + } else { + setState(info, STATE_READY); + } +} + +void DownloadManager::nxmRequestFailed(QString gameName, int modID, int fileID, + QVariant userData, int requestID, int errorCode, + const QString& errorString) +{ + std::set::iterator idIter = m_RequestIDs.find(requestID); + if (idIter == m_RequestIDs.end()) { + return; + } else { + m_RequestIDs.erase(idIter); + } + + DownloadInfo* userDataInfo = downloadInfoByID(userData.toInt()); + + int index = 0; + + for (QVector::iterator iter = m_ActiveDownloads.begin(); + iter != m_ActiveDownloads.end(); ++iter, ++index) { + DownloadInfo* info = *iter; + if (info != userDataInfo) + continue; + + // MD5 searches continue until all possible games are done + if (info->m_State == STATE_FETCHINGMODINFO_MD5) { + if (info->m_GamesToQuery.count() >= 2) { + info->m_GamesToQuery.pop_front(); + setState(info, STATE_FETCHINGMODINFO_MD5); + return; + } else if (errorCode == 404 && !info->m_AskIfNotFound) { + // prevent re-querying (only possible with repository = "Nexus") + info->m_FileInfo->repository = ""; + setState(info, STATE_READY); + return; + } else { + info->m_State = STATE_READY; + queryInfo(index); + emit update(index); + return; + } + } + + if (info->m_FileInfo->modID == modID) { + if (info->m_State < STATE_FETCHINGMODINFO) { + m_ActiveDownloads.erase(iter); + delete info; + } else { + setState(info, STATE_READY); + } + emit update(index); + break; + } + } + + removePending(gameName, modID, fileID); + emit showMessage(tr("Failed to request file info from nexus: %1").arg(errorString)); +} + +void DownloadManager::downloadFinished(int index) +{ + DownloadInfo* info; + if (index > 0) + info = m_ActiveDownloads[index]; + else { + info = findDownload(this->sender(), &index); + if (info == nullptr && index == 0) { + info = m_ActiveDownloads[index]; + } + } + + if (info != nullptr) { + QNetworkReply* reply = info->m_Reply; + QByteArray data; + if (reply->isOpen() && info->m_HasData) { + data = reply->readAll(); + info->m_Output.write(data); + } + info->m_Output.close(); + TaskProgressManager::instance().forgetMe(info->m_TaskProgressId); + + bool error = false; + if ((info->m_State != STATE_CANCELING) && (info->m_State != STATE_PAUSING)) { + bool textData = reply->header(QNetworkRequest::ContentTypeHeader) + .toString() + .startsWith("text", Qt::CaseInsensitive); + if (textData) + emit showMessage( + tr("Warning: Content type is: %1") + .arg(reply->header(QNetworkRequest::ContentTypeHeader).toString())); + if ((info->m_Output.size() == 0) || + ((reply->error() != QNetworkReply::NoError) && + (reply->error() != QNetworkReply::OperationCanceledError))) { + if (reply->error() == QNetworkReply::UnknownContentError) + emit showMessage( + tr("Download header content length: %1 downloaded file size: %2") + .arg(reply->header(QNetworkRequest::ContentLengthHeader).toLongLong()) + .arg(info->m_Output.size())); + if (info->m_Tries == 0) { + emit showMessage(tr("Download failed: %1 (%2)") + .arg(reply->errorString()) + .arg(reply->error())); + } + error = true; + setState(info, STATE_ERROR); + } + } + + if (info->m_State == STATE_CANCELING) { + setState(info, STATE_CANCELED); + } else if (info->m_State == STATE_PAUSING) { + if (info->m_Output.isOpen() && info->m_HasData) { + info->m_Output.write(info->m_Reply->readAll()); + } + setState(info, STATE_PAUSED); + } + + if (info->m_State == STATE_CANCELED || (info->m_Tries == 0 && error)) { + emit aboutToUpdate(); + info->m_Output.remove(); + delete info; + m_ActiveDownloads.erase(m_ActiveDownloads.begin() + index); + if (error) + emit showMessage( + tr("We were unable to download the file due to errors after four retries. " + "There may be an issue with the Nexus servers.")); + emit update(-1); + } else if (info->isPausedState() || info->m_State == STATE_PAUSING) { + emit aboutToUpdate(); + info->m_Output.close(); + createMetaFile(info); + emit update(index); + } else { + emit aboutToUpdate(); + QString url = info->m_Urls[info->m_CurrentUrl]; + if (info->m_FileInfo->userData.contains("downloadMap")) { + foreach (const QVariant& server, + info->m_FileInfo->userData["downloadMap"].toList()) { + QVariantMap serverMap = server.toMap(); + if (serverMap["URI"].toString() == url) { + int deltaTime = info->m_StartTime.elapsed() / 1000; + if (deltaTime > 5) { + emit downloadSpeed(serverMap["short_name"].toString(), + (info->m_TotalSize - info->m_PreResumeSize) / + deltaTime); + } // no division by zero please! Also, if the download is shorter than a + // few seconds, the result is way to inprecise + break; + } + } + } + + bool isNexus = info->m_FileInfo->repository == "Nexus"; + // need to change state before changing the file name, otherwise .unfinished is + // appended + if (isNexus) { + setState(info, STATE_FETCHINGMODINFO); + } else { + setState(info, STATE_NOFETCH); + } + + QString newName = getFileNameFromNetworkReply(reply); + QString oldName = QFileInfo(info->m_Output).fileName(); + + startDisableDirWatcher(); + if (!newName.isEmpty() && (oldName.isEmpty())) { + info->setName(getDownloadFileName(newName), true); + } else { + info->setName(m_OutputDirectory + "/" + info->m_FileName, + true); // don't rename but remove the ".unfinished" extension + } + endDisableDirWatcher(); + + if (!isNexus) { + setState(info, STATE_READY); + } + + emit update(index); + } + reply->close(); + reply->deleteLater(); + + if ((info->m_Tries > 0) && error) { + --info->m_Tries; + resumeDownloadInt(index); + } + } else { + log::warn("no download index {}", index); + } +} + +void DownloadManager::downloadError(QNetworkReply::NetworkError error) +{ + if (error != QNetworkReply::OperationCanceledError) { + QNetworkReply* reply = qobject_cast(sender()); + log::warn("{} ({})", + reply != nullptr ? reply->errorString() : "Download error occured", + error); + } +} + +void DownloadManager::metaDataChanged() +{ + int index = 0; + + DownloadInfo* info = findDownload(this->sender(), &index); + if (info != nullptr) { + QString newName = getFileNameFromNetworkReply(info->m_Reply); + if (!newName.isEmpty() && (info->m_FileName.isEmpty())) { + startDisableDirWatcher(); + info->setName(getDownloadFileName(newName), true); + endDisableDirWatcher(); + refreshAlphabeticalTranslation(); + if (!info->m_Output.isOpen() && + !info->m_Output.open(QIODevice::WriteOnly | QIODevice::Append)) { + reportError(tr("failed to re-open %1").arg(info->m_FileName)); + setState(info, STATE_CANCELING); + } + } + } else { + log::warn("meta data event for unknown download"); + } +} + +void DownloadManager::directoryChanged(const QString&) +{ + if (DownloadManager::m_DirWatcherDisabler == 0) + refreshList(); +} + +void DownloadManager::managedGameChanged(MOBase::IPluginGame const* managedGame) +{ + m_ManagedGame = managedGame; +} + +void DownloadManager::checkDownloadTimeout() +{ + for (int i = 0; i < m_ActiveDownloads.size(); ++i) { + if (m_ActiveDownloads[i]->m_StartTime.elapsed() - + m_ActiveDownloads[i]->m_DownloadTimeLast > + 5 * 1000 && + m_ActiveDownloads[i]->m_State == STATE_DOWNLOADING && + m_ActiveDownloads[i]->m_Reply != nullptr && + m_ActiveDownloads[i]->m_Reply->isOpen()) { + pauseDownload(i); + downloadFinished(i); + resumeDownload(i); + } + } +} + +void DownloadManager::writeData(DownloadInfo* info) +{ + if (info != nullptr) { + qint64 ret = info->m_Output.write(info->m_Reply->readAll()); + if (ret < info->m_Reply->size()) { + QString fileName = + info->m_FileName; // m_FileName may be destroyed after setState + setState(info, DownloadState::STATE_CANCELED); + + log::error("Unable to write download \"{}\" to drive (return {})", + info->m_FileName, ret); + + reportError(tr("Unable to write download to drive (return %1).\n" + "Check the drive's available storage.\n\n" + "Canceling download \"%2\"...") + .arg(ret) + .arg(fileName)); + } + } +} diff --git a/src/src/downloadmanager.h b/src/src/downloadmanager.h new file mode 100644 index 0000000..f17fe3c --- /dev/null +++ b/src/src/downloadmanager.h @@ -0,0 +1,647 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef DOWNLOADMANAGER_H +#define DOWNLOADMANAGER_H + +#include "serverinfo.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using namespace boost::accumulators; + +namespace MOBase +{ +class IPluginGame; +} + +class NexusInterface; +class PluginContainer; +class OrganizerCore; + +/*! + * \brief manages downloading of files and provides progress information for gui + *elements + **/ +class DownloadManager : public QObject +{ + Q_OBJECT + +public: + enum DownloadState + { + STATE_STARTED = 0, + STATE_DOWNLOADING, + STATE_CANCELING, + STATE_PAUSING, + STATE_CANCELED, + STATE_PAUSED, + STATE_ERROR, + STATE_FETCHINGMODINFO, + STATE_FETCHINGFILEINFO, + STATE_FETCHINGMODINFO_MD5, + STATE_NOFETCH, + STATE_READY, + STATE_INSTALLED, + STATE_UNINSTALLED + }; + +private: + struct DownloadInfo + { + ~DownloadInfo() { delete m_FileInfo; } + accumulator_set> m_DownloadAcc; + accumulator_set> m_DownloadTimeAcc; + qint64 m_DownloadLast; + qint64 m_DownloadTimeLast; + unsigned int m_DownloadID; + QString m_FileName; + QFile m_Output; + QNetworkReply* m_Reply; + QElapsedTimer m_StartTime; + qint64 m_PreResumeSize; + std::pair m_Progress; + bool m_HasData; + DownloadState m_State; + int m_CurrentUrl; + QStringList m_Urls; + qint64 m_ResumePos; + qint64 m_TotalSize; + QDateTime m_Created; // used as a cache in DownloadManager::getFileTime, may not be + // valid elsewhere + QByteArray m_Hash; + QStringList m_GamesToQuery; + QString m_RemoteFileName; + + int m_Tries; + bool m_ReQueried; + bool m_AskIfNotFound; + + quint32 m_TaskProgressId; + + MOBase::ModRepositoryFileInfo* m_FileInfo{nullptr}; + + bool m_Hidden; + + static DownloadInfo* createNew(const MOBase::ModRepositoryFileInfo* fileInfo, + const QStringList& URLs); + static DownloadInfo* createFromMeta(const QString& filePath, bool showHidden, + const QString outputDirectory, + std::optional fileSize = {}); + + /** + * @brief rename the file + * this will change the file name as well as the display name. It will automatically + * append .unfinished to the name if this file is still being downloaded + * @param newName the new name to setName + * @param renameFile if true, the file is assumed to exist and renamed. If the file + *does not yet exist, set this to false + **/ + void setName(QString newName, bool renameFile); + + unsigned int downloadID() { return m_DownloadID; } + + bool isPausedState(); + + QString currentURL(); + + private: + static unsigned int s_NextDownloadID; + + private: + DownloadInfo() + : m_TotalSize(0), m_ReQueried(false), m_Hidden(false), m_HasData(false), + m_AskIfNotFound(true), m_DownloadTimeLast(0), m_DownloadLast(0), + m_DownloadAcc(tag::rolling_window::window_size = 200), + m_DownloadTimeAcc(tag::rolling_window::window_size = 200) + {} + }; + + friend class DownloadManagerProxy; + + using SignalDownloadCallback = boost::signals2::signal; + +public: + /** + * @brief constructor + * + * @param nexusInterface interface to use to retrieve information from the relevant + *nexus page + * @param parent parent object + **/ + explicit DownloadManager(NexusInterface* nexusInterface, QObject* parent); + + ~DownloadManager(); + + void setParentWidget(QWidget* w); + + /** + * @brief determine if a download is currently in progress + * + * @return true if there is currently a download in progress + **/ + bool downloadsInProgress(); + + /** + * @brief determine if a download is currently in progress, does not count paused + *ones. + * + * @return true if there is currently a download in progress (that is not paused + *already). + **/ + bool downloadsInProgressNoPause(); + + /** + * @brief set the output directory to write to + * + * @param outputDirectory the new output directory + **/ + void setOutputDirectory(const QString& outputDirectory, const bool refresh = true); + + /** + * @brief disables feedback from the downlods fileSystemWhatcher untill + *disableDownloadsWatcherEnd() is called + * + **/ + static void startDisableDirWatcher(); + + /** + * @brief re-enables feedback from the downlods fileSystemWhatcher after + *disableDownloadsWatcherStart() was called + **/ + static void endDisableDirWatcher(); + + /** + * @return current download directory + **/ + QString getOutputDirectory() const { return m_OutputDirectory; } + + /** + * @brief sets whether hidden files are to be shown after all + */ + void setShowHidden(bool showHidden); + + void setPluginContainer(PluginContainer* pluginContainer); + + /** + * @brief download from an already open network connection + * + * @param reply the network reply to download from + * @param fileInfo information about the file, like mod id, file id, version, ... + * @return true if the download was started, false if it wasn't. The latter currently + *only happens if there is a duplicate and the user decides not to download again + **/ + bool addDownload(QNetworkReply* reply, const MOBase::ModRepositoryFileInfo* fileInfo); + + /** + * @brief download from an already open network connection + * + * @param reply the network reply to download from + * @param fileName the name to use for the file. This may be overridden by the name in + *the fileInfo-structure or if the http stream specifies a name + * @param fileInfo information previously retrieved from the nexus network + * @return true if the download was started, false if it wasn't. The latter currently + *only happens if there is a duplicate and the user decides not to download again + **/ + bool addDownload(QNetworkReply* reply, const QStringList& URLs, + const QString& fileName, QString gameName, int modID, int fileID = 0, + const MOBase::ModRepositoryFileInfo* fileInfo = + new MOBase::ModRepositoryFileInfo()); + + /** + * @brief start a download using a nxm-link + * + * starts a download using a nxm-link. The download manager will first query the nexus + * page for file information. + * @param url a nxm link looking like this: nxm://skyrim/mods/1234/files/4711 + * @todo the game name encoded into the link is currently ignored, all downloads are + *incorrectly assumed to be for the identified game + **/ + void addNXMDownload(const QString& url); + + /** + * @brief retrieve the total number of downloads, both finished and unfinished + *including downloads from previous sessions + * + * @return total number of downloads + **/ + int numTotalDownloads() const; + + /** + * @brief retrieve number of pending downloads (nexus downloads for which we don't + * know the name and url yet) + * @return number of pending downloads + */ + int numPendingDownloads() const; + + /** + * @brief retrieve the info of a pending download + * @param index index of the pending download (index in the range [0, + * numPendingDownloads()[) + * @return pair of modid, fileid + */ + std::tuple getPendingDownload(int index); + + /** + * @brief retrieve the full path to the download specified by index + * + * @param index the index to look up + * @return absolute path of the file + **/ + QString getFilePath(int index) const; + + /** + * @brief retrieve a descriptive name of the download specified by index + * + * @param index index of the file to look up + * @return display name of the file + **/ + QString getDisplayName(int index) const; + + /** + * @brief retrieve the filename of the download specified by index + * + * @param index index of the file to look up + * @return name of the file + **/ + QString getFileName(int index) const; + + /** + * @brief retrieve the file size of the download specified by index + * + * @param index index of the file to look up + * @return size of the file (total size during download) + */ + qint64 getFileSize(int index) const; + + /** + * @brief retrieve the creation time of the download specified by index + * @param index index of the file to look up + * @return size of the file (total size during download) + */ + QDateTime getFileTime(int index) const; + + /** + * @brief retrieve the current progress of the download specified by index + * + * @param index index of the file to look up + * @return progress of the download in percent (integer) + **/ + std::pair getProgress(int index) const; + + /** + * @brief retrieve the current state of the download + * + * retrieve the current state of the download. A download usually goes through + * the following states: + * started -> downloading -> fetching mod info -> fetching file info -> done + * in case of downloads started via nxm-link, file information is fetched first + * + * @param index index of the file to look up + * @return the download state + **/ + DownloadState getState(int index) const; + + /** + * @param index index of the file to look up + * @return true if the nexus information for this download is not complete + **/ + bool isInfoIncomplete(int index) const; + + /** + * @brief retrieve the nexus mod id of the download specified by index + * + * @param index index of the file to look up + * @return the nexus mod id + **/ + int getModID(int index) const; + + /** + * @brief retrieve the displayable game name of the download specified by the index + * + * @param index index of the file to look up + * @return the displayable game name + **/ + QString getDisplayGameName(int index) const; + + /** + * @brief retrieve the game name of the downlaod specified by the index + * + * @param index index of the file to look up + * @return the game name + **/ + QString getGameName(int index) const; + + /** + * @brief determine if the specified file is supposed to be hidden + * @param index index of the file to look up + * @return true if the specified file is supposed to be hidden + */ + bool isHidden(int index) const; + + /** + * @brief retrieve all nexus info of the download specified by index + * + * @param index index of the file to look up + * @return the nexus mod information + **/ + const MOBase::ModRepositoryFileInfo* getFileInfo(int index) const; + + /** + * @brief mark a download as installed + * + * @param index index of the file to mark installed + */ + void markInstalled(int index); + + void markInstalled(QString download); + + /** + * @brief mark a download as uninstalled + * + * @param index index of the file to mark uninstalled + */ + void markUninstalled(int index); + + void markUninstalled(QString download); + + /** + * @brief refreshes the list of downloads + */ + void refreshList(); + + /** + * @brief Query infos for every download in the list + */ + void queryDownloadListInfo(); + +public: // IDownloadManager interface: + int startDownloadURLs(const QStringList& urls); + int startDownloadNexusFile(const QString& gameName, int modID, int fileID); + QString downloadPath(int id); + + boost::signals2::connection + onDownloadComplete(const std::function& callback); + boost::signals2::connection + onDownloadPaused(const std::function& callback); + boost::signals2::connection + onDownloadFailed(const std::function& callback); + boost::signals2::connection + onDownloadRemoved(const std::function& callback); + + /** + * @brief retrieve a download index from the filename + * @param fileName file to look up + * @return index of that download or -1 if it wasn't found + */ + int indexByName(const QString& fileName) const; + int indexByInfo(const DownloadInfo* info) const; + + void pauseAll(); + +Q_SIGNALS: + + void aboutToUpdate(); + + /** + * @brief signals that the specified download has changed + * + * @param row the row that changed. This corresponds to the download index + **/ + void update(int row); + + /** + * @brief signals the ui that a message should be displayed + * + * @param message the message to display + **/ + void showMessage(const QString& message); + + /** + * @brief emitted whenever the state of a download changes + * @param row the row that changed + * @param state the new state + */ + void stateChanged(int row, DownloadManager::DownloadState state); + + /** + * @brief emitted whenever a download completes successfully, reporting the download + * speed for the server used + */ + void downloadSpeed(const QString& serverName, int bytesPerSecond); + + /** + * @brief emitted whenever a new download is added to the list + */ + void downloadAdded(); + +public slots: + + /** + * @brief removes the specified download + * + * @param index index of the download to remove + * @param deleteFile if true, the file will also be deleted from disc, otherwise it is + *only marked as hidden. + **/ + void removeDownload(int index, bool deleteFile); + + /** + * @brief restores the specified download to view (which was previously hidden + * @param index index of the download to restore + */ + void restoreDownload(int index); + + /** + * @brief cancel the specified download. This will lead to the corresponding file to + *be deleted + * + * @param index index of the download to cancel + **/ + void cancelDownload(int index); + + void pauseDownload(int index); + + void resumeDownload(int index); + + void queryInfo(int index); + + void queryInfoMd5(int index, bool askIfNotFound = true); + + void visitOnNexus(int index); + + void visitUploaderProfile(int index); + + void openFile(int index); + + void openMetaFile(int index); + + void openInDownloadsFolder(int index); + + void nxmDescriptionAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID); + + void nxmFilesAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID); + + void nxmFileInfoAvailable(QString gameName, int modID, int fileID, QVariant userData, + QVariant resultData, int requestID); + + void nxmDownloadURLsAvailable(QString gameName, int modID, int fileID, + QVariant userData, QVariant resultData, int requestID); + + void nxmFileInfoFromMd5Available(QString gameName, QVariant userData, + QVariant resultData, int requestID); + + void nxmRequestFailed(QString gameName, int modID, int fileID, QVariant userData, + int requestID, int errorCode, const QString& errorString); + + void managedGameChanged(MOBase::IPluginGame const* gamePlugin); + +private slots: + + void downloadProgress(qint64 bytesReceived, qint64 bytesTotal); + void downloadReadyRead(); + void downloadFinished(int index = 0); + void downloadError(QNetworkReply::NetworkError error); + void metaDataChanged(); + void directoryChanged(const QString& dirctory); + void checkDownloadTimeout(); + +private: + void createMetaFile(DownloadInfo* info); + DownloadManager::DownloadInfo* getDownloadInfo(QString fileName); + +public: + /** Get a unique filename for a download. + * + * This allows you multiple versions of download files, useful if the file + * comes from a web site with no version control + * + * @param basename: Name of the file + * + * @return Unique(ish) name + */ + QString getDownloadFileName(const QString& baseName, bool rename = false) const; + +private: + void startDownload(QNetworkReply* reply, DownloadInfo* newDownload, bool resume); + void resumeDownloadInt(int index); + + /** + * @brief start a download from a url + * + * @param url the url to download from + * @param fileInfo information previously retrieved from the mod page + * @return true if the download was started, false if it wasn't. The latter currently + *only happens if there is a duplicate and the user decides not to download again + **/ + bool addDownload(const QStringList& URLs, QString gameName, int modID, int fileID, + const MOBase::ModRepositoryFileInfo* fileInfo); + + // important: the caller has to lock the list-mutex, otherwise the + // DownloadInfo-pointer might get invalidated at any time + DownloadInfo* findDownload(QObject* reply, int* index = nullptr) const; + + void removeFile(int index, bool deleteFile); + + void refreshAlphabeticalTranslation(); + + bool ByName(int LHS, int RHS); + + QString getFileNameFromNetworkReply(QNetworkReply* reply); + + void setState(DownloadInfo* info, DownloadManager::DownloadState state); + + DownloadInfo* downloadInfoByID(unsigned int id); + + void removePending(QString gameName, int modID, int fileID); + + static QString getFileTypeString(int fileType); + + void writeData(DownloadInfo* info); + +private: + static const int AUTOMATIC_RETRIES = 3; + +private: + NexusInterface* m_NexusInterface; + + OrganizerCore* m_OrganizerCore; + QWidget* m_ParentWidget; + + QVector> m_PendingDownloads; + + QVector m_ActiveDownloads; + + QString m_OutputDirectory; + std::set m_RequestIDs; + QVector m_AlphabeticalTranslation; + + QFileSystemWatcher m_DirWatcher; + + SignalDownloadCallback m_DownloadComplete; + SignalDownloadCallback m_DownloadPaused; + SignalDownloadCallback m_DownloadFailed; + SignalDownloadCallback m_DownloadRemoved; + + // The dirWatcher is actually triggering off normal Mo operations such as deleting + // downloads or editing .meta files so it needs to be disabled during operations that + // are known to cause the creation or deletion of files in the Downloads folder. + // Notably using QSettings to edit a file creates a temporarily .lock file that causes + // the Watcher to trigger multiple listRefreshes freezing the ui. + static int m_DirWatcherDisabler; + + std::map m_DownloadFails; + + bool m_ShowHidden; + + MOBase::IPluginGame const* m_ManagedGame; + + QTimer m_TimeoutTimer; +}; + +class ScopedDisableDirWatcher +{ +public: + ScopedDisableDirWatcher(DownloadManager* downloadManager); + ~ScopedDisableDirWatcher(); + +private: + DownloadManager* m_downloadManager; +}; + +#endif // DOWNLOADMANAGER_H diff --git a/src/src/downloadmanagerproxy.cpp b/src/src/downloadmanagerproxy.cpp new file mode 100644 index 0000000..de32fdf --- /dev/null +++ b/src/src/downloadmanagerproxy.cpp @@ -0,0 +1,79 @@ +#include "downloadmanagerproxy.h" + +#include "organizerproxy.h" +#include "proxyutils.h" + +using namespace MOBase; +using namespace MOShared; + +DownloadManagerProxy::DownloadManagerProxy(OrganizerProxy* oproxy, + DownloadManager* downloadManager) + : m_OrganizerProxy(oproxy), m_Proxied(downloadManager) +{} + +DownloadManagerProxy::~DownloadManagerProxy() +{ + disconnectSignals(); +} + +void DownloadManagerProxy::connectSignals() +{ + m_Connections.push_back(m_Proxied->onDownloadComplete( + callSignalIfPluginActive(m_OrganizerProxy, m_DownloadComplete))); + m_Connections.push_back(m_Proxied->onDownloadFailed( + callSignalIfPluginActive(m_OrganizerProxy, m_DownloadFailed))); + m_Connections.push_back(m_Proxied->onDownloadRemoved( + callSignalIfPluginActive(m_OrganizerProxy, m_DownloadRemoved))); + m_Connections.push_back(m_Proxied->onDownloadPaused( + callSignalIfPluginActive(m_OrganizerProxy, m_DownloadPaused))); +} + +void DownloadManagerProxy::disconnectSignals() +{ + for (auto& conn : m_Connections) { + conn.disconnect(); + } + m_Connections.clear(); +} + +int DownloadManagerProxy::startDownloadURLs(const QStringList& urls) +{ + return m_Proxied->startDownloadURLs(urls); +} + +int DownloadManagerProxy::startDownloadNexusFile(int modID, int fileID) +{ + return m_Proxied->startDownloadNexusFile( + m_OrganizerProxy->managedGame()->gameNexusName(), modID, fileID); +} + +int DownloadManagerProxy::startDownloadNexusFileForGame(const QString& gameName, + int modID, int fileID) +{ + return m_Proxied->startDownloadNexusFile(gameName, modID, fileID); +} + +QString DownloadManagerProxy::downloadPath(int id) +{ + return m_Proxied->downloadPath(id); +} + +bool DownloadManagerProxy::onDownloadComplete(const std::function& callback) +{ + return m_DownloadComplete.connect(callback).connected(); +} + +bool DownloadManagerProxy::onDownloadPaused(const std::function& callback) +{ + return m_DownloadPaused.connect(callback).connected(); +} + +bool DownloadManagerProxy::onDownloadFailed(const std::function& callback) +{ + return m_DownloadFailed.connect(callback).connected(); +} + +bool DownloadManagerProxy::onDownloadRemoved(const std::function& callback) +{ + return m_DownloadRemoved.connect(callback).connected(); +} diff --git a/src/src/downloadmanagerproxy.h b/src/src/downloadmanagerproxy.h new file mode 100644 index 0000000..ef3605f --- /dev/null +++ b/src/src/downloadmanagerproxy.h @@ -0,0 +1,45 @@ +#ifndef DOWNLOADMANAGERPROXY_H +#define DOWNLOADMANAGERPROXY_H + +#include "downloadmanager.h" +#include + +class OrganizerProxy; + +class DownloadManagerProxy : public MOBase::IDownloadManager +{ + +public: + DownloadManagerProxy(OrganizerProxy* oproxy, DownloadManager* downloadManager); + virtual ~DownloadManagerProxy(); + + int startDownloadURLs(const QStringList& urls) override; + int startDownloadNexusFile(int modID, int fileID) override; + int startDownloadNexusFileForGame(const QString& gameName, int modID, + int fileID) override; + QString downloadPath(int id) override; + + bool onDownloadComplete(const std::function& callback) override; + bool onDownloadPaused(const std::function& callback) override; + bool onDownloadFailed(const std::function& callback) override; + bool onDownloadRemoved(const std::function& callback) override; + +private: + friend class OrganizerProxy; + + // See OrganizerProxy::connectSignals(). + void connectSignals(); + void disconnectSignals(); + + OrganizerProxy* m_OrganizerProxy; + DownloadManager* m_Proxied; + + DownloadManager::SignalDownloadCallback m_DownloadComplete; + DownloadManager::SignalDownloadCallback m_DownloadPaused; + DownloadManager::SignalDownloadCallback m_DownloadFailed; + DownloadManager::SignalDownloadCallback m_DownloadRemoved; + + std::vector m_Connections; +}; + +#endif // ORGANIZERPROXY_H diff --git a/src/src/downloadstab.cpp b/src/src/downloadstab.cpp new file mode 100644 index 0000000..004ff98 --- /dev/null +++ b/src/src/downloadstab.cpp @@ -0,0 +1,110 @@ +#include "downloadstab.h" +#include "downloadlist.h" +#include "downloadlistview.h" +#include "organizercore.h" +#include "ui_mainwindow.h" + +DownloadsTab::DownloadsTab(OrganizerCore& core, Ui::MainWindow* mwui) + : m_core(core), + ui{mwui->btnRefreshDownloads, mwui->btnQueryDownloadsInfo, mwui->downloadView, + mwui->showHiddenBox, mwui->downloadFilterEdit} +{ + DownloadList* sourceModel = new DownloadList(m_core, ui.list); + + ui.list->setModel(sourceModel); + ui.list->setManager(m_core.downloadManager()); + ui.list->setItemDelegate( + new DownloadProgressDelegate(m_core.downloadManager(), ui.list)); + + update(); + + m_filter.setEdit(ui.filter); + m_filter.setList(ui.list); + m_filter.setSortPredicate([sourceModel](auto&& left, auto&& right) { + return sourceModel->lessThanPredicate(left, right); + }); + + connect(ui.refresh, &QPushButton::clicked, [&] { + refresh(); + }); + connect(ui.queryInfos, &QPushButton::clicked, [&] { + queryInfos(); + }); + connect(ui.list, SIGNAL(installDownload(int)), &m_core, SLOT(installDownload(int))); + connect(ui.list, SIGNAL(queryInfo(int)), m_core.downloadManager(), + SLOT(queryInfo(int))); + connect(ui.list, SIGNAL(queryInfoMd5(int)), m_core.downloadManager(), + SLOT(queryInfoMd5(int))); + connect(ui.list, SIGNAL(visitOnNexus(int)), m_core.downloadManager(), + SLOT(visitOnNexus(int))); + connect(ui.list, SIGNAL(visitUploaderProfile(int)), m_core.downloadManager(), + SLOT(visitUploaderProfile(int))); + connect(ui.list, SIGNAL(openFile(int)), m_core.downloadManager(), + SLOT(openFile(int))); + connect(ui.list, SIGNAL(openMetaFile(int)), m_core.downloadManager(), + SLOT(openMetaFile(int))); + connect(ui.list, SIGNAL(openInDownloadsFolder(int)), m_core.downloadManager(), + SLOT(openInDownloadsFolder(int))); + connect(ui.list, SIGNAL(removeDownload(int, bool)), m_core.downloadManager(), + SLOT(removeDownload(int, bool))); + connect(ui.list, SIGNAL(restoreDownload(int)), m_core.downloadManager(), + SLOT(restoreDownload(int))); + connect(ui.list, SIGNAL(cancelDownload(int)), m_core.downloadManager(), + SLOT(cancelDownload(int))); + connect(ui.list, SIGNAL(pauseDownload(int)), m_core.downloadManager(), + SLOT(pauseDownload(int))); + connect(ui.list, &DownloadListView::resumeDownload, [&](int i) { + resumeDownload(i); + }); +} + +void DownloadsTab::update() +{ + // this means downloadTab initialization hasn't happened yet + if (ui.list->model() == nullptr) { + return; + } + + // set the view attribute and default row sizes + if (m_core.settings().interface().compactDownloads()) { + ui.list->setProperty("downloadView", "compact"); + ui.list->setStyleSheet("DownloadListView::item { padding: 4px 2px; }"); + } else { + ui.list->setProperty("downloadView", "standard"); + ui.list->setStyleSheet("DownloadListView::item { padding: 16px 4px; }"); + } + + ui.list->style()->unpolish(ui.list); + ui.list->style()->polish(ui.list); + qobject_cast(ui.list->header())->customResizeSections(); + + m_core.downloadManager()->refreshList(); +} + +void DownloadsTab::refresh() +{ + m_core.downloadManager()->refreshList(); +} + +void DownloadsTab::queryInfos() +{ + if (m_core.settings().network().offlineMode()) { + if (QMessageBox::warning(nullptr, tr("Query Metadata"), + tr("Cannot query metadata while offline mode is enabled. " + "Do you want to disable offline mode?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_core.settings().network().setOfflineMode(false); + } else { + return; + } + } + + m_core.downloadManager()->queryDownloadListInfo(); +} + +void DownloadsTab::resumeDownload(int downloadIndex) +{ + m_core.loggedInAction(ui.list, [this, downloadIndex] { + m_core.downloadManager()->resumeDownload(downloadIndex); + }); +} diff --git a/src/src/downloadstab.h b/src/src/downloadstab.h new file mode 100644 index 0000000..a16f979 --- /dev/null +++ b/src/src/downloadstab.h @@ -0,0 +1,46 @@ +#ifndef MODORGANIZER_DOWNLOADTAB_INCLUDED +#define MODORGANIZER_DOWNLOADTAB_INCLUDED + +#include + +namespace Ui +{ +class MainWindow; +} +class OrganizerCore; +class DownloadListView; + +class DownloadsTab : public QObject +{ + Q_OBJECT; + +public: + DownloadsTab(OrganizerCore& core, Ui::MainWindow* ui); + + void update(); + +private: + struct DownloadsTabUi + { + QPushButton* refresh; + QPushButton* queryInfos; + DownloadListView* list; + QCheckBox* showHidden; + QLineEdit* filter; + }; + + OrganizerCore& m_core; + DownloadsTabUi ui; + MOBase::FilterWidget m_filter; + + void refresh(); + + /** + * @brief Handle click on the "Query infos" button + **/ + void queryInfos(); + + void resumeDownload(int downloadIndex); +}; + +#endif // MODORGANIZER_DOWNLOADTAB_INCLUDED diff --git a/src/src/editexecutablesdialog.cpp b/src/src/editexecutablesdialog.cpp new file mode 100644 index 0000000..e5dcda9 --- /dev/null +++ b/src/src/editexecutablesdialog.cpp @@ -0,0 +1,905 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "editexecutablesdialog.h" +#include "filedialogmemory.h" +#include "forcedloaddialog.h" +#include "modlist.h" +#include "organizercore.h" +#include "spawn.h" +#include "ui_editexecutablesdialog.h" + +#include +#ifdef _WIN32 +#include +#endif +#include +#include + +using namespace MOBase; +using namespace MOShared; + +class IgnoreChanges +{ +public: + IgnoreChanges(EditExecutablesDialog* d) : m_dialog(d) + { + m_dialog->m_settingUI = true; + } + + ~IgnoreChanges() { m_dialog->m_settingUI = false; } + + IgnoreChanges(const IgnoreChanges&) = delete; + IgnoreChanges& operator=(const IgnoreChanges&) = delete; + +private: + EditExecutablesDialog* m_dialog; +}; + +EditExecutablesDialog::EditExecutablesDialog(OrganizerCore& oc, int sel, + QWidget* parent) + : TutorableDialog("EditExecutables", parent), ui(new Ui::EditExecutablesDialog), + m_organizerCore(oc), m_originalExecutables(*oc.executablesList()), + m_executablesList(*oc.executablesList()), m_settingUI(false) +{ + ui->setupUi(this); + ui->splitter->setSizes({200, 1}); + ui->splitter->setStretchFactor(0, 0); + ui->splitter->setStretchFactor(1, 1); + + loadCustomOverwrites(); + loadForcedLibraries(); + + QStringList modNames; + + for (auto&& m : m_organizerCore.modList()->allMods()) { + auto mod = ModInfo::getByName(m); + if (!mod->hasAnyOfTheseFlags({ModInfo::FLAG_FOREIGN, ModInfo::FLAG_BACKUP, + ModInfo::FLAG_OVERWRITE, ModInfo::FLAG_SEPARATOR})) { + ui->mods->addItem(m); + modNames.push_back(m); + } + } + + auto* c = new QCompleter(modNames); + c->setCaseSensitivity(Qt::CaseInsensitive); + c->setCompletionMode(QCompleter::UnfilteredPopupCompletion); + ui->mods->setCompleter(c); + + fillList(); + setDirty(false); + + if (sel >= 0 && sel < ui->list->count()) { + selectIndex(sel); + } + + auto* m = new QMenu; + m->addAction(tr("Add from file..."), [&] { + addFromFile(); + }); + m->addAction(tr("Add empty"), [&] { + addEmpty(); + }); + m->addAction(tr("Clone selected"), [&] { + clone(); + }); + ui->add->setMenu(m); + + // some widgets need to do more than just save() and have their own handler + connect(ui->binary, &QLineEdit::textChanged, [&] { + save(); + }); + connect(ui->workingDirectory, &QLineEdit::textChanged, [&] { + save(); + }); + connect(ui->arguments, &QLineEdit::textChanged, [&] { + save(); + }); + connect(ui->steamAppID, &QLineEdit::textChanged, [&] { + save(); + }); + connect(ui->mods, &QComboBox::currentTextChanged, [&] { + save(); + }); + connect(ui->useApplicationIcon, &QCheckBox::toggled, [&] { + save(); + }); + connect(ui->minimizeToSystemTray, &QCheckBox::toggled, [&] { + save(); + }); + connect(ui->hide, &QCheckBox::toggled, [&] { + save(); + }); + connect(ui->list->model(), &QAbstractItemModel::rowsMoved, [&] { + saveOrder(); + }); +} + +EditExecutablesDialog::~EditExecutablesDialog() = default; + +int EditExecutablesDialog::exec() +{ + GeometrySaver gs(Settings::instance(), this); + return QDialog::exec(); +} + +void EditExecutablesDialog::loadCustomOverwrites() +{ + const auto p = m_organizerCore.currentProfile(); + + for (const auto& e : m_executablesList) { + const auto s = p->setting("custom_overwrites", e.title()).toString(); + + if (!s.isEmpty()) { + m_customOverwrites.set(e.title(), true, s); + } + } +} + +void EditExecutablesDialog::loadForcedLibraries() +{ + const auto p = m_organizerCore.currentProfile(); + + for (const auto& e : m_executablesList) { + m_forcedLibraries.set(e.title(), p->forcedLibrariesEnabled(e.title()), + p->determineForcedLibraries(e.title())); + } +} + +ExecutablesList EditExecutablesDialog::getExecutablesList() const +{ + ExecutablesList newList; + + // make sure the executables are in the same order as in the list + for (int i = 0; i < ui->list->count(); ++i) { + const auto& title = ui->list->item(i)->text(); + + auto itor = m_executablesList.find(title); + + if (itor == m_executablesList.end()) { + log::warn("getExecutablesList(): executable '{}' not found", title); + continue; + } + + newList.setExecutable(*itor); + } + + return newList; +} + +const EditExecutablesDialog::CustomOverwrites& +EditExecutablesDialog::getCustomOverwrites() const +{ + return m_customOverwrites; +} + +const EditExecutablesDialog::ForcedLibraries& +EditExecutablesDialog::getForcedLibraries() const +{ + return m_forcedLibraries; +} + +bool EditExecutablesDialog::checkOutputMods(const ExecutablesList& exes) +{ + // make sure the output mods for exes exist since the combobox is editable + // + // it'd be convenient for users to automatically create mods here if they're + // not found, but this is a can of worms: it would require a refresh + // because getIndex() still won't find it after calling + // OrganizerCore::createMod(), which is a problem if the user just clicked + // Apply and continued doing things + // + // triggering a refresh while this dialog is up doesn't sound like a very + // smart thing to do for now, so this just shows an error + + for (const auto& e : exes) { + auto modName = m_customOverwrites.find(e.title()); + + if (modName && modName->enabled) { + if (modName->value.isEmpty()) { + QMessageBox::critical(this, tr("Empty output mod"), + tr("The output mod for %2 is empty.").arg(e.title())); + + return false; + } else if (ModInfo::getIndex(modName->value) == UINT_MAX) { + QMessageBox::critical(this, tr("Output mod not found"), + tr("The output mod '%1' for %2 does not exist.") + .arg(modName->value) + .arg(e.title())); + + return false; + } + } + } + + return true; +} + +bool EditExecutablesDialog::commitChanges() +{ + const auto newExecutables = getExecutablesList(); + + if (!checkOutputMods(newExecutables)) { + return false; + } + + auto profile = m_organizerCore.currentProfile(); + + // remove all the custom overwrites and forced libraries + for (const auto& e : m_originalExecutables) { + profile->removeSetting("custom_overwrites", e.title()); + profile->removeForcedLibraries(e.title()); + } + + // set the new custom overwrites and forced libraries + for (const auto& e : newExecutables) { + if (auto modName = m_customOverwrites.find(e.title())) { + if (modName && modName->enabled) { + profile->storeSetting("custom_overwrites", e.title(), modName->value); + } + } + + if (auto libraryList = m_forcedLibraries.find(e.title())) { + if (libraryList && !libraryList->value.empty()) { + profile->setForcedLibrariesEnabled(e.title(), libraryList->enabled); + profile->storeForcedLibraries(e.title(), libraryList->value); + } + } + } + + // set the new executables list + m_organizerCore.setExecutablesList(newExecutables); + + setDirty(false); + + return true; +} + +void EditExecutablesDialog::setDirty(bool b) +{ + if (auto* button = ui->buttons->button(QDialogButtonBox::Apply)) { + button->setEnabled(b); + } +} + +void EditExecutablesDialog::selectIndex(int i) +{ + if (i >= 0 && i < ui->list->count()) { + ui->list->selectionModel()->setCurrentIndex(ui->list->model()->index(i, 0), + QItemSelectionModel::ClearAndSelect); + } +} + +QListWidgetItem* EditExecutablesDialog::selectedItem() +{ + const auto selection = ui->list->selectedItems(); + + if (selection.empty()) { + return nullptr; + } + + return selection[0]; +} + +Executable* EditExecutablesDialog::selectedExe() +{ + auto* item = selectedItem(); + if (!item) { + return nullptr; + } + + const auto& title = item->text(); + auto itor = m_executablesList.find(title); + + if (itor == m_executablesList.end()) { + return nullptr; + } + + return &*itor; +} + +void EditExecutablesDialog::fillList() +{ + ui->list->clear(); + + for (const auto& exe : m_executablesList) { + ui->list->addItem(createListItem(exe)); + } + + // select the first one in the list, if any + if (ui->list->count() > 0) { + selectIndex(0); + } else { + updateUI(nullptr, nullptr); + } +} + +QListWidgetItem* EditExecutablesDialog::createListItem(const Executable& exe) +{ + return new QListWidgetItem(exe.title()); +} + +void EditExecutablesDialog::updateUI(const QListWidgetItem* item, const Executable* e) +{ + // the ui is currently being set, ignore changes + IgnoreChanges c(this); + + if (e) { + setEdits(*e); + } else { + clearEdits(); + } + + setButtons(item, e); +} + +void EditExecutablesDialog::setButtons(const QListWidgetItem* item, const Executable* e) +{ + // add and remove are always enabled + + if (item) { + ui->up->setEnabled(canMove(item, -1)); + ui->down->setEnabled(canMove(item, +1)); + } else { + ui->up->setEnabled(false); + ui->down->setEnabled(false); + } +} + +void EditExecutablesDialog::clearEdits() +{ + ui->title->clear(); + ui->title->setEnabled(false); + ui->binary->clear(); + ui->binary->setEnabled(false); + ui->browseBinary->setEnabled(false); + ui->workingDirectory->clear(); + ui->workingDirectory->setEnabled(false); + ui->browseWorkingDirectory->setEnabled(false); + ui->arguments->clear(); + ui->arguments->setEnabled(false); + ui->overwriteSteamAppID->setEnabled(false); + ui->overwriteSteamAppID->setChecked(false); + ui->steamAppID->setEnabled(false); + ui->steamAppID->clear(); + ui->createFilesInMod->setEnabled(false); + ui->createFilesInMod->setChecked(false); + ui->mods->setEnabled(false); + ui->mods->setCurrentIndex(-1); + ui->forceLoadLibraries->setEnabled(false); + ui->forceLoadLibraries->setChecked(false); + ui->configureLibraries->setEnabled(false); + ui->useApplicationIcon->setEnabled(false); + ui->useApplicationIcon->setChecked(false); + ui->minimizeToSystemTray->setEnabled(false); + ui->minimizeToSystemTray->setChecked(false); + ui->hide->setEnabled(false); + ui->hide->setChecked(false); + + m_lastGoodTitle = ""; +} + +void EditExecutablesDialog::setEdits(const Executable& e) +{ + ui->title->setText(e.title()); + ui->binary->setText(QDir::toNativeSeparators(e.binaryInfo().filePath())); + ui->workingDirectory->setText(QDir::toNativeSeparators(e.workingDirectory())); + ui->arguments->setText(e.arguments()); + ui->overwriteSteamAppID->setChecked(!e.steamAppID().isEmpty()); + ui->steamAppID->setEnabled(!e.steamAppID().isEmpty()); + ui->steamAppID->setText(e.steamAppID()); + ui->useApplicationIcon->setChecked(e.usesOwnIcon()); + ui->minimizeToSystemTray->setChecked(e.minimizeToSystemTray()); + ui->hide->setChecked(e.hide()); + + m_lastGoodTitle = e.title(); + + { + int modIndex = -1; + + const auto modName = m_customOverwrites.find(e.title()); + + if (modName && !modName->value.isEmpty()) { + modIndex = ui->mods->findText(modName->value); + + if (modIndex == -1) { + log::warn("executable '{}' uses mod '{}' as a custom overwrite, but that mod " + "doesn't exist", + e.title(), modName->value); + } + } + + const bool hasCustomOverwrites = (modName && modName->enabled); + + ui->createFilesInMod->setChecked(hasCustomOverwrites); + ui->mods->setEnabled(hasCustomOverwrites); + ui->mods->setCurrentIndex(modIndex); + } + + { + const auto libraryList = m_forcedLibraries.find(e.title()); + const bool hasForcedLibraries = (libraryList && libraryList->enabled); + + ui->forceLoadLibraries->setChecked(hasForcedLibraries); + ui->configureLibraries->setEnabled(hasForcedLibraries); + } + + // always enabled + ui->title->setEnabled(true); + ui->binary->setEnabled(true); + ui->browseBinary->setEnabled(true); + ui->workingDirectory->setEnabled(true); + ui->browseWorkingDirectory->setEnabled(true); + ui->arguments->setEnabled(true); + ui->overwriteSteamAppID->setEnabled(true); + ui->useApplicationIcon->setEnabled(true); + ui->createFilesInMod->setEnabled(true); + ui->forceLoadLibraries->setEnabled(true); + ui->minimizeToSystemTray->setEnabled(true); + ui->hide->setEnabled(true); +} + +void EditExecutablesDialog::save() +{ + if (m_settingUI) { + return; + } + + auto* e = selectedExe(); + if (!e) { + log::warn("trying to save but nothing is selected"); + return; + } + + // title may have changed, start with the stuff using it + + // custom overwrites + if (ui->createFilesInMod->isChecked()) { + m_customOverwrites.set(e->title(), true, ui->mods->currentText()); + } else { + m_customOverwrites.setEnabled(e->title(), false); + } + + // forced libraries + m_forcedLibraries.setEnabled(e->title(), ui->forceLoadLibraries->isChecked()); + + // get the new title, but ignore it if it's conflicting with an already + // existing executable + QString newTitle = ui->title->text().trimmed(); + if (isTitleConflicting(newTitle)) { + newTitle = e->title(); + } + + if (e->title() != newTitle) { + // now rename both the custom overwrites and forced libraries if the title + // is being changed + m_customOverwrites.rename(e->title(), newTitle); + m_forcedLibraries.rename(e->title(), newTitle); + + // save the new title + e->title(newTitle); + } + + e->binaryInfo(QFileInfo(ui->binary->text())); + e->workingDirectory(ui->workingDirectory->text()); + e->arguments(ui->arguments->text()); + + if (ui->overwriteSteamAppID->isChecked()) { + e->steamAppID(ui->steamAppID->text()); + } else { + e->steamAppID(""); + } + + if (ui->useApplicationIcon->isChecked()) { + e->flags(e->flags() | Executable::UseApplicationIcon); + } else { + e->flags(e->flags() & (~Executable::UseApplicationIcon)); + } + + if (ui->minimizeToSystemTray->isChecked()) { + e->flags(e->flags() | Executable::MinimizeToSystemTray); + } else { + e->flags(e->flags() & (~Executable::MinimizeToSystemTray)); + } + + if (ui->hide->isChecked()) { + e->flags(e->flags() | Executable::Hide); + } else { + e->flags(e->flags() & (~Executable::Hide)); + } + + setDirty(true); +} + +void EditExecutablesDialog::saveOrder() +{ + m_executablesList = getExecutablesList(); + setDirty(true); +} + +bool EditExecutablesDialog::canMove(const QListWidgetItem* item, int direction) +{ + if (!item) { + return false; + } + + if (direction < 0) { + // moving up + return (ui->list->row(item) > 0); + + } else if (direction > 0) { + // moving down + return (ui->list->row(item) < (ui->list->count() - 1)); + } + + return false; +} + +void EditExecutablesDialog::move(QListWidgetItem* item, int direction) +{ + if (!canMove(item, direction)) { + return; + } + + const auto oldRow = ui->list->row(item); + const auto newRow = oldRow + (direction > 0 ? 1 : -1); + + // removing item + ui->list->takeItem(oldRow); + ui->list->insertItem(newRow, item); + + selectIndex(newRow); + setDirty(true); +} + +void EditExecutablesDialog::on_list_itemSelectionChanged() +{ + updateUI(selectedItem(), selectedExe()); +} + +void EditExecutablesDialog::on_reset_clicked() +{ + const auto title = tr("Reset plugin executables"); + + const auto text = + tr("This will restore all the executables provided by the game plugin. If " + "there are existing executables with the same names, they will be " + "automatically renamed and left unchanged."); + + const auto buttons = QMessageBox::Ok | QMessageBox::Cancel; + + if (QMessageBox::question(this, title, text, buttons) != QMessageBox::Ok) { + return; + } + + m_executablesList.resetFromPlugin(m_organizerCore.managedGame()); + fillList(); + + setDirty(true); +} + +void EditExecutablesDialog::on_add_clicked() +{ + addFromFile(); +} + +void EditExecutablesDialog::on_remove_clicked() +{ + auto* item = selectedItem(); + if (!item) { + log::warn("trying to remove entry but nothing is selected"); + return; + } + + auto* exe = selectedExe(); + if (!exe) { + log::warn("trying to remove entry but nothing is selected"); + return; + } + + const int currentRow = ui->list->row(item); + delete item; + + m_customOverwrites.remove(exe->title()); + m_forcedLibraries.remove(exe->title()); + + // removing from main list, must be done last because it invalidates the + // exe pointer + m_executablesList.remove(exe->title()); + + // reselecting the same row as before, or the last one + if (currentRow >= ui->list->count()) { + // that was the last item, select the new list item, if any + if (ui->list->count() > 0) { + selectIndex(ui->list->count() - 1); + } + } else { + selectIndex(currentRow); + } + + setDirty(true); +} + +void EditExecutablesDialog::on_up_clicked() +{ + auto* item = selectedItem(); + if (!item) { + return; + } + + move(item, -1); +} + +void EditExecutablesDialog::on_down_clicked() +{ + auto* item = selectedItem(); + if (!item) { + return; + } + + move(item, +1); +} + +bool EditExecutablesDialog::isTitleConflicting(const QString& s) +{ + for (const auto& exe : m_executablesList) { + if (exe.title() == s) { + if (&exe != selectedExe()) { + // found an executable that's not the current one with the same title + return true; + } + } + } + + return false; +} + +void EditExecutablesDialog::on_title_textChanged(const QString& original) +{ + if (m_settingUI) { + return; + } + + auto s = original.trimmed(); + + // disallow empty names + if (s.isEmpty()) { + return; + } + + // disallow changing the title to something that already exists + if (isTitleConflicting(s)) { + return; + } + + m_lastGoodTitle = s; + + // must save before modifying the item in the list widget because saving + // relies on the item's text being the same as an item in m_executablesList + save(); + + // once the executable is saved, the list item must be changed to match the + // new name + if (auto* i = selectedItem()) { + i->setText(s); + } +} + +void EditExecutablesDialog::on_title_editingFinished() +{ + ui->title->setText(m_lastGoodTitle); +} + +void EditExecutablesDialog::on_overwriteSteamAppID_toggled(bool checked) +{ + if (m_settingUI) { + return; + } + + ui->steamAppID->setEnabled(checked); + save(); +} + +void EditExecutablesDialog::on_createFilesInMod_toggled(bool checked) +{ + if (m_settingUI) { + return; + } + + ui->mods->setEnabled(checked); + save(); + + if (checked) { + ui->mods->lineEdit()->selectAll(); + ui->mods->setFocus(); + } +} + +void EditExecutablesDialog::on_forceLoadLibraries_toggled(bool checked) +{ + if (m_settingUI) { + return; + } + + ui->configureLibraries->setEnabled(ui->forceLoadLibraries->isChecked()); + save(); +} + +void EditExecutablesDialog::on_browseBinary_clicked() +{ + const auto binaryName = browseBinary(ui->binary->text()); + if (binaryName.fileName().isEmpty()) { + return; + } + + setBinary(binaryName); + save(); +} + +void EditExecutablesDialog::addFromFile() +{ + const auto binary = browseBinary(ui->binary->text()); + if (binary.fileName().isEmpty()) { + return; + } + + addNew(Executable(binary.completeBaseName())); + setBinary(binary); +} + +void EditExecutablesDialog::addEmpty() +{ + addNew(Executable(tr("New Executable"))); +} + +void EditExecutablesDialog::clone() +{ + auto* e = selectedExe(); + if (!e) { + return; + } + + addNew(*e); +} + +void EditExecutablesDialog::addNew(Executable e) +{ + const auto fixedTitle = m_executablesList.makeNonConflictingTitle(e.title()); + if (!fixedTitle) { + return; + } + + e.title(*fixedTitle); + + m_executablesList.setExecutable(e); + + auto* item = createListItem(e); + ui->list->addItem(item); + + selectIndex(ui->list->count() - 1); + setDirty(true); +} + +void EditExecutablesDialog::setBinary(const QFileInfo& binary) +{ + // setting binary + if (binary.suffix().compare("jar", Qt::CaseInsensitive) == 0) { + // special case for jar files, uses the system java installation + setJarBinary(binary); + } else { + ui->binary->setText(QDir::toNativeSeparators(binary.absoluteFilePath())); + } + + // setting title if some variation of "New Executable" + if (ui->title->text().startsWith(tr("New Executable"), Qt::CaseInsensitive)) { + const auto prefix = binary.completeBaseName(); + const auto newTitle = m_executablesList.makeNonConflictingTitle(prefix); + + if (newTitle) { + ui->title->setText(*newTitle); + } + } +} + +void EditExecutablesDialog::on_browseWorkingDirectory_clicked() +{ + QString dirName = FileDialogMemory::getExistingDirectory( + "editExecutableDirectory", this, tr("Select a directory"), + ui->workingDirectory->text()); + + if (dirName.isNull()) { + // cancelled + return; + } + + ui->workingDirectory->setText(dirName); +} + +void EditExecutablesDialog::on_configureLibraries_clicked() +{ + auto* e = selectedExe(); + if (!e) { + log::warn("trying to configure libraries but nothing is selected"); + return; + } + + ForcedLoadDialog dialog(m_organizerCore.managedGame(), this); + + if (auto libraryList = m_forcedLibraries.find(e->title())) { + dialog.setValues(libraryList->value); + } + + if (dialog.exec() == QDialog::Accepted) { + m_forcedLibraries.setValue(e->title(), dialog.values()); + save(); + } +} + +void EditExecutablesDialog::on_buttons_clicked(QAbstractButton* b) +{ + if (b == ui->buttons->button(QDialogButtonBox::Ok)) { + if (commitChanges()) { + accept(); + } + } else if (b == ui->buttons->button(QDialogButtonBox::Apply)) { + commitChanges(); + } else { + reject(); + } +} + +QFileInfo EditExecutablesDialog::browseBinary(const QString& initial) +{ + const QString Filters = + tr("Executables (*.exe *.bat *.jar)") + ";;" + tr("All Files (*.*)"); + + const auto f = FileDialogMemory::getOpenFileName( + "editExecutableBinary", this, tr("Select an executable"), initial, Filters); + + if (f.isNull()) { + return {}; + } + + return QFileInfo(f); +} + +void EditExecutablesDialog::setJarBinary(const QFileInfo& binary) +{ + auto java = spawn::findJavaInstallation(binary.absoluteFilePath()); + + if (java.isEmpty()) { + QMessageBox::information( + this, tr("Java required"), + tr("MO requires Java to run this application. If you already " + "have it installed, select javaw.exe from that installation as " + "the binary.")); + } + + { + // only save once + IgnoreChanges c(this); + + ui->binary->setText(java); + ui->workingDirectory->setText(QDir::toNativeSeparators(binary.absolutePath())); + ui->arguments->setText("-jar \"" + + QDir::toNativeSeparators(binary.absoluteFilePath()) + "\""); + } + + save(); +} diff --git a/src/src/editexecutablesdialog.h b/src/src/editexecutablesdialog.h new file mode 100644 index 0000000..4430d6c --- /dev/null +++ b/src/src/editexecutablesdialog.h @@ -0,0 +1,239 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef EDITEXECUTABLESDIALOG_H +#define EDITEXECUTABLESDIALOG_H + +#include "executableslist.h" +#include "iplugingame.h" +#include "profile.h" +#include "tutorabledialog.h" +#include +#include +#include +#include + +namespace Ui +{ +class EditExecutablesDialog; +} + +class ModList; +class OrganizerCore; + +/** helper class to manage custom overwrites within the edit executables + * dialog, stores a T and a bool in map indexed by a QString + **/ +template +class ToggableMap +{ +public: + struct Value + { + bool enabled; + T value; + + Value(bool b, T&& v) : enabled(b), value(std::forward(v)) {} + }; + + /** + * returns the Value associated with the given title, or empty + **/ + std::optional find(const QString& title) const + { + auto itor = m_map.find(title); + if (itor == m_map.end()) { + return {}; + } + + return itor->second; + } + + /** + * sets the given value, adds it if not found + **/ + void set(QString title, bool b, T value) + { + m_map.insert_or_assign(std::move(title), Value(b, std::move(value))); + } + + /** + * sets whether the given value is enabled, inserts it if not found + **/ + void setEnabled(const QString& title, bool b) + { + auto itor = m_map.find(title); + + if (itor == m_map.end()) { + m_map.emplace(title, Value(b, {})); + } else { + itor->second.enabled = b; + } + } + + /** + * sets the given value, inserts it enabled if not found + **/ + void setValue(const QString& title, T value) + { + auto itor = m_map.find(title); + + if (itor == m_map.end()) { + m_map.emplace(title, Value(true, std::move(value))); + } else { + itor->second.value = std::move(value); + } + } + + /** + * renames the given value, ignored if not found + **/ + void rename(const QString& oldTitle, QString newTitle) + { + auto itor = m_map.find(oldTitle); + if (itor == m_map.end()) { + return; + } + + // move to new title, erase old + m_map.emplace(std::move(newTitle), std::move(itor->second)); + m_map.erase(itor); + } + + /** + * removes the given value, ignored if not found + **/ + void remove(const QString& title) + { + auto itor = m_map.find(title); + if (itor == m_map.end()) { + return; + } + + m_map.erase(itor); + } + +private: + std::map m_map; +}; + +/** + * @brief Dialog to manage the list of executables + **/ +class EditExecutablesDialog : public MOBase::TutorableDialog +{ + Q_OBJECT; + friend class IgnoreChanges; + +public: + using CustomOverwrites = ToggableMap; + using ForcedLibraries = ToggableMap>; + + explicit EditExecutablesDialog(OrganizerCore& oc, int selection = -1, + QWidget* parent = nullptr); + + ~EditExecutablesDialog(); + + // also saves and restores geometry + // + int exec() override; + + ExecutablesList getExecutablesList() const; + const CustomOverwrites& getCustomOverwrites() const; + const ForcedLibraries& getForcedLibraries() const; + +private slots: + void on_list_itemSelectionChanged(); + + void on_reset_clicked(); + void on_add_clicked(); + void on_remove_clicked(); + void on_up_clicked(); + void on_down_clicked(); + + void on_title_textChanged(const QString& s); + void on_title_editingFinished(); + void on_overwriteSteamAppID_toggled(bool checked); + void on_createFilesInMod_toggled(bool checked); + void on_forceLoadLibraries_toggled(bool checked); + + void on_browseBinary_clicked(); + void on_browseWorkingDirectory_clicked(); + void on_configureLibraries_clicked(); + + void on_buttons_clicked(QAbstractButton* b); + +private: + std::unique_ptr ui; + OrganizerCore& m_organizerCore; + + // copy of the original executables, used to clear the current settings when + // committing changes + const ExecutablesList m_originalExecutables; + + // current executable list + ExecutablesList m_executablesList; + + // custom overwrites set in the dialog + CustomOverwrites m_customOverwrites; + + // forced libraries set in the dialog + ForcedLibraries m_forcedLibraries; + + // remembers the last executable title that made sense, reverts to this when + // the widget loses focus if it's empty + QString m_lastGoodTitle; + + // true when the change events being triggered are in response to loading + // the executable's data into the UI, not from a user change + bool m_settingUI; + + void loadCustomOverwrites(); + void loadForcedLibraries(); + + QListWidgetItem* selectedItem(); + Executable* selectedExe(); + + void fillList(); + QListWidgetItem* createListItem(const Executable& exe); + void updateUI(const QListWidgetItem* item, const Executable* e); + void clearEdits(); + void setEdits(const Executable& e); + void setButtons(const QListWidgetItem* item, const Executable* e); + void save(); + void saveOrder(); + bool canMove(const QListWidgetItem* item, int direction); + void move(QListWidgetItem* item, int direction); + bool isTitleConflicting(const QString& s); + bool commitChanges(); + void setDirty(bool b); + void selectIndex(int i); + bool checkOutputMods(const ExecutablesList& exes); + + void addFromFile(); + void addEmpty(); + void clone(); + void addNew(Executable e); + + QFileInfo browseBinary(const QString& initial); + void setBinary(const QFileInfo& binary); + void setJarBinary(const QFileInfo& binary); +}; + +#endif // EDITEXECUTABLESDIALOG_H diff --git a/src/src/editexecutablesdialog.ui b/src/src/editexecutablesdialog.ui new file mode 100644 index 0000000..005f3a1 --- /dev/null +++ b/src/src/editexecutablesdialog.ui @@ -0,0 +1,534 @@ + + + EditExecutablesDialog + + + + 0 + 0 + 710 + 440 + + + + Modify Executables + + + + + + + 0 + 100 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + 9 + + + false + + + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + + + Executables + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Add an executable + + + Add an executable + + + Add an executable + + + Add + + + + :/MO/gui/add:/MO/gui/add + + + QToolButton::InstantPopup + + + + + + + Remove the selected executable + + + Remove the selected executable + + + Remove the selected executable + + + Remove + + + + :/MO/gui/resources/list-remove.png:/MO/gui/resources/list-remove.png + + + + + + + Move the executable up in the list + + + Move the executable up in the list + + + Move the executable up in the list + + + Up + + + + :/MO/gui/resources/go-up.png:/MO/gui/resources/go-up.png + + + + + + + Move the executable down in the list + + + Move the executable down in the list + + + Move the executable down in the list + + + Down + + + + :/MO/gui/resources/go-down.png:/MO/gui/resources/go-down.png + + + + + + + Adds the executables provided by the game plugin and moves any existing executables out of the way + + + Adds the executables provided by the game plugin and moves any existing executables out of the way + + + Adds the executables provided by the game plugin and moves any existing executables out of the way + + + Reset + + + + :/MO/gui/edit_clear:/MO/gui/edit_clear + + + + + + + + + List of configured executables + + + This is a list of your configured executables. Executables in grey are automatically recognised and can not be modified. + + + QAbstractItemView::InternalMove + + + Qt::TargetMoveAction + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 10 + + + + + 20 + + + + + Title + + + + + + + Name of the executable. This is only for display purposes. + + + Name of the executable. This is only for display purposes. + + + + + + + Binary + + + + + + + + + Binary to run + + + Binary to run + + + + + + + Browse filesystem + + + Browse filesystem for the executable to run. + + + ... + + + + + + + + + Start in + + + + + + + + + + + + ... + + + + + + + + + Arguments + + + + + + + Arguments to pass to the application + + + Arguments to pass to the application + + + + + + + + + + + Allow the Steam AppID to be used for this executable to be changed. + + + Allow the Steam AppID to be used for this executable to be changed. +Every game/tool distributed through Steam has a unique ID. MO needs to know this ID to start those programs directly, otherwise the program is started by steam and then MO will not work. By default, MO will use the AppID for the game. +Right now the only case I know of where this needs to be overwritten is for the Skyrim Creation Kit which has its own AppID. This overwrite is already preconfigured. + + + Overwrite Steam AppID + + + + + + + false + + + Steam AppID to use for this executable that differs from the games AppID. + + + Steam AppID to use for this executable that differs from the games AppID. +Every game/tool distributed through Steam has a unique ID. MO needs to know this ID to start those programs directly, otherwise the program is started by steam and then MO will not work. By default, MO will use the AppID for the game (usually 72850). +Right now the only case I know of where this needs to be overwritten is for the Skyrim Creation Kit which has its own AppID (usually 202480). This overwrite is already preconfigured. + + + + + + + + + + + If this is enabled, new files are created in the specified mod instead of the "Overwrite" mod. + + + Create files in mod instead of overwrite (*) + + + + + + + false + + + true + + + + + + + + + + + If this is enabled, the configured libraries will be automatically loaded when this executable is launched. + + + Force load libraries (*) + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + false + + + Configure Libraries + + + + + + + + + Use application's icon for desktop shortcuts + + + + + + + Mod Organizer will minimize to the system tray while this executable is running. It will reappear after it finishes. + + + Mod Organizer will minimize to the system tray while this executable is running. It will reappear after it finishes. + + + Minimize to system tray while running + + + + + + + This executable will not appear in the list, on the toolbar or in the menu. It will still be visible in this dialog. + + + This executable will not appear in the list, on the toolbar or in the menu. It will still be visible in this dialog. + + + Hide in user interface + + + + + + + (*) Profile specific + + + 5 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + QDialogButtonBox::Apply|QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + add + remove + up + down + reset + list + title + binary + browseBinary + workingDirectory + browseWorkingDirectory + arguments + overwriteSteamAppID + steamAppID + createFilesInMod + mods + forceLoadLibraries + configureLibraries + useApplicationIcon + + + + + + diff --git a/src/src/env.cpp b/src/src/env.cpp new file mode 100644 index 0000000..8674f39 --- /dev/null +++ b/src/src/env.cpp @@ -0,0 +1,1557 @@ +#include "env.h" +#include "envdump.h" +#include "envmetrics.h" +#include "envmodule.h" +#include "envsecurity.h" +#include "envshortcut.h" +#include "envwindows.h" +#include "settings.h" +#include "shared/util.h" +#include +#include + +#ifndef _WIN32 +#include +#endif + +namespace env +{ + +using namespace MOBase; + +#ifdef _WIN32 + +Console::Console() : m_hasConsole(false), m_in(nullptr), m_out(nullptr), m_err(nullptr) +{ + // try to attach to parent + if (!AttachConsole(ATTACH_PARENT_PROCESS)) { + if (GetLastError() != ERROR_ACCESS_DENIED) { + // parent has no console, create one + if (!AllocConsole()) { + // failed, ignore + return; + } + } + } + + m_hasConsole = true; + + // redirect stdin, stdout and stderr to it + freopen_s(&m_in, "CONIN$", "r", stdin); + freopen_s(&m_out, "CONOUT$", "w", stdout); + freopen_s(&m_err, "CONOUT$", "w", stderr); +} + +Console::~Console() +{ + // close redirected handles and redirect standard stream to NUL in case + // they're used after this + + if (m_err) { + std::fclose(m_err); + freopen_s(&m_err, "NUL", "w", stderr); + } + + if (m_out) { + std::fclose(m_out); + freopen_s(&m_out, "NUL", "w", stdout); + } + + if (m_in) { + std::fclose(m_in); + freopen_s(&m_in, "NUL", "r", stdin); + } + + // close console + if (m_hasConsole) { + FreeConsole(); + } +} + +#else // !_WIN32 + +Console::Console() : m_hasConsole(false), m_in(nullptr), m_out(nullptr), m_err(nullptr) +{ + // on Linux, stdout/stderr are already available +} + +Console::~Console() +{ + // no-op on Linux +} + +#endif // _WIN32 + +ModuleNotification::ModuleNotification(QObject* o, std::function f) + : m_cookie(nullptr), m_object(o), m_f(std::move(f)) +{} + +#ifdef _WIN32 + +ModuleNotification::~ModuleNotification() +{ + if (!m_cookie) { + return; + } + + typedef NTSTATUS NTAPI LdrUnregisterDllNotificationType(PVOID Cookie); + + LibraryPtr ntdll(LoadLibraryW(L"ntdll.dll")); + + if (!ntdll) { + log::error("failed to load ntdll.dll while unregistering for module notifications"); + return; + } + + auto* LdrUnregisterDllNotification = + reinterpret_cast( + GetProcAddress(ntdll.get(), "LdrUnregisterDllNotification")); + + if (!LdrUnregisterDllNotification) { + log::error("LdrUnregisterDllNotification not found in ntdll.dll"); + return; + } + + const auto r = LdrUnregisterDllNotification(m_cookie); + if (r != 0) { + log::error("failed to unregister for module notifications, error {}", r); + } +} + +#else // !_WIN32 + +ModuleNotification::~ModuleNotification() +{ + // no-op on Linux (no ntdll DLL notification system) +} + +#endif // _WIN32 + +void ModuleNotification::setCookie(void* c) +{ + m_cookie = c; +} + +void ModuleNotification::fire(QString path, std::size_t fileSize) +{ + if (m_loaded.contains(path)) { + // don't notify if it's been loaded before + return; + } + + m_loaded.insert(path); + + // constructing a Module will query the version info of the file, which seems + // to generate an access violation for at least plugin_python.dll on Windows 7 + // + // it's not clear what the problem is, but making sure this is deferred until + // _after_ the dll is loaded seems to fix it + // + // so this queues the callback in the main thread + + if (m_f) { + QMetaObject::invokeMethod( + m_object, + [path, fileSize, f = m_f] { + f(Module(path, fileSize)); + }, + Qt::QueuedConnection); + } +} + +Environment::Environment() {} + +// anchor +Environment::~Environment() = default; + +const std::vector& Environment::loadedModules() const +{ + if (m_modules.empty()) { + m_modules = getLoadedModules(); + } + + return m_modules; +} + +std::vector Environment::runningProcesses() const +{ + return getRunningProcesses(); +} + +const WindowsInfo& Environment::windowsInfo() const +{ + if (!m_windows) { + m_windows.reset(new WindowsInfo); + } + + return *m_windows; +} + +const std::vector& Environment::securityProducts() const +{ + if (m_security.empty()) { + m_security = getSecurityProducts(); + } + + return m_security; +} + +const Metrics& Environment::metrics() const +{ + if (!m_metrics) { + m_metrics.reset(new Metrics); + } + + return *m_metrics; +} + +#ifdef _WIN32 + +QString Environment::timezone() const +{ + TIME_ZONE_INFORMATION tz = {}; + + const auto r = GetTimeZoneInformation(&tz); + if (r == TIME_ZONE_ID_INVALID) { + const auto e = GetLastError(); + log::error("failed to get timezone, {}", formatSystemMessage(e)); + return "unknown"; + } + + auto offsetString = [](int o) { + return QString("%1%2:%3") + .arg(o < 0 ? "" : "+") + .arg(QString::number(o / 60), 2, QChar::fromLatin1('0')) + .arg(QString::number(o % 60), 2, QChar::fromLatin1('0')); + }; + + const auto stdName = QString::fromWCharArray(tz.StandardName); + const auto stdOffset = -(tz.Bias + tz.StandardBias); + const auto std = QString("%1, %2").arg(stdName).arg(offsetString(stdOffset)); + + const auto dstName = QString::fromWCharArray(tz.DaylightName); + const auto dstOffset = -(tz.Bias + tz.DaylightBias); + const auto dst = QString("%1, %2").arg(dstName).arg(offsetString(dstOffset)); + + QString s; + + if (r == TIME_ZONE_ID_DAYLIGHT) { + s = dst + " (dst is active, std is " + std + ")"; + } else { + s = std + " (std is active, dst is " + dst + ")"; + } + + return s; +} + +#else // !_WIN32 + +QString Environment::timezone() const +{ + QTimeZone tz = QTimeZone::systemTimeZone(); + if (!tz.isValid()) { + log::error("failed to get system timezone"); + return "unknown"; + } + + return QString::fromUtf8(tz.id()); +} + +#endif // _WIN32 + +#ifdef _WIN32 + +std::unique_ptr +Environment::onModuleLoaded(QObject* o, std::function f) +{ + typedef struct _UNICODE_STRING + { + USHORT Length; + USHORT MaximumLength; + PWSTR Buffer; + } UNICODE_STRING, *PUNICODE_STRING; + + typedef const PUNICODE_STRING PCUNICODE_STRING; + + typedef struct _LDR_DLL_LOADED_NOTIFICATION_DATA + { + ULONG Flags; // Reserved. + PCUNICODE_STRING FullDllName; // The full path name of the DLL module. + PCUNICODE_STRING BaseDllName; // The base file name of the DLL module. + PVOID DllBase; // A pointer to the base address for the DLL in memory. + ULONG SizeOfImage; // The size of the DLL image, in bytes. + } LDR_DLL_LOADED_NOTIFICATION_DATA, *PLDR_DLL_LOADED_NOTIFICATION_DATA; + + typedef struct _LDR_DLL_UNLOADED_NOTIFICATION_DATA + { + ULONG Flags; // Reserved. + PCUNICODE_STRING FullDllName; // The full path name of the DLL module. + PCUNICODE_STRING BaseDllName; // The base file name of the DLL module. + PVOID DllBase; // A pointer to the base address for the DLL in memory. + ULONG SizeOfImage; // The size of the DLL image, in bytes. + } LDR_DLL_UNLOADED_NOTIFICATION_DATA, *PLDR_DLL_UNLOADED_NOTIFICATION_DATA; + + typedef union _LDR_DLL_NOTIFICATION_DATA + { + LDR_DLL_LOADED_NOTIFICATION_DATA Loaded; + LDR_DLL_UNLOADED_NOTIFICATION_DATA Unloaded; + } LDR_DLL_NOTIFICATION_DATA, *PLDR_DLL_NOTIFICATION_DATA; + + typedef VOID CALLBACK LDR_DLL_NOTIFICATION_FUNCTION( + ULONG NotificationReason, const PLDR_DLL_NOTIFICATION_DATA NotificationData, + PVOID Context); + + typedef LDR_DLL_NOTIFICATION_FUNCTION* PLDR_DLL_NOTIFICATION_FUNCTION; + + typedef NTSTATUS NTAPI LdrRegisterDllNotificationType( + ULONG Flags, PLDR_DLL_NOTIFICATION_FUNCTION NotificationFunction, PVOID Context, + PVOID * Cookie); + + const ULONG LDR_DLL_NOTIFICATION_REASON_LOADED = 1; + const ULONG LDR_DLL_NOTIFICATION_REASON_UNLOADED = 2; + + // loading ntdll.dll, the function will be found with GetProcAddress() + LibraryPtr ntdll(LoadLibraryW(L"ntdll.dll")); + + if (!ntdll) { + log::error("failed to load ntdll.dll while registering for module notifications"); + return {}; + } + + auto* LdrRegisterDllNotification = reinterpret_cast( + GetProcAddress(ntdll.get(), "LdrRegisterDllNotification")); + + if (!LdrRegisterDllNotification) { + log::error("LdrRegisterDllNotification not found in ntdll.dll"); + return {}; + } + + auto context = std::make_unique(o, f); + void* cookie = nullptr; + + auto OnDllLoaded = [](ULONG reason, const PLDR_DLL_NOTIFICATION_DATA data, + void* context) { + if (reason == LDR_DLL_NOTIFICATION_REASON_LOADED) { + if (data && data->Loaded.FullDllName) { + if (context) { + static_cast(context)->fire( + QString::fromWCharArray(data->Loaded.FullDllName->Buffer, + data->Loaded.FullDllName->Length / + sizeof(wchar_t)), + data->Loaded.SizeOfImage); + } + } + } + }; + + const auto r = LdrRegisterDllNotification(0, OnDllLoaded, context.get(), &cookie); + + if (r != 0) { + log::error("failed to register for module notifications, error {}", r); + return {}; + } + + context->setCookie(cookie); + + return context; +} + +#else // !_WIN32 + +std::unique_ptr +Environment::onModuleLoaded(QObject*, std::function) +{ + // no DLL notification system on Linux + return {}; +} + +#endif // _WIN32 + +void Environment::dump(const Settings& s) const +{ + log::debug("windows: {}", windowsInfo().toString()); + + log::debug("time zone: {}", timezone()); + + if (windowsInfo().compatibilityMode()) { + log::warn("MO seems to be running in compatibility mode"); + } + + log::debug("security products:"); + + { + // ignore products with identical names, some AVs register themselves with + // the same names and provider, but different guids + std::set productNames; + for (const auto& sp : securityProducts()) { + productNames.insert(sp.toString()); + } + + for (auto&& name : productNames) { + log::debug(" . {}", name); + } + } + + log::debug("modules loaded in process:"); + for (const auto& m : loadedModules()) { + if (m.interesting()) { + log::debug(" . {}", m.toString()); + } + } + + log::debug("displays:"); + for (const auto& d : metrics().displays()) { + log::debug(" . {}", d.toString()); + } + + const auto r = metrics().desktopGeometry(); + log::debug("desktop geometry: ({},{})-({},{})", r.left(), r.top(), r.right(), + r.bottom()); + + dumpDisks(s); +} + +void Environment::dumpDisks(const Settings& s) const +{ + std::set rootPaths; + + auto dump = [&](auto&& path) { + const QFileInfo fi(path); + const QStorageInfo si(fi.absoluteFilePath()); + + if (rootPaths.contains(si.rootPath())) { + // already seen + return; + } + + // remember + rootPaths.insert(si.rootPath()); + + log::debug(" . {} free={} MB{}", si.rootPath(), (si.bytesFree() / 1000 / 1000), + (si.isReadOnly() ? " (readonly)" : "")); + }; + + log::debug("drives:"); + + dump(QStorageInfo::root().rootPath()); + dump(s.paths().base()); + dump(s.paths().downloads()); + dump(s.paths().mods()); + dump(s.paths().cache()); + dump(s.paths().profiles()); + dump(s.paths().overwrite()); + dump(QCoreApplication::applicationDirPath()); +} + +QString path() +{ + return get("PATH"); +} + +#ifdef _WIN32 + +QString appendToPath(const QString& s) +{ + auto old = path(); + set("PATH", old + ";" + s); + return old; +} + +QString prependToPath(const QString& s) +{ + auto old = path(); + set("PATH", s + ";" + old); + return old; +} + +#else // !_WIN32 + +QString appendToPath(const QString& s) +{ + auto old = path(); + set("PATH", old + ":" + s); + return old; +} + +QString prependToPath(const QString& s) +{ + auto old = path(); + set("PATH", s + ":" + old); + return old; +} + +#endif // _WIN32 + +void setPath(const QString& s) +{ + set("PATH", s); +} + +#ifdef _WIN32 + +QString get(const QString& name) +{ + std::size_t bufferSize = 4000; + auto buffer = std::make_unique(bufferSize); + + DWORD realSize = ::GetEnvironmentVariableW(name.toStdWString().c_str(), buffer.get(), + static_cast(bufferSize)); + + if (realSize > bufferSize) { + bufferSize = realSize; + buffer = std::make_unique(bufferSize); + + realSize = ::GetEnvironmentVariableW(name.toStdWString().c_str(), buffer.get(), + static_cast(bufferSize)); + } + + if (realSize == 0) { + const auto e = ::GetLastError(); + + // don't log if not found + if (e != ERROR_ENVVAR_NOT_FOUND) { + log::error("failed to get environment variable '{}', {}", name, + formatSystemMessage(e)); + } + + return {}; + } + + return QString::fromWCharArray(buffer.get(), realSize); +} + +void set(const QString& n, const QString& v) +{ + ::SetEnvironmentVariableW(n.toStdWString().c_str(), v.toStdWString().c_str()); +} + +#else // !_WIN32 + +QString get(const QString& name) +{ + return qEnvironmentVariable(name.toUtf8()); +} + +void set(const QString& n, const QString& v) +{ + qputenv(n.toUtf8(), v.toUtf8()); +} + +#endif // _WIN32 + +Service::Service(QString name) : Service(std::move(name), StartType::None, Status::None) +{} + +Service::Service(QString name, StartType st, Status s) + : m_name(std::move(name)), m_startType(st), m_status(s) +{} + +const QString& Service::name() const +{ + return m_name; +} + +bool Service::isValid() const +{ + return (m_startType != StartType::None) && (m_status != Status::None); +} + +Service::StartType Service::startType() const +{ + return m_startType; +} + +Service::Status Service::status() const +{ + return m_status; +} + +QString Service::toString() const +{ + return QString("service '%1', start=%2, status=%3") + .arg(m_name) + .arg(env::toString(m_startType)) + .arg(env::toString(m_status)); +} + +QString toString(Service::StartType st) +{ + using ST = Service::StartType; + + switch (st) { + case ST::None: + return "none"; + + case ST::Disabled: + return "disabled"; + + case ST::Enabled: + return "enabled"; + + default: + return QString("unknown %1").arg(static_cast(st)); + } +} + +QString toString(Service::Status st) +{ + using S = Service::Status; + + switch (st) { + case S::None: + return "none"; + + case S::Stopped: + return "stopped"; + + case S::Running: + return "running"; + + default: + return QString("unknown %1").arg(static_cast(st)); + } +} + +#ifdef _WIN32 + +Service::StartType getServiceStartType(SC_HANDLE s, const QString& name) +{ + DWORD needed = 0; + + if (!QueryServiceConfig(s, NULL, 0, &needed)) { + const auto e = GetLastError(); + + if (e != ERROR_INSUFFICIENT_BUFFER) { + log::error("QueryServiceConfig() for size for '{}' failed, {}", name, + GetLastError()); + + return Service::StartType::None; + } + } + + const auto size = needed; + MallocPtr config( + static_cast(std::malloc(size))); + + if (!QueryServiceConfig(s, config.get(), size, &needed)) { + const auto e = GetLastError(); + + log::error("QueryServiceConfig() for '{}' failed", name, formatSystemMessage(e)); + + return Service::StartType::None; + } + + switch (config->dwStartType) { + case SERVICE_AUTO_START: // fall-through + case SERVICE_BOOT_START: + case SERVICE_DEMAND_START: + case SERVICE_SYSTEM_START: { + return Service::StartType::Enabled; + } + + case SERVICE_DISABLED: { + return Service::StartType::Disabled; + } + + default: { + log::error("unknown service start type {} for '{}'", config->dwStartType, name); + + return Service::StartType::None; + } + } +} + +Service::Status getServiceStatus(SC_HANDLE s, const QString& name) +{ + DWORD needed = 0; + + if (!QueryServiceStatusEx(s, SC_STATUS_PROCESS_INFO, NULL, 0, &needed)) { + const auto e = GetLastError(); + + if (e != ERROR_INSUFFICIENT_BUFFER) { + log::error("QueryServiceStatusEx() for size for '{}' failed, {}", name, + GetLastError()); + + return Service::Status::None; + } + } + + const auto size = needed; + MallocPtr status( + static_cast(std::malloc(size))); + + const auto r = QueryServiceStatusEx( + s, SC_STATUS_PROCESS_INFO, reinterpret_cast(status.get()), size, &needed); + + if (!r) { + const auto e = GetLastError(); + + log::error("QueryServiceStatusEx() failed for '{}', {}", name, + formatSystemMessage(e)); + + return Service::Status::None; + } + + switch (status->dwCurrentState) { + case SERVICE_START_PENDING: // fall-through + case SERVICE_CONTINUE_PENDING: + case SERVICE_RUNNING: { + return Service::Status::Running; + } + + case SERVICE_STOPPED: // fall-through + case SERVICE_STOP_PENDING: + case SERVICE_PAUSE_PENDING: + case SERVICE_PAUSED: { + return Service::Status::Stopped; + } + + default: { + log::error("unknown service status {} for '{}'", status->dwCurrentState, name); + + return Service::Status::None; + } + } +} + +Service getService(const QString& name) +{ + // service manager + const LocalPtr scm( + OpenSCManager(NULL, NULL, SERVICE_QUERY_STATUS | SERVICE_QUERY_CONFIG)); + + if (!scm) { + const auto e = GetLastError(); + log::error("OpenSCManager() failed, {}", formatSystemMessage(e)); + return Service(name); + } + + // service + const LocalPtr s(OpenService(scm.get(), name.toStdWString().c_str(), + SERVICE_QUERY_STATUS | SERVICE_QUERY_CONFIG)); + + if (!s) { + const auto e = GetLastError(); + log::error("OpenService() failed for '{}', {}", name, formatSystemMessage(e)); + return Service(name); + } + + const auto startType = getServiceStartType(s.get(), name); + const auto status = getServiceStatus(s.get(), name); + + return {name, startType, status}; +} + +#else // !_WIN32 + +Service getService(const QString& name) +{ + // no Windows SCM on Linux, return invalid service + return Service(name); +} + +#endif // _WIN32 + +#ifdef _WIN32 + +std::optional getAssocString(const QFileInfo& file, ASSOCSTR astr) +{ + const auto ext = L"." + file.suffix().toStdWString(); + + // getting buffer size + DWORD bufferSize = 0; + auto r = AssocQueryStringW(ASSOCF_INIT_IGNOREUNKNOWN, astr, ext.c_str(), L"open", + nullptr, &bufferSize); + + // returns S_FALSE when giving back the buffer size, so that's actually the + // expected return value + + if (r != S_FALSE || bufferSize == 0) { + if (r == HRESULT_FROM_WIN32(ERROR_NO_ASSOCIATION)) { + log::error("file '{}' has no associated executable", file.absoluteFilePath()); + } else { + log::error("can't get buffer size for AssocQueryStringW(), {}", + formatSystemMessage(r)); + } + return {}; + } + + // getting string + auto buffer = std::make_unique(bufferSize + 1); + std::fill(buffer.get(), buffer.get() + bufferSize + 1, 0); + + r = AssocQueryStringW(ASSOCF_INIT_IGNOREUNKNOWN, astr, ext.c_str(), L"open", + buffer.get(), &bufferSize); + + if (FAILED(r)) { + log::error("failed to get exe associated with '{}', {}", file.suffix(), + formatSystemMessage(r)); + + return {}; + } + + // buffer size includes the null terminator + return QString::fromWCharArray(buffer.get(), bufferSize - 1); +} + +QString formatCommandLine(const QFileInfo& targetInfo, const QString& cmd) +{ + // yeah, FormatMessage() expects at least as many arguments as there are + // placeholders and while the command for associations should typically only + // have %1, the user can actually enter anything in the registry + // + // since the maximum number of arguments is 99, this creates an array of 99 + // wchar_* where the first one (%1) points to the filename and the remaining + // 98 to "" + // + // FormatMessage() actually takes a va_list* for the arguments, but by passing + // FORMAT_MESSAGE_ARGUMENT_ARRAY, an array of DWORD_PTR can be given instead + + // 99 arguments + std::array args; + + // first one is the filename + const auto wpath = targetInfo.absoluteFilePath().toStdWString(); + args[0] = reinterpret_cast(wpath.c_str()); + + // remaining are "" + std::fill(args.begin() + 1, args.end(), reinterpret_cast(L"")); + + // must be freed with LocalFree() + wchar_t* buffer = nullptr; + + const auto wcmd = cmd.toStdWString(); + + const auto n = + ::FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_ARGUMENT_ARRAY | + FORMAT_MESSAGE_FROM_STRING, + wcmd.c_str(), 0, 0, reinterpret_cast(&buffer), 0, + reinterpret_cast(&args[0])); + + if (n == 0 || !buffer) { + const auto e = GetLastError(); + + log::error("failed to format command line '{}' with path '{}', {}", cmd, + targetInfo.absoluteFilePath(), formatSystemMessage(e)); + + return {}; + } + + auto s = QString::fromWCharArray(buffer, n); + ::LocalFree(buffer); + + return s.trimmed(); +} + +std::pair splitExeAndArguments(const QString& cmd) +{ + int exeBegin = 0; + int exeEnd = -1; + + if (cmd[0] == '"') { + // surrounded by double-quotes, so find the next one + exeBegin = 1; + exeEnd = cmd.indexOf('"', exeBegin); + + if (exeEnd == -1) { + log::error("missing terminating double-quote in command line '{}'", cmd); + return {}; + } + } else { + // no double-quotes, find the first whitespace + exeEnd = cmd.indexOf(QRegularExpression("\\s")); + if (exeEnd == -1) { + exeEnd = cmd.size(); + } + } + + QString exe = cmd.mid(exeBegin, exeEnd - exeBegin).trimmed(); + QString args = cmd.mid(exeEnd + 1).trimmed(); + + return {std::move(exe), std::move(args)}; +} + +Association getAssociation(const QFileInfo& targetInfo) +{ + log::debug("getting association for '{}', extension is '.{}'", + targetInfo.absoluteFilePath(), targetInfo.suffix()); + + const auto cmd = getAssocString(targetInfo, ASSOCSTR_COMMAND); + if (!cmd) { + return {}; + } + + log::debug("raw cmd is '{}'", *cmd); + + QString formattedCmd = formatCommandLine(targetInfo, *cmd); + if (formattedCmd.isEmpty()) { + log::error("command line associated with '{}' is empty", + targetInfo.absoluteFilePath()); + + return {}; + } + + log::debug("formatted cmd is '{}'", formattedCmd); + + const auto p = splitExeAndArguments(formattedCmd); + if (p.first.isEmpty()) { + return {}; + } + + log::debug("split into exe='{}' and cmd='{}'", p.first, p.second); + + return {QFileInfo(p.first), *cmd, p.second}; +} + +#else // !_WIN32 + +Association getAssociation(const QFileInfo& targetInfo) +{ + log::debug("getting association for '{}', extension is '.{}'", + targetInfo.absoluteFilePath(), targetInfo.suffix()); + + // try xdg-mime to find the default application + const QString mimeType = "application/x-" + targetInfo.suffix(); + + QProcess xdgMime; + xdgMime.start("xdg-mime", QStringList() << "query" << "default" << mimeType); + + if (!xdgMime.waitForFinished(3000)) { + log::debug("xdg-mime query timed out for '{}'", targetInfo.suffix()); + return {}; + } + + const QString desktopFile = QString::fromUtf8(xdgMime.readAllStandardOutput()).trimmed(); + if (desktopFile.isEmpty()) { + log::debug("no association found for '{}'", targetInfo.suffix()); + return {}; + } + + log::debug("associated desktop file: '{}'", desktopFile); + + return {}; +} + +#endif // _WIN32 + +#ifdef _WIN32 + +struct RegistryKeyCloser +{ + using pointer = HKEY; + + void operator()(HKEY key) + { + if (key != 0) { + ::RegCloseKey(key); + } + } +}; + +using RegistryKeyPtr = std::unique_ptr; + +RegistryKeyPtr openRegistryKey(HKEY parent, const wchar_t* name) +{ + HKEY subkey = 0; + + auto r = ::RegOpenKeyExW(parent, name, 0, + KEY_SET_VALUE | KEY_ENUMERATE_SUB_KEYS | KEY_QUERY_VALUE, + &subkey); + + if (r != ERROR_SUCCESS) { + return {}; + } + + return RegistryKeyPtr(subkey); +} + +bool keyHasValues(HKEY key) +{ + auto name = std::make_unique(1000 + 1); + DWORD nameSize = 1000; + + // note that RegEnumValueW() also enumerates the default value if it exists + auto r = ::RegEnumValueW(key, 0, name.get(), &nameSize, nullptr, nullptr, nullptr, + nullptr); + + if (r != ERROR_NO_MORE_ITEMS) { + return true; + } + + // no values, no default, it's empty + return false; +} + +bool forEachSubKey(HKEY key, std::function f) +{ + auto name = std::make_unique(1000 + 1); + DWORD nameSize = 1000; + + DWORD i = 0; + + // something would be really wrong if it had more than 100 keys + while (i < 100) { + auto r = ::RegEnumKeyExW(key, i, name.get(), &nameSize, nullptr, nullptr, nullptr, + nullptr); + + if (r == ERROR_NO_MORE_ITEMS) { + // no more subkeys + break; + } + + if (r == ERROR_SUCCESS) { + // a subkey exists + auto subkey = openRegistryKey(key, name.get()); + + if (!subkey) { + // can't open it, stop + return false; + } + + // fire callback + if (!f(name.get())) { + return false; + } + } else { + // something went wrong, stop + return false; + } + + ++i; + } + + return true; +} + +bool isKeyEmpty(HKEY key) +{ + // check for any values + if (keyHasValues(key)) { + return false; + } + + auto r = forEachSubKey(key, [&](const wchar_t* name) { + // a subkey exists, recursively check if it's empty + auto subkey = openRegistryKey(key, name); + + if (!subkey) { + // can't open, stop + return false; + } + + if (!isKeyEmpty(subkey.get())) { + // not empty, stop + return false; + } + + // empty, go on + return true; + }); + + if (!r) { + // something went wrong or some subkey has values + return false; + } + + // key has no values and has either no subkeys or all subkeys are empty + return true; +} + +void deleteRegistryKeyIfEmpty(const QString& name) +{ + if (name.isEmpty()) { + return; + } + + auto key = openRegistryKey(HKEY_CURRENT_USER, name.toStdWString().c_str()); + if (!key) { + return; + } + + if (!isKeyEmpty(key.get())) { + return; + } + + ::RegDeleteTreeW(HKEY_CURRENT_USER, name.toStdWString().c_str()); +} + +bool registryValueExists(const QString& keyName, const QString& valueName) +{ + auto key = openRegistryKey(HKEY_CURRENT_USER, keyName.toStdWString().c_str()); + + if (!key) { + return false; + } + + DWORD type = 0; + + auto r = ::RegQueryValueExW(key.get(), valueName.toStdWString().c_str(), nullptr, + &type, nullptr, nullptr); + + return (r == ERROR_SUCCESS); +} + +#else // !_WIN32 + +void deleteRegistryKeyIfEmpty(const QString&) +{ + // no registry on Linux +} + +bool registryValueExists(const QString&, const QString&) +{ + // no registry on Linux + return false; +} + +#endif // _WIN32 + +#ifdef _WIN32 + +// returns the filename of the given process or the current one +// +std::filesystem::path processPath(HANDLE process = INVALID_HANDLE_VALUE) +{ + // double the buffer size 10 times + const int MaxTries = 10; + + DWORD bufferSize = MAX_PATH; + + for (int tries = 0; tries < MaxTries; ++tries) { + auto buffer = std::make_unique(bufferSize + 1); + std::fill(buffer.get(), buffer.get() + bufferSize + 1, 0); + + DWORD writtenSize = 0; + + if (process == INVALID_HANDLE_VALUE) { + // query this process + writtenSize = GetModuleFileNameW(0, buffer.get(), bufferSize); + } else { + // query another process + writtenSize = GetModuleBaseNameW(process, 0, buffer.get(), bufferSize); + } + + if (writtenSize == 0) { + // hard failure + const auto e = GetLastError(); + std::wcerr << formatSystemMessage(e) << L"\n"; + break; + } else if (writtenSize >= bufferSize) { + // buffer is too small, try again + bufferSize *= 2; + } else { + // if GetModuleFileName() works, `writtenSize` does not include the null + // terminator + const std::wstring s(buffer.get(), writtenSize); + const std::filesystem::path path(s); + + return path; + } + } + + // something failed or the path is way too long to make sense + + std::wstring what; + if (process == INVALID_HANDLE_VALUE) { + what = L"the current process"; + } else { + what = L"pid " + std::to_wstring(reinterpret_cast(process)); + } + + std::wcerr << L"failed to get filename for " << what << L"\n"; + return {}; +} + +std::wstring processFilename(HANDLE process = INVALID_HANDLE_VALUE) +{ + const auto p = processPath(process); + if (p.empty()) { + return {}; + } + + return p.filename().native(); +} + +std::filesystem::path thisProcessPath() +{ + return processPath(); +} + +#else // !_WIN32 + +std::filesystem::path thisProcessPath() +{ + char buf[PATH_MAX] = {}; + ssize_t len = ::readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (len <= 0) { + std::cerr << "failed to readlink /proc/self/exe: " << strerror(errno) << "\n"; + return {}; + } + + buf[len] = '\0'; + return std::filesystem::path(buf); +} + +#endif // _WIN32 + +#ifdef _WIN32 + +DWORD findOtherPid() +{ + const std::wstring defaultName = L"ModOrganizer.exe"; + + std::wclog << L"looking for the other process...\n"; + + // used to skip the current process below + const auto thisPid = GetCurrentProcessId(); + std::wclog << L"this process id is " << thisPid << L"\n"; + + // getting the filename for this process, assumes the other process has the + // same one + auto filename = processFilename(); + if (filename.empty()) { + std::wcerr << L"can't get current process filename, defaulting to " << defaultName + << L"\n"; + + filename = defaultName; + } else { + std::wclog << L"this process filename is " << filename << L"\n"; + } + + // getting all running processes + const auto processes = getRunningProcesses(); + std::wclog << L"there are " << processes.size() << L" processes running\n"; + + // going through processes, trying to find one with the same name and a + // different pid than this process has + for (const auto& p : processes) { + if (p.name().toStdWString() == filename) { + if (p.pid() != thisPid) { + return p.pid(); + } + } + } + + std::wclog << L"no process with this filename\n" + << L"MO may not be running, or it may be running as administrator\n" + << L"you can try running this again as administrator\n"; + + return 0; +} + +#else // !_WIN32 + +pid_t findOtherPid() +{ + std::clog << "looking for the other process...\n"; + + const pid_t thisPid = ::getpid(); + std::clog << "this process id is " << thisPid << "\n"; + + // scan /proc/*/comm for "ModOrganizer" + const std::string targetName = "ModOrganizer"; + + for (const auto& entry : std::filesystem::directory_iterator("/proc")) { + if (!entry.is_directory()) { + continue; + } + + const auto pidStr = entry.path().filename().string(); + + // check if the directory name is a number (a PID) + bool isNumber = true; + for (char c : pidStr) { + if (!std::isdigit(static_cast(c))) { + isNumber = false; + break; + } + } + if (!isNumber) { + continue; + } + + pid_t pid = std::stoi(pidStr); + if (pid == thisPid) { + continue; + } + + std::ifstream commFile(entry.path() / "comm"); + if (!commFile.is_open()) { + continue; + } + + std::string comm; + std::getline(commFile, comm); + + if (comm == targetName) { + std::clog << "found other process with pid " << pid << "\n"; + return pid; + } + } + + std::clog << "no process with this filename\n"; + return 0; +} + +#endif // _WIN32 + +#ifdef _WIN32 + +std::wstring tempDir() +{ + const DWORD bufferSize = MAX_PATH + 1; + wchar_t buffer[bufferSize + 1] = {}; + + const auto written = GetTempPathW(bufferSize, buffer); + if (written == 0) { + const auto e = GetLastError(); + + std::wcerr << L"failed to get temp path, " << formatSystemMessage(e) << L"\n"; + + return {}; + } + + // `written` does not include the null terminator + return std::wstring(buffer, buffer + written); +} + +std::wstring safeVersion() +{ + try { + // this can throw + return MOShared::createVersionInfo().string().toStdWString() + L"-"; + } catch (...) { + return {}; + } +} + +HandlePtr tempFile(const std::wstring dir) +{ + // maximum tries of incrementing the counter + const int MaxTries = 100; + + // UTC time and date will be in the filename + const auto now = std::time(0); + const auto tm = std::gmtime(&now); + + // "ModOrganizer-YYYYMMDDThhmmss.dmp", with a possible "-i" appended, where + // i can go until MaxTries + std::wostringstream oss; + oss << L"ModOrganizer-" << safeVersion() << std::setw(4) << (1900 + tm->tm_year) + << std::setw(2) << std::setfill(L'0') << (tm->tm_mon + 1) << std::setw(2) + << std::setfill(L'0') << tm->tm_mday << "T" << std::setw(2) << std::setfill(L'0') + << tm->tm_hour << std::setw(2) << std::setfill(L'0') << tm->tm_min << std::setw(2) + << std::setfill(L'0') << tm->tm_sec; + + const std::wstring prefix = oss.str(); + const std::wstring ext = L".dmp"; + + // first path to try, without counter in it + std::wstring path = dir + L"\\" + prefix + ext; + + for (int i = 0; i < MaxTries; ++i) { + std::wclog << L"trying file '" << path << L"'\n"; + + HandlePtr h(CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, nullptr)); + + if (h.get() != INVALID_HANDLE_VALUE) { + // worked + return h; + } + + const auto e = GetLastError(); + + if (e != ERROR_FILE_EXISTS) { + // probably no write access + std::wcerr << L"failed to create dump file, " << formatSystemMessage(e) << L"\n"; + + return {}; + } + + // try again with "-i" + path = dir + L"\\" + prefix + L"-" + std::to_wstring(i + 1) + ext; + } + + std::wcerr << L"can't create dump file, ran out of filenames\n"; + return {}; +} + +HandlePtr dumpFile(const wchar_t* dir) +{ + // try the given directory, if any + if (dir) { + HandlePtr h = tempFile(dir); + if (h.get() != INVALID_HANDLE_VALUE) { + return h; + } + } + + // try the current directory + HandlePtr h = tempFile(L"."); + if (h.get() != INVALID_HANDLE_VALUE) { + return h; + } + + std::wclog << L"cannot write dump file in current directory\n"; + + // try the temp directory + const auto temp = tempDir(); + + if (!temp.empty()) { + h = tempFile(temp.c_str()); + if (h.get() != INVALID_HANDLE_VALUE) { + return h; + } + } + + return {}; +} + +#endif // _WIN32 + +CoreDumpTypes coreDumpTypeFromString(const std::string& s) +{ + if (s == "data") + return env::CoreDumpTypes::Data; + else if (s == "full") + return env::CoreDumpTypes::Full; + else + return env::CoreDumpTypes::Mini; +} + +std::string toString(CoreDumpTypes type) +{ + switch (type) { + case CoreDumpTypes::Mini: + return "mini"; + + case CoreDumpTypes::Data: + return "data"; + + case CoreDumpTypes::Full: + return "full"; + + default: + return "?"; + } +} + +#ifdef _WIN32 + +bool createMiniDump(const wchar_t* dir, HANDLE process, CoreDumpTypes type) +{ + const DWORD pid = GetProcessId(process); + + const HandlePtr file = dumpFile(dir); + if (!file) { + std::wcerr << L"nowhere to write the dump file\n"; + return false; + } + + auto flags = + _MINIDUMP_TYPE(MiniDumpNormal | MiniDumpWithHandleData | + MiniDumpWithUnloadedModules | MiniDumpWithProcessThreadData); + + if (type == CoreDumpTypes::Data) { + std::wclog << L"writing minidump with data\n"; + flags = _MINIDUMP_TYPE(flags | MiniDumpWithDataSegs); + } else if (type == CoreDumpTypes::Full) { + std::wclog << L"writing full minidump\n"; + flags = _MINIDUMP_TYPE(flags | MiniDumpWithFullMemory); + } else { + std::wclog << L"writing mini minidump\n"; + } + + const auto ret = + MiniDumpWriteDump(process, pid, file.get(), flags, nullptr, nullptr, nullptr); + + if (!ret) { + const auto e = GetLastError(); + + std::wcerr << L"failed to write mini dump, " << formatSystemMessage(e) << L"\n"; + + return false; + } + + std::wclog << L"minidump written correctly\n"; + return true; +} + +bool coredump(const wchar_t* dir, CoreDumpTypes type) +{ + std::wclog << L"creating minidump for the current process\n"; + return createMiniDump(dir, GetCurrentProcess(), type); +} + +bool coredumpOther(CoreDumpTypes type) +{ + std::wclog << L"creating minidump for a running process\n"; + + const auto pid = findOtherPid(); + if (pid == 0) { + std::wcerr << L"no other process found\n"; + return false; + } + + std::wclog << L"found other process with pid " << pid << L"\n"; + + HandlePtr handle( + OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, pid)); + + if (!handle) { + const auto e = GetLastError(); + + std::wcerr << L"failed to open process " << pid << L", " << formatSystemMessage(e) + << L"\n"; + + return false; + } + + return createMiniDump(nullptr, handle.get(), type); +} + +#else // !_WIN32 + +bool coredump(const char* dir, CoreDumpTypes type) +{ + std::clog << "coredump requested (type: " << toString(type) << ")\n"; + + if (dir) { + std::clog << "dump directory: " << dir << "\n"; + } + + // on Linux, call abort() to generate a core dump (if ulimit allows) + std::clog << "calling abort() to generate core dump\n"; + std::abort(); + + // unreachable + return false; +} + +bool coredumpOther(CoreDumpTypes type) +{ + std::clog << "creating core dump for a running process\n"; + + const auto pid = findOtherPid(); + if (pid == 0) { + std::cerr << "no other process found\n"; + return false; + } + + std::clog << "found other process with pid " << pid << "\n"; + + // send SIGABRT to the other process to trigger a core dump + if (::kill(pid, SIGABRT) != 0) { + std::cerr << "failed to send SIGABRT to process " << pid << ": " + << strerror(errno) << "\n"; + return false; + } + + std::clog << "sent SIGABRT to process " << pid << "\n"; + return true; +} + +#endif // _WIN32 + +} // namespace env diff --git a/src/src/env.h b/src/src/env.h new file mode 100644 index 0000000..c9d9804 --- /dev/null +++ b/src/src/env.h @@ -0,0 +1,313 @@ +#ifndef ENV_ENV_H +#define ENV_ENV_H + +class Settings; + +namespace env +{ + +class Module; +class Process; +class SecurityProduct; +class WindowsInfo; +class Metrics; + +#ifdef _WIN32 + +// used by DesktopDCPtr, calls ReleaseDC(0, dc) as the deleter +// +struct DesktopDCReleaser +{ + using pointer = HDC; + + void operator()(HDC dc) + { + if (dc != 0) { + ::ReleaseDC(0, dc); + } + } +}; + +using DesktopDCPtr = std::unique_ptr; + +// used by HMenuPtr, calls DestroyMenu() as the deleter +// +struct HMenuFreer +{ + using pointer = HMENU; + + void operator()(HMENU h) + { + if (h != 0) { + ::DestroyMenu(h); + } + } +}; + +using HMenuPtr = std::unique_ptr; + +// used by LibraryPtr, calls FreeLibrary as the deleter +// +struct LibraryFreer +{ + using pointer = HINSTANCE; + + void operator()(HINSTANCE h) + { + if (h != 0) { + ::FreeLibrary(h); + } + } +}; + +using LibraryPtr = std::unique_ptr; + +// used by COMPtr, calls Release() as the deleter +// +struct COMReleaser +{ + void operator()(IUnknown* p) + { + if (p) { + p->Release(); + } + } +}; + +template +using COMPtr = std::unique_ptr; + +// used by LocalPtr, calls LocalFree() as the deleter +// +template +struct LocalFreer +{ + using pointer = T; + + void operator()(T p) { ::LocalFree(p); } +}; + +template +using LocalPtr = std::unique_ptr>; + +// used by the CoTaskMemPtr, calls CoTaskMemFree() as the deleter +// +template +struct CoTaskMemFreer +{ + using pointer = T; + + void operator()(T p) { ::CoTaskMemFree(p); } +}; + +template +using CoTaskMemPtr = std::unique_ptr>; + +#else // !_WIN32 + +// used by LibraryPtr on Linux, calls dlclose() as the deleter +// +struct LibraryFreer +{ + void operator()(void* h) + { + if (h) { + ::dlclose(h); + } + } +}; + +using LibraryPtr = std::unique_ptr; + +#endif // _WIN32 + +// used by MallocPtr, calls std::free() as the deleter +// +struct MallocFreer +{ + void operator()(void* p) { std::free(p); } +}; + +template +using MallocPtr = std::unique_ptr; + +// creates a console in the constructor and destroys it in the destructor, +// also redirects standard streams +// +class Console +{ +public: + // opens the console and redirects standard streams to it + // + Console(); + + // destroys the console and redirects the standard stream to NUL + // + ~Console(); + +private: + // whether the console was allocated successfully + bool m_hasConsole; + + // standard streams + FILE* m_in; + FILE* m_out; + FILE* m_err; +}; + +class ModuleNotification +{ +public: + ModuleNotification(QObject* o, std::function f); + ~ModuleNotification(); + + ModuleNotification(const ModuleNotification&) = delete; + ModuleNotification& operator=(const ModuleNotification&) = delete; + + ModuleNotification(ModuleNotification&&) = default; + ModuleNotification& operator=(ModuleNotification&&) = default; + + void setCookie(void* c); + void fire(QString path, std::size_t fileSize); + +private: + void* m_cookie; + QObject* m_object; + std::set m_loaded; + std::function m_f; +}; + +// represents the process's environment +// +class Environment +{ +public: + Environment(); + ~Environment(); + + // list of loaded modules in the current process + // + const std::vector& loadedModules() const; + + // list of running processes; not cached + // + std::vector runningProcesses() const; + + // information about the operating system + // + const WindowsInfo& windowsInfo() const; + + // information about the installed security products + // + const std::vector& securityProducts() const; + + // information about displays + // + const Metrics& metrics() const; + + // timezone + // + QString timezone() const; + + // will call `f` on the same thread `o` is running on every time a module + // is loaded in the process + // + std::unique_ptr onModuleLoaded(QObject* o, + std::function f); + + // logs the environment + // + void dump(const Settings& s) const; + +private: + mutable std::vector m_modules; + mutable std::unique_ptr m_windows; + mutable std::vector m_security; + mutable std::unique_ptr m_metrics; + + // dumps all the disks involved in the settings + // + void dumpDisks(const Settings& s) const; +}; + +// environment variables +// +QString get(const QString& name); +void set(const QString& name, const QString& value); + +QString path(); +QString appendToPath(const QString& s); +QString prependToPath(const QString& s); +void setPath(const QString& s); + +class Service +{ +public: + enum class StartType + { + None = 0, + Disabled, + Enabled + }; + + enum class Status + { + None = 0, + Stopped, + Running + }; + + explicit Service(QString name); + Service(QString name, StartType st, Status s); + + bool isValid() const; + + const QString& name() const; + StartType startType() const; + Status status() const; + + QString toString() const; + +private: + QString m_name; + StartType m_startType; + Status m_status; +}; + +Service getService(const QString& name); +QString toString(Service::StartType st); +QString toString(Service::Status st); + +struct Association +{ + // path to the executable associated with the file + QFileInfo executable; + + // full command line associated with the file + QString commandLine; + + // command line _without_ the executable and with placeholders such as %1 + // replaced by the given file + QString formattedCommandLine; +}; + +// returns the associated executable and command line, executable is empty on +// error +// +Association getAssociation(const QFileInfo& file); + +// returns whether the given value exists +// +bool registryValueExists(const QString& key, const QString& value); + +// deletes a registry key if it's empty or only contains empty keys +// +void deleteRegistryKeyIfEmpty(const QString& name); + +// returns the path to this process +// +std::filesystem::path thisProcessPath(); + +} // namespace env + +#endif // ENV_ENV_H diff --git a/src/src/envdump.h b/src/src/envdump.h new file mode 100644 index 0000000..3795e9d --- /dev/null +++ b/src/src/envdump.h @@ -0,0 +1,33 @@ +#ifndef MODORGANIZER_ENVDUMP_INCLUDED +#define MODORGANIZER_ENVDUMP_INCLUDED + +namespace env +{ + +enum class CoreDumpTypes +{ + None, + Mini, + Data, + Full +}; + +CoreDumpTypes coreDumpTypeFromString(const std::string& s); +std::string toString(CoreDumpTypes type); + +// creates a minidump file for this process +// +#ifdef _WIN32 +bool coredump(const wchar_t* dir, CoreDumpTypes type); +#else +bool coredump(const char* dir, CoreDumpTypes type); +#endif + +// finds another process with the same name as this one and creates a minidump +// file for it +// +bool coredumpOther(CoreDumpTypes type); + +} // namespace env + +#endif // MODORGANIZER_ENVDUMP_INCLUDED diff --git a/src/src/envfs.cpp b/src/src/envfs.cpp new file mode 100644 index 0000000..9c7cbc6 --- /dev/null +++ b/src/src/envfs.cpp @@ -0,0 +1,722 @@ +#include "envfs.h" +#include "env.h" +#include "shared/util.h" +#include +#include + +using namespace MOBase; + +#ifdef _WIN32 + +typedef struct _UNICODE_STRING +{ + USHORT Length; + USHORT MaximumLength; + PWSTR Buffer; +} UNICODE_STRING, *PUNICODE_STRING; +typedef const UNICODE_STRING* PCUNICODE_STRING; + +typedef struct _OBJECT_ATTRIBUTES +{ + ULONG Length; + HANDLE RootDirectory; + PUNICODE_STRING ObjectName; + ULONG Attributes; + PVOID SecurityDescriptor; + PVOID SecurityQualityOfService; +} OBJECT_ATTRIBUTES, *POBJECT_ATTRIBUTES; + +typedef struct _FILE_DIRECTORY_INFORMATION +{ + ULONG NextEntryOffset; + ULONG FileIndex; + LARGE_INTEGER CreationTime; + LARGE_INTEGER LastAccessTime; + LARGE_INTEGER LastWriteTime; + LARGE_INTEGER ChangeTime; + LARGE_INTEGER EndOfFile; + LARGE_INTEGER AllocationSize; + ULONG FileAttributes; + ULONG FileNameLength; + WCHAR FileName[1]; +} FILE_DIRECTORY_INFORMATION, *PFILE_DIRECTORY_INFORMATION; + +#define FILE_SHARE_VALID_FLAGS 0x00000007 + +// copied from ntstatus.h +#define STATUS_SUCCESS ((NTSTATUS)0x00000000L) +#define STATUS_BUFFER_OVERFLOW ((NTSTATUS)0x80000005L) +#define STATUS_NO_MORE_FILES ((NTSTATUS)0x80000006L) +#define STATUS_NO_SUCH_FILE ((NTSTATUS)0xC000000FL) + +typedef struct _IO_STATUS_BLOCK IO_STATUS_BLOCK; + +typedef struct _IO_STATUS_BLOCK* PIO_STATUS_BLOCK; +typedef VOID(NTAPI* PIO_APC_ROUTINE)(PVOID ApcContext, PIO_STATUS_BLOCK IoStatusBlock, + ULONG Reserved); + +typedef enum _FILE_INFORMATION_CLASS +{ + FileDirectoryInformation = 1 +} FILE_INFORMATION_CLASS; + +typedef NTSTATUS(WINAPI* NtQueryDirectoryFile_type)(HANDLE, HANDLE, PIO_APC_ROUTINE, + PVOID, PIO_STATUS_BLOCK, PVOID, + ULONG, FILE_INFORMATION_CLASS, + BOOLEAN, PUNICODE_STRING, BOOLEAN); + +typedef NTSTATUS(WINAPI* NtOpenFile_type)(PHANDLE, ACCESS_MASK, POBJECT_ATTRIBUTES, + PIO_STATUS_BLOCK, ULONG, ULONG); + +typedef NTSTATUS(WINAPI* NtClose_type)(HANDLE); + +NtOpenFile_type NtOpenFile = nullptr; +NtQueryDirectoryFile_type NtQueryDirectoryFile = nullptr; +extern NtClose_type NtClose = nullptr; + +#define FILE_DIRECTORY_FILE 0x00000001 +#define FILE_WRITE_THROUGH 0x00000002 +#define FILE_SEQUENTIAL_ONLY 0x00000004 +#define FILE_NO_INTERMEDIATE_BUFFERING 0x00000008 + +#define FILE_SYNCHRONOUS_IO_ALERT 0x00000010 +#define FILE_SYNCHRONOUS_IO_NONALERT 0x00000020 +#define FILE_NON_DIRECTORY_FILE 0x00000040 +#define FILE_CREATE_TREE_CONNECTION 0x00000080 + +#define FILE_COMPLETE_IF_OPLOCKED 0x00000100 +#define FILE_NO_EA_KNOWLEDGE 0x00000200 +#define FILE_OPEN_REMOTE_INSTANCE 0x00000400 +#define FILE_RANDOM_ACCESS 0x00000800 + +#define FILE_DELETE_ON_CLOSE 0x00001000 +#define FILE_OPEN_BY_FILE_ID 0x00002000 +#define FILE_OPEN_FOR_BACKUP_INTENT 0x00004000 +#define FILE_NO_COMPRESSION 0x00008000 + +#if (_WIN32_WINNT >= _WIN32_WINNT_WIN7) +#define FILE_OPEN_REQUIRING_OPLOCK 0x00010000 +#endif + +#define FILE_RESERVE_OPFILTER 0x00100000 +#define FILE_OPEN_REPARSE_POINT 0x00200000 +#define FILE_OPEN_NO_RECALL 0x00400000 +#define FILE_OPEN_FOR_FREE_SPACE_QUERY 0x00800000 + +#define FILE_VALID_OPTION_FLAGS 0x00ffffff +#define FILE_VALID_PIPE_OPTION_FLAGS 0x00000032 +#define FILE_VALID_MAILSLOT_OPTION_FLAGS 0x00000032 +#define FILE_VALID_SET_FLAGS 0x00000036 + +typedef struct _IO_STATUS_BLOCK +{ +#pragma warning(push) +#pragma warning(disable : 4201) // we'll always use the Microsoft compiler + union + { + NTSTATUS Status; + PVOID Pointer; + } DUMMYUNIONNAME; +#pragma warning(pop) + + ULONG_PTR Information; +} IO_STATUS_BLOCK, *PIO_STATUS_BLOCK; + +namespace env +{ + +std::wstring_view toStringView(const UNICODE_STRING* s) +{ + if (s && s->Buffer) { + return {s->Buffer, (s->Length / sizeof(wchar_t))}; + } else { + return {}; + } +} + +std::wstring_view toStringView(POBJECT_ATTRIBUTES poa) +{ + if (poa->ObjectName) { + return toStringView(poa->ObjectName); + } + + return {}; +} + +QString toString(POBJECT_ATTRIBUTES poa) +{ + const auto sv = toStringView(poa); + return QString::fromWCharArray(sv.data(), static_cast(sv.size())); +} + +class HandleCloserThread +{ +public: + HandleCloserThread() : m_ready(false) { m_handles.reserve(50000); } + + void shrink() { m_handles.shrink_to_fit(); } + + void add(HANDLE h) { m_handles.push_back(h); } + + void wakeup() + { + { + std::unique_lock lock(m_mutex); + m_ready = true; + } + + m_cv.notify_one(); + } + + void run() + { + MOShared::SetThisThreadName("HandleCloserThread"); + + std::unique_lock lock(m_mutex); + m_cv.wait(lock, [&] { + return m_ready; + }); + + closeHandles(); + } + +private: + std::vector m_handles; + std::condition_variable m_cv; + std::mutex m_mutex; + bool m_ready; + + void closeHandles() + { + for (auto& h : m_handles) { + NtClose(h); + } + + m_handles.clear(); + m_ready = false; + } +}; + +constexpr std::size_t AllocSize = 1024 * 1024; +static ThreadPool g_handleClosers; + +void setHandleCloserThreadCount(std::size_t n) +{ + g_handleClosers.setMax(n); +} + +void forEachEntryImpl(void* cx, HandleCloserThread& hc, + std::vector>& buffers, + POBJECT_ATTRIBUTES poa, std::size_t depth, DirStartF* dirStartF, + DirEndF* dirEndF, FileF* fileF) +{ + IO_STATUS_BLOCK iosb; + UNICODE_STRING ObjectName; + OBJECT_ATTRIBUTES oa = {sizeof(oa), 0, &ObjectName}; + NTSTATUS status; + + status = NtOpenFile(&oa.RootDirectory, FILE_GENERIC_READ, poa, &iosb, + FILE_SHARE_VALID_FLAGS, + FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_FOR_BACKUP_INTENT); + + if (status < 0) { + log::error("failed to open directory '{}': {}", toString(poa), + formatNtMessage(status)); + + return; + } + + hc.add(oa.RootDirectory); + unsigned char* buffer; + + if (depth >= buffers.size()) { + buffers.emplace_back(std::make_unique(AllocSize)); + buffer = buffers.back().get(); + } else { + buffer = buffers[depth].get(); + } + + union + { + PVOID pv; + PBYTE pb; + PFILE_DIRECTORY_INFORMATION DirInfo; + }; + + for (;;) { + status = + NtQueryDirectoryFile(oa.RootDirectory, NULL, NULL, NULL, &iosb, buffer, + AllocSize, FileDirectoryInformation, FALSE, NULL, FALSE); + + if (status == STATUS_NO_MORE_FILES) { + break; + } else if (status < 0) { + log::error("failed to read directory '{}': {}", toString(poa), + formatNtMessage(status)); + + break; + } + + ULONG NextEntryOffset = 0; + + pv = buffer; + + auto isDotDir = [](auto* o) { + if (o->Length == 2 && o->Buffer[0] == '.') { + return true; + } + + if (o->Length == 4 && o->Buffer[0] == '.' && o->Buffer[1] == '.') { + return true; + } + + return false; + }; + + std::size_t count = 0; + + for (;;) { + ++count; + pb += NextEntryOffset; + + ObjectName.Buffer = DirInfo->FileName; + ObjectName.Length = (USHORT)DirInfo->FileNameLength; + + if (!isDotDir(&ObjectName)) { + ObjectName.MaximumLength = ObjectName.Length; + + if (DirInfo->FileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + if (dirStartF && dirEndF) { + dirStartF(cx, toStringView(&oa)); + forEachEntryImpl(cx, hc, buffers, &oa, depth + 1, dirStartF, dirEndF, + fileF); + dirEndF(cx, toStringView(&oa)); + } + } else { + FILETIME ft; + ft.dwLowDateTime = DirInfo->LastWriteTime.LowPart; + ft.dwHighDateTime = DirInfo->LastWriteTime.HighPart; + + fileF(cx, toStringView(&oa), ft, DirInfo->AllocationSize.QuadPart); + } + } + + NextEntryOffset = DirInfo->NextEntryOffset; + + if (NextEntryOffset == 0) { + break; + } + } + } +} + +std::wstring makeNtPath(const std::wstring& path) +{ + constexpr const wchar_t* nt_prefix = L"\\??\\"; + constexpr const wchar_t* nt_unc_prefix = L"\\??\\UNC\\"; + constexpr const wchar_t* share_prefix = L"\\\\"; + + if (path.starts_with(nt_prefix)) { + // already an nt path + return path; + } else if (path.starts_with(share_prefix)) { + // network shared need \??\UNC\ as a prefix + return nt_unc_prefix + path.substr(2); + } else { + // prepend the \??\ prefix + return nt_prefix + path; + } +} + +void DirectoryWalker::forEachEntry(const std::wstring& path, void* cx, + DirStartF* dirStartF, DirEndF* dirEndF, FileF* fileF) +{ + auto& hc = g_handleClosers.request(); + + if (!NtOpenFile) { + LibraryPtr m(::LoadLibraryW(L"ntdll.dll")); + NtOpenFile = (NtOpenFile_type)::GetProcAddress(m.get(), "NtOpenFile"); + NtQueryDirectoryFile = + (NtQueryDirectoryFile_type)::GetProcAddress(m.get(), "NtQueryDirectoryFile"); + NtClose = (NtClose_type)::GetProcAddress(m.get(), "NtClose"); + } + + const std::wstring ntpath = makeNtPath(path); + + UNICODE_STRING ObjectName = {}; + ObjectName.Buffer = const_cast(ntpath.c_str()); + ObjectName.Length = (USHORT)ntpath.size() * sizeof(wchar_t); + ObjectName.MaximumLength = ObjectName.Length; + + OBJECT_ATTRIBUTES oa = {}; + oa.Length = sizeof(oa); + oa.ObjectName = &ObjectName; + + forEachEntryImpl(cx, hc, m_buffers, &oa, 0, dirStartF, dirEndF, fileF); + hc.wakeup(); +} + +void forEachEntry(const std::wstring& path, void* cx, DirStartF* dirStartF, + DirEndF* dirEndF, FileF* fileF) +{ + DirectoryWalker().forEachEntry(path, cx, dirStartF, dirEndF, fileF); +} + +Directory getFilesAndDirs(const std::wstring& path) +{ + struct Context + { + std::stack current; + }; + + Directory root; + + Context cx; + cx.current.push(&root); + + env::forEachEntry( + path, &cx, + [](void* pcx, std::wstring_view path) { + Context* cx = (Context*)pcx; + + cx->current.top()->dirs.push_back(Directory(path)); + cx->current.push(&cx->current.top()->dirs.back()); + }, + + [](void* pcx, std::wstring_view path) { + Context* cx = (Context*)pcx; + cx->current.pop(); + }, + + [](void* pcx, std::wstring_view path, FILETIME ft, uint64_t s) { + Context* cx = (Context*)pcx; + + cx->current.top()->files.push_back(File(path, ft, s)); + }); + + return root; +} + +void getFilesAndDirsWithFindImpl(const std::wstring& path, Directory& d) +{ + const std::wstring searchString = path + L"\\*"; + + WIN32_FIND_DATAW findData; + + HANDLE searchHandle = + ::FindFirstFileExW(searchString.c_str(), FindExInfoBasic, &findData, + FindExSearchNameMatch, nullptr, FIND_FIRST_EX_LARGE_FETCH); + + if (searchHandle != INVALID_HANDLE_VALUE) { + BOOL result = true; + + while (result) { + if (findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + if ((wcscmp(findData.cFileName, L".") != 0) && + (wcscmp(findData.cFileName, L"..") != 0)) { + const std::wstring newPath = path + L"\\" + findData.cFileName; + d.dirs.push_back(Directory(findData.cFileName)); + getFilesAndDirsWithFindImpl(newPath, d.dirs.back()); + } + } else { + const auto size = + (findData.nFileSizeHigh * (MAXDWORD + 1)) + findData.nFileSizeLow; + + d.files.push_back(File(findData.cFileName, findData.ftLastWriteTime, size)); + } + + result = ::FindNextFileW(searchHandle, &findData); + } + } + + ::FindClose(searchHandle); +} + +Directory getFilesAndDirsWithFind(const std::wstring& path) +{ + Directory d; + getFilesAndDirsWithFindImpl(path, d); + return d; +} + +} // namespace env + +#else // Linux + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace env +{ + +// Convert timespec to FILETIME (100-nanosecond intervals since Jan 1, 1601) +static FILETIME timespecToFiletime(const struct timespec& ts) +{ + // Unix epoch to Windows epoch offset: 11644473600 seconds + constexpr uint64_t EPOCH_DIFF = 116444736000000000ULL; + uint64_t winTime = static_cast(ts.tv_sec) * 10000000ULL + + static_cast(ts.tv_nsec) / 100ULL + EPOCH_DIFF; + FILETIME ft; + ft.dwLowDateTime = static_cast(winTime & 0xFFFFFFFF); + ft.dwHighDateTime = static_cast(winTime >> 32); + return ft; +} + +// Convert std::wstring path to narrow string for POSIX APIs +static std::string toNarrow(const std::wstring& ws) +{ + return QString::fromStdWString(ws).toStdString(); +} + +// Convert narrow string to std::wstring +static std::wstring toWide(const std::string& s) +{ + return QString::fromStdString(s).toStdWString(); +} + +static std::string decodeProcMountField(const std::string& in) +{ + std::string out; + out.reserve(in.size()); + + for (size_t i = 0; i < in.size();) { + if (in[i] == '\\' && i + 3 < in.size() && std::isdigit(in[i + 1]) && + std::isdigit(in[i + 2]) && std::isdigit(in[i + 3])) { + const std::string oct = in.substr(i + 1, 3); + const int value = std::stoi(oct, nullptr, 8); + out.push_back(static_cast(value)); + i += 4; + continue; + } + + out.push_back(in[i]); + ++i; + } + + return out; +} + +static bool isMountPoint(const std::string& path) +{ + const std::string cleanPath = QDir::cleanPath(QString::fromStdString(path)).toStdString(); + std::ifstream mounts("/proc/mounts"); + if (!mounts.is_open()) { + return false; + } + + std::string line; + while (std::getline(mounts, line)) { + std::istringstream iss(line); + std::string device; + std::string mountPoint; + if (!(iss >> device >> mountPoint)) { + continue; + } + + const std::string decoded = + QDir::cleanPath(QString::fromStdString(decodeProcMountField(mountPoint))) + .toStdString(); + if (decoded == cleanPath) { + return true; + } + } + + return false; +} + +static bool runUnmountCommand(const QString& program, const QStringList& args) +{ + QProcess p; + p.start(program, args); + if (!p.waitForFinished(3000)) { + p.kill(); + return false; + } + + return p.exitStatus() == QProcess::NormalExit && p.exitCode() == 0; +} + +static bool tryRecoverStaleMount(const std::string& dirPath) +{ + if (!isMountPoint(dirPath)) { + return false; + } + + const QString clean = QDir::cleanPath(QString::fromStdString(dirPath)); + log::warn("stale mount detected at '{}', attempting recovery", clean); + + runUnmountCommand("fusermount3", {"-u", clean}); + runUnmountCommand("fusermount", {"-u", clean}); + runUnmountCommand("umount", {clean}); + runUnmountCommand("umount", {"-l", clean}); + runUnmountCommand("fusermount3", {"-uz", clean}); + runUnmountCommand("fusermount", {"-uz", clean}); + + const bool recovered = !isMountPoint(dirPath); + if (recovered) { + log::info("recovered stale mount at '{}'", clean); + } else { + log::warn("failed to recover stale mount at '{}'", clean); + } + + return recovered; +} + +// HandleCloserThread stub for Linux (no NT handles to close) +class HandleCloserThread +{ +public: + HandleCloserThread() : m_ready(false) {} + void shrink() {} + void add(void*) {} + void wakeup() + { + std::unique_lock lock(m_mutex); + m_ready = true; + m_cv.notify_one(); + } + void run() + { + std::unique_lock lock(m_mutex); + m_cv.wait(lock, [&] { return m_ready; }); + m_ready = false; + } + +private: + std::condition_variable m_cv; + std::mutex m_mutex; + bool m_ready; +}; + +static ThreadPool g_handleClosers; + +void setHandleCloserThreadCount(std::size_t n) +{ + g_handleClosers.setMax(n); +} + +// Recursive directory walker using POSIX opendir/readdir +static void forEachEntryImpl(const std::string& dirPath, void* cx, + DirStartF* dirStartF, DirEndF* dirEndF, FileF* fileF) +{ + DIR* dir = opendir(dirPath.c_str()); + if (!dir && errno == ENOTCONN) { + if (tryRecoverStaleMount(dirPath)) { + dir = opendir(dirPath.c_str()); + } + } + if (!dir) { + log::error("failed to open directory '{}': {}", + QString::fromStdString(dirPath), strerror(errno)); + return; + } + + struct dirent* entry; + while ((entry = readdir(dir)) != nullptr) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + + std::string fullPath = dirPath + "/" + entry->d_name; + std::wstring wname = toWide(entry->d_name); + + struct stat st; + if (lstat(fullPath.c_str(), &st) != 0) { + continue; + } + + if (S_ISDIR(st.st_mode)) { + if (dirStartF && dirEndF) { + std::wstring_view nameView(wname); + dirStartF(cx, nameView); + forEachEntryImpl(fullPath, cx, dirStartF, dirEndF, fileF); + dirEndF(cx, nameView); + } + } else { + FILETIME ft = timespecToFiletime(st.st_mtim); + fileF(cx, std::wstring_view(wname), ft, static_cast(st.st_size)); + } + } + + closedir(dir); +} + +void DirectoryWalker::forEachEntry(const std::wstring& path, void* cx, + DirStartF* dirStartF, DirEndF* dirEndF, FileF* fileF) +{ + forEachEntryImpl(toNarrow(path), cx, dirStartF, dirEndF, fileF); +} + +void forEachEntry(const std::wstring& path, void* cx, DirStartF* dirStartF, + DirEndF* dirEndF, FileF* fileF) +{ + DirectoryWalker().forEachEntry(path, cx, dirStartF, dirEndF, fileF); +} + +Directory getFilesAndDirs(const std::wstring& path) +{ + struct Context + { + std::stack current; + }; + + Directory root; + + Context cx; + cx.current.push(&root); + + env::forEachEntry( + path, &cx, + [](void* pcx, std::wstring_view path) { + Context* cx = (Context*)pcx; + cx->current.top()->dirs.push_back(Directory(path)); + cx->current.push(&cx->current.top()->dirs.back()); + }, + + [](void* pcx, std::wstring_view) { + Context* cx = (Context*)pcx; + cx->current.pop(); + }, + + [](void* pcx, std::wstring_view path, FILETIME ft, uint64_t s) { + Context* cx = (Context*)pcx; + cx->current.top()->files.push_back(File(path, ft, s)); + }); + + return root; +} + +// Linux equivalent of getFilesAndDirsWithFind - same as getFilesAndDirs +Directory getFilesAndDirsWithFind(const std::wstring& path) +{ + return getFilesAndDirs(path); +} + +} // namespace env + +#endif // _WIN32 + +// Cross-platform constructors +namespace env +{ + +File::File(std::wstring_view n, FILETIME ft, uint64_t s) + : name(n.begin(), n.end()), lcname(MOShared::ToLowerCopy(name)), lastModified(ft), + size(s) +{} + +Directory::Directory() {} + +Directory::Directory(std::wstring_view n) + : name(n.begin(), n.end()), lcname(MOShared::ToLowerCopy(name)) +{} + +} // namespace env diff --git a/src/src/envfs.h b/src/src/envfs.h new file mode 100644 index 0000000..b83795a --- /dev/null +++ b/src/src/envfs.h @@ -0,0 +1,192 @@ +#ifndef ENV_ENVFS_H +#define ENV_ENVFS_H + +#include "thread_utils.h" +#include + +namespace env +{ + +struct File +{ + std::wstring name; + std::wstring lcname; + FILETIME lastModified; + uint64_t size; + + File(std::wstring_view name, FILETIME ft, uint64_t size); +}; + +struct Directory +{ + std::wstring name; + std::wstring lcname; + + std::vector dirs; + std::vector files; + + Directory(); + Directory(std::wstring_view name); +}; + +template +class ThreadPool +{ +public: + ThreadPool(std::size_t max = 1) { setMax(max); } + + ~ThreadPool() { stopAndJoin(); } + + void setMax(std::size_t n) { m_threads.resize(n); } + + void stopAndJoin() + { + for (auto& ti : m_threads) { + ti.stop = true; + ti.wakeup(); + } + + for (auto& ti : m_threads) { + if (ti.thread.joinable()) { + ti.thread.join(); + } + } + } + + void waitForAll() + { + for (;;) { + bool done = true; + + for (auto& ti : m_threads) { + if (ti.busy) { + done = false; + break; + } + } + + if (done) { + break; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + } + + T& request() + { + if (m_threads.empty()) { + std::terminate(); + } + + for (;;) { + for (auto& ti : m_threads) { + bool expected = false; + + if (ti.busy.compare_exchange_strong(expected, true)) { + ti.wakeup(); + return ti.o; + } + } + + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + } + + template + void forEach(F&& f) + { + for (auto& ti : m_threads) { + f(ti.o); + } + } + +private: + struct ThreadInfo + { + std::thread thread; + std::atomic busy; + T o; + + std::condition_variable cv; + std::mutex mutex; + bool ready; + + std::atomic stop; + + ThreadInfo() : busy(true), ready(false), stop(false) + { + thread = MOShared::startSafeThread([&] { + run(); + }); + } + + ~ThreadInfo() + { + if (thread.joinable()) { + stop = true; + wakeup(); + thread.join(); + } + } + + void wakeup() + { + { + std::scoped_lock lock(mutex); + ready = true; + } + + cv.notify_one(); + } + + void run() + { + busy = false; + + while (!stop) { + std::unique_lock lock(mutex); + cv.wait(lock, [&] { + return ready; + }); + + if (stop) { + break; + } + + o.run(); + + ready = false; + busy = false; + } + } + }; + + std::list m_threads; +}; + +using DirStartF = void(void*, std::wstring_view); +using DirEndF = void(void*, std::wstring_view); +using FileF = void(void*, std::wstring_view, FILETIME, uint64_t); + +void setHandleCloserThreadCount(std::size_t n); + +class DirectoryWalker +{ +public: + void forEachEntry(const std::wstring& path, void* cx, DirStartF* dirStartF, + DirEndF* dirEndF, FileF* fileF); + +private: + std::vector> m_buffers; +}; + +void forEachEntry(const std::wstring& path, void* cx, DirStartF* dirStartF, + DirEndF* dirEndF, FileF* fileF); + +Directory getFilesAndDirs(const std::wstring& path); +Directory getFilesAndDirsWithFind(const std::wstring& path); + +} // namespace env + +#endif // ENV_ENVFS_H diff --git a/src/src/envmetrics.cpp b/src/src/envmetrics.cpp new file mode 100644 index 0000000..828848d --- /dev/null +++ b/src/src/envmetrics.cpp @@ -0,0 +1,379 @@ +#include "envmetrics.h" + +#ifdef _WIN32 + +#include "env.h" +#include +#include +#include +#include +#include + +namespace env +{ + +using namespace MOBase; + +// fallback for windows 7 +// +int getDesktopDpi() +{ + // desktop DC + DesktopDCPtr dc(GetDC(0)); + + if (!dc) { + const auto e = GetLastError(); + log::error("can't get desktop DC, {}", formatSystemMessage(e)); + return 0; + } + + return GetDeviceCaps(dc.get(), LOGPIXELSX); +} + +// finds a monitor by device name; there's no real good way to do that except +// by enumerating all the monitors and checking their name +// +HMONITOR findMonitor(const QString& name) +{ + // passed to the enumeration callback + struct Data + { + QString name; + HMONITOR hm; + }; + + Data data = {name, 0}; + + // callback + auto callback = [](HMONITOR hm, HDC, RECT*, LPARAM lp) { + auto& data = *reinterpret_cast(lp); + + MONITORINFOEX mi = {}; + mi.cbSize = sizeof(mi); + + // monitor info will include the name + if (!GetMonitorInfoW(hm, &mi)) { + const auto e = GetLastError(); + log::error("GetMonitorInfo() failed for '{}', {}", data.name, + formatSystemMessage(e)); + + // error for this monitor, but continue + return TRUE; + } + + if (QString::fromWCharArray(mi.szDevice) == data.name) { + // found, stop + data.hm = hm; + return FALSE; + } + + // not found, continue to the next monitor + return TRUE; + }; + + // for each monitor + EnumDisplayMonitors(0, nullptr, callback, reinterpret_cast(&data)); + + return data.hm; +} + +// returns the dpi for the given monitor; for systems that do not support +// per-monitor dpi (such as windows 7), this is the desktop dpi +// +int getDpi(const QString& monitorDevice) +{ + using GetDpiForMonitorFunction = + HRESULT WINAPI(HMONITOR, MONITOR_DPI_TYPE, UINT*, UINT*); + + static LibraryPtr shcore; + static GetDpiForMonitorFunction* GetDpiForMonitor = nullptr; + static bool checked = false; + + if (!checked) { + // try to find GetDpiForMonitor() from shcored.dll + + shcore.reset(LoadLibraryW(L"Shcore.dll")); + + if (shcore) { + // windows 8.1+ only + GetDpiForMonitor = reinterpret_cast( + GetProcAddress(shcore.get(), "GetDpiForMonitor")); + } + + checked = true; + } + + if (!GetDpiForMonitor) { + // get the desktop dpi instead + return getDesktopDpi(); + } + + // there's no way to get an HMONITOR from a device name, so all monitors + // will have to be enumerated and their name checked + HMONITOR hm = findMonitor(monitorDevice); + if (!hm) { + log::error("can't get dpi for monitor '{}', not found", monitorDevice); + return 0; + } + + UINT dpiX = 0, dpiY = 0; + const auto r = GetDpiForMonitor(hm, MDT_EFFECTIVE_DPI, &dpiX, &dpiY); + + if (FAILED(r)) { + log::error("GetDpiForMonitor() failed for '{}', {}", monitorDevice, + formatSystemMessage(r)); + + return 0; + } + + // dpiX and dpiY are always identical, as per the documentation + return dpiX; +} + +Display::Display(QString adapter, QString monitorDevice, bool primary) + : m_adapter(std::move(adapter)), m_monitorDevice(std::move(monitorDevice)), + m_primary(primary), m_resX(0), m_resY(0), m_dpi(0), m_refreshRate(0) +{ + getSettings(); + m_dpi = getDpi(m_monitorDevice); +} + +const QString& Display::adapter() const +{ + return m_adapter; +} + +const QString& Display::monitorDevice() const +{ + return m_monitorDevice; +} + +bool Display::primary() +{ + return m_primary; +} + +int Display::resX() const +{ + return m_resX; +} + +int Display::resY() const +{ + return m_resY; +} + +int Display::dpi() +{ + return m_dpi; +} + +int Display::refreshRate() const +{ + return m_refreshRate; +} + +QString Display::toString() const +{ + return QString("%1*%2 %3hz dpi=%4 on %5%6") + .arg(m_resX) + .arg(m_resY) + .arg(m_refreshRate) + .arg(m_dpi) + .arg(m_adapter) + .arg(m_primary ? " (primary)" : ""); +} + +void Display::getSettings() +{ + DEVMODEW dm = {}; + dm.dmSize = sizeof(dm); + + const auto wsDevice = m_monitorDevice.toStdWString(); + + if (!EnumDisplaySettingsW(wsDevice.c_str(), ENUM_CURRENT_SETTINGS, &dm)) { + log::error("EnumDisplaySettings() failed for '{}'", m_monitorDevice); + return; + } + + // all these fields should be available + + if (dm.dmFields & DM_DISPLAYFREQUENCY) { + m_refreshRate = dm.dmDisplayFrequency; + } + + if (dm.dmFields & DM_PELSWIDTH) { + m_resX = dm.dmPelsWidth; + } + + if (dm.dmFields & DM_PELSHEIGHT) { + m_resY = dm.dmPelsHeight; + } +} + +Metrics::Metrics() +{ + getDisplays(); +} + +const std::vector& Metrics::displays() const +{ + return m_displays; +} + +QRect Metrics::desktopGeometry() const +{ + QRect r; + + for (auto* s : QGuiApplication::screens()) { + r = r.united(s->geometry()); + } + + return r; +} + +void Metrics::getDisplays() +{ + // don't bother if it goes over 100 + for (int i = 0; i < 100; ++i) { + DISPLAY_DEVICEW device = {}; + device.cb = sizeof(device); + + if (!EnumDisplayDevicesW(nullptr, i, &device, 0)) { + // no more + break; + } + + // EnumDisplayDevices() seems to be returning a lot of devices that are + // not actually monitors, but those don't have the + // DISPLAY_DEVICE_ATTACHED_TO_DESKTOP bit set + if ((device.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP) == 0) { + continue; + } + + m_displays.emplace_back(QString::fromWCharArray(device.DeviceString), + QString::fromWCharArray(device.DeviceName), + (device.StateFlags & DISPLAY_DEVICE_PRIMARY_DEVICE)); + } +} + +} // namespace env + +#else // Linux + +#include +#include +#include + +namespace env +{ + +Display::Display(QString adapter, QString monitorDevice, bool primary) + : m_adapter(std::move(adapter)), m_monitorDevice(std::move(monitorDevice)), + m_primary(primary), m_resX(0), m_resY(0), m_dpi(0), m_refreshRate(0) +{ + getSettings(); +} + +const QString& Display::adapter() const +{ + return m_adapter; +} + +const QString& Display::monitorDevice() const +{ + return m_monitorDevice; +} + +bool Display::primary() +{ + return m_primary; +} + +int Display::resX() const +{ + return m_resX; +} + +int Display::resY() const +{ + return m_resY; +} + +int Display::dpi() +{ + return m_dpi; +} + +int Display::refreshRate() const +{ + return m_refreshRate; +} + +QString Display::toString() const +{ + return QString("%1*%2 %3hz dpi=%4 on %5%6") + .arg(m_resX) + .arg(m_resY) + .arg(m_refreshRate) + .arg(m_dpi) + .arg(m_adapter) + .arg(m_primary ? " (primary)" : ""); +} + +void Display::getSettings() +{ + // Use QScreen to get display settings on Linux + const auto screens = QGuiApplication::screens(); + for (auto* screen : screens) { + if (screen->name() == m_monitorDevice) { + const auto geo = screen->geometry(); + m_resX = geo.width(); + m_resY = geo.height(); + m_refreshRate = qRound(screen->refreshRate()); + m_dpi = qRound(screen->logicalDotsPerInch()); + return; + } + } + + // Fallback: if monitor name didn't match, use primary screen + if (auto* primary = QGuiApplication::primaryScreen()) { + const auto geo = primary->geometry(); + m_resX = geo.width(); + m_resY = geo.height(); + m_refreshRate = qRound(primary->refreshRate()); + m_dpi = qRound(primary->logicalDotsPerInch()); + } +} + +Metrics::Metrics() +{ + getDisplays(); +} + +const std::vector& Metrics::displays() const +{ + return m_displays; +} + +QRect Metrics::desktopGeometry() const +{ + if (auto* primary = QGuiApplication::primaryScreen()) { + return primary->virtualGeometry(); + } + return QRect(); +} + +void Metrics::getDisplays() +{ + const auto screens = QGuiApplication::screens(); + for (auto* screen : screens) { + const bool isPrimary = (screen == QGuiApplication::primaryScreen()); + m_displays.emplace_back(screen->manufacturer() + " " + screen->model(), + screen->name(), isPrimary); + } +} + +} // namespace env + +#endif // _WIN32 diff --git a/src/src/envmetrics.h b/src/src/envmetrics.h new file mode 100644 index 0000000..b0033d6 --- /dev/null +++ b/src/src/envmetrics.h @@ -0,0 +1,80 @@ +#ifndef ENV_METRICS_H +#define ENV_METRICS_H + +#include +#include + +namespace env +{ + +// information about a monitor +// +class Display +{ +public: + Display(QString adapter, QString monitorDevice, bool primary); + + // display name of the adapter running the monitor + // + const QString& adapter() const; + + // internal device name of the monitor, this is not a display name + // + const QString& monitorDevice() const; + + // whether this monitor is the primary + // + bool primary(); + + // resolution + // + int resX() const; + int resY() const; + + // dpi + // + int dpi(); + + // refresh rate in hz + // + int refreshRate() const; + + // string representation + // + QString toString() const; + +private: + QString m_adapter; + QString m_monitorDevice; + bool m_primary; + int m_resX, m_resY; + int m_dpi; + int m_refreshRate; + + void getSettings(); +}; + +// holds various information about Windows metrics +// +class Metrics +{ +public: + Metrics(); + + // list of displays on the system + // + const std::vector& displays() const; + + // full resolution + // + QRect desktopGeometry() const; + +private: + std::vector m_displays; + + void getDisplays(); +}; + +} // namespace env + +#endif // ENV_METRICS_H diff --git a/src/src/envmodule.cpp b/src/src/envmodule.cpp new file mode 100644 index 0000000..2c8d5be --- /dev/null +++ b/src/src/envmodule.cpp @@ -0,0 +1,1108 @@ +#include "envmodule.h" +#include "env.h" +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace env +{ + +using namespace MOBase; + +// the rationale for logging md5 was to make sure the various files were the +// same as in the released version; this turned out to be of dubious interest, +// while adding to the startup time +constexpr bool UseMD5 = false; + +#ifdef _WIN32 + +Module::Module(QString path, std::size_t fileSize) + : m_path(std::move(path)), m_fileSize(fileSize) +{ + const auto fi = getFileInfo(); + + m_version = getVersion(fi.ffi); + m_timestamp = getTimestamp(fi.ffi); + m_versionString = fi.fileDescription; + + if (UseMD5) { + m_md5 = getMD5(); + } +} + +#else // Linux + +Module::Module(QString path, std::size_t fileSize) + : m_path(std::move(path)), m_fileSize(fileSize) +{ + const auto fi = getFileInfo(); + + m_timestamp = getTimestamp(); + m_versionString = fi.fileDescription; + + if (UseMD5) { + m_md5 = getMD5(); + } +} + +#endif + +const QString& Module::path() const +{ + return m_path; +} + +QString Module::displayPath() const +{ + return QDir::fromNativeSeparators(m_path.toLower()); +} + +std::size_t Module::fileSize() const +{ + return m_fileSize; +} + +const QString& Module::version() const +{ + return m_version; +} + +const QString& Module::versionString() const +{ + return m_versionString; +} + +const QDateTime& Module::timestamp() const +{ + return m_timestamp; +} + +const QString& Module::md5() const +{ + return m_md5; +} + +QString Module::timestampString() const +{ + if (!m_timestamp.isValid()) { + return "(no timestamp)"; + } + + return m_timestamp.toString(Qt::DateFormat::ISODate); +} + +QString Module::toString() const +{ + QStringList sl; + + // file size + sl.push_back(displayPath()); + sl.push_back(QString("%1 B").arg(m_fileSize)); + + // version + if (m_version.isEmpty() && m_versionString.isEmpty()) { + sl.push_back("(no version)"); + } else { + if (!m_version.isEmpty()) { + sl.push_back(m_version); + } + + if (!m_versionString.isEmpty() && m_versionString != m_version) { + sl.push_back(versionString()); + } + } + + // timestamp + if (m_timestamp.isValid()) { + sl.push_back(m_timestamp.toString(Qt::DateFormat::ISODate)); + } else { + sl.push_back("(no timestamp)"); + } + + // md5 + if (!m_md5.isEmpty()) { + sl.push_back(m_md5); + } + + return sl.join(", "); +} + +#ifdef _WIN32 + +Module::FileInfo Module::getFileInfo() const +{ + const auto wspath = m_path.toStdWString(); + + // getting version info size + DWORD dummy = 0; + const DWORD size = GetFileVersionInfoSizeW(wspath.c_str(), &dummy); + + if (size == 0) { + const auto e = GetLastError(); + + if (e == ERROR_RESOURCE_TYPE_NOT_FOUND) { + // not an error, no version information built into that module + return {}; + } + + if (e == ERROR_RESOURCE_DATA_NOT_FOUND) { + // not an error, no version information built into that module; + // happens often in wine + return {}; + } + + log::debug("GetFileVersionInfoSizeW() failed on '{}', {}", m_path, + formatSystemMessage(e)); + + return {}; + } + + // getting version info + auto buffer = std::make_unique(size); + + if (!GetFileVersionInfoW(wspath.c_str(), 0, size, buffer.get())) { + const auto e = GetLastError(); + + log::error("GetFileVersionInfoW() failed on '{}', {}", m_path, + formatSystemMessage(e)); + + return {}; + } + + // the version info has two major parts: a fixed version and a localizable + // set of strings + + FileInfo fi; + fi.ffi = getFixedFileInfo(buffer.get()); + fi.fileDescription = getFileDescription(buffer.get()); + + return fi; +} + +VS_FIXEDFILEINFO Module::getFixedFileInfo(std::byte* buffer) const +{ + void* valuePointer = nullptr; + unsigned int valueSize = 0; + + // the fixed version info is in the root + const auto ret = VerQueryValueW(buffer, L"\\", &valuePointer, &valueSize); + + if (!ret || !valuePointer || valueSize == 0) { + // not an error, no fixed file info + return {}; + } + + const auto* fi = static_cast(valuePointer); + + // signature is always 0xfeef04bd + if (fi->dwSignature != 0xfeef04bd) { + log::error("bad file info signature {:#x} for '{}'", fi->dwSignature, m_path); + + return {}; + } + + return *fi; +} + +QString Module::getFileDescription(std::byte* buffer) const +{ + struct LANGANDCODEPAGE + { + WORD wLanguage; + WORD wCodePage; + }; + + void* valuePointer = nullptr; + unsigned int valueSize = 0; + + // getting list of available languages + auto ret = + VerQueryValueW(buffer, L"\\VarFileInfo\\Translation", &valuePointer, &valueSize); + + if (!ret || !valuePointer || valueSize == 0) { + log::error("VerQueryValueW() for translations failed on '{}'", m_path); + return {}; + } + + // number of languages + const auto count = valueSize / sizeof(LANGANDCODEPAGE); + if (count == 0) { + return {}; + } + + // using the first language in the list to get FileVersion + const auto* lcp = static_cast(valuePointer); + + const auto subBlock = QString("\\StringFileInfo\\%1%2\\FileVersion") + .arg(lcp->wLanguage, 4, 16, QChar('0')) + .arg(lcp->wCodePage, 4, 16, QChar('0')); + + ret = VerQueryValueW(buffer, subBlock.toStdWString().c_str(), &valuePointer, + &valueSize); + + if (!ret || !valuePointer || valueSize == 0) { + // not an error, no file version + return {}; + } + + // valueSize includes the null terminator + return QString::fromWCharArray(static_cast(valuePointer), valueSize - 1); +} + +QString Module::getVersion(const VS_FIXEDFILEINFO& fi) const +{ + if (fi.dwSignature == 0) { + return {}; + } + + const DWORD major = (fi.dwFileVersionMS >> 16) & 0xffff; + const DWORD minor = (fi.dwFileVersionMS >> 0) & 0xffff; + const DWORD maintenance = (fi.dwFileVersionLS >> 16) & 0xffff; + const DWORD build = (fi.dwFileVersionLS >> 0) & 0xffff; + + if (major == 0 && minor == 0 && maintenance == 0 && build == 0) { + return {}; + } + + return QString("%1.%2.%3.%4").arg(major).arg(minor).arg(maintenance).arg(build); +} + +QDateTime Module::getTimestamp(const VS_FIXEDFILEINFO& fi) const +{ + FILETIME ft = {}; + + if (fi.dwSignature == 0 || (fi.dwFileDateMS == 0 && fi.dwFileDateLS == 0)) { + // if the file info is invalid or doesn't have a date, use the creation + // time on the file + + // opening the file + HandlePtr h(CreateFileW(m_path.toStdWString().c_str(), GENERIC_READ, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, 0)); + + if (h.get() == INVALID_HANDLE_VALUE) { + const auto e = GetLastError(); + + log::debug("can't open file '{}' for timestamp, {}", m_path, + formatSystemMessage(e)); + + return {}; + } + + // getting the file time + if (!GetFileTime(h.get(), &ft, nullptr, nullptr)) { + const auto e = GetLastError(); + + log::error("can't get file time for '{}', {}", m_path, formatSystemMessage(e)); + + return {}; + } + } else { + // use the time from the file info + ft.dwHighDateTime = fi.dwFileDateMS; + ft.dwLowDateTime = fi.dwFileDateLS; + } + + // converting to SYSTEMTIME + SYSTEMTIME utc = {}; + + if (!FileTimeToSystemTime(&ft, &utc)) { + log::error( + "FileTimeToSystemTime() failed on timestamp high={:#x} low={:#x} for '{}'", + ft.dwHighDateTime, ft.dwLowDateTime, m_path); + + return {}; + } + + return QDateTime(QDate(utc.wYear, utc.wMonth, utc.wDay), + QTime(utc.wHour, utc.wMinute, utc.wSecond, utc.wMilliseconds)); +} + +bool Module::interesting() const +{ + static const auto windir = []() -> QString { + try { + return QDir::toNativeSeparators(MOBase::getKnownFolder(FOLDERID_Windows).path()) + + "\\"; + } catch (...) { + return "c:\\windows\\"; + } + }(); + + if (m_path.startsWith(windir, Qt::CaseInsensitive)) { + return false; + } + + return true; +} + +QString Module::getMD5() const +{ + static const std::set ignore = { + "\\windows\\", "\\program files\\", "\\program files (x86)\\", "\\programdata\\"}; + + // don't calculate md5 for system files, it's not really relevant and + // it takes a while + for (auto&& i : ignore) { + if (m_path.contains(i, Qt::CaseInsensitive)) { + return {}; + } + } + + // opening the file + QFile f(m_path); + + if (!f.open(QFile::ReadOnly)) { + log::error("failed to open file '{}' for md5", m_path); + return {}; + } + + // hashing + QCryptographicHash hash(QCryptographicHash::Md5); + if (!hash.addData(&f)) { + log::error("failed to calculate md5 for '{}'", m_path); + return {}; + } + + return hash.result().toHex(); +} + +#else // Linux + +Module::FileInfo Module::getFileInfo() const +{ + // On Linux, there are no PE version resources. Return empty info. + return {}; +} + +QDateTime Module::getTimestamp() const +{ + QFileInfo fi(m_path); + if (!fi.exists()) { + return {}; + } + return fi.lastModified(); +} + +bool Module::interesting() const +{ + // Filter out system libraries + if (m_path.startsWith("/usr/lib/", Qt::CaseInsensitive) || + m_path.startsWith("/usr/lib64/", Qt::CaseInsensitive) || + m_path.startsWith("/lib/", Qt::CaseInsensitive) || + m_path.startsWith("/lib64/", Qt::CaseInsensitive)) { + return false; + } + + return true; +} + +QString Module::getMD5() const +{ + static const std::set ignore = { + "/usr/lib/", "/usr/lib64/", "/usr/share/", "/lib/", "/lib64/"}; + + // don't calculate md5 for system files + for (auto&& i : ignore) { + if (m_path.startsWith(i, Qt::CaseInsensitive)) { + return {}; + } + } + + QFile f(m_path); + if (!f.open(QFile::ReadOnly)) { + log::error("failed to open file '{}' for md5", m_path); + return {}; + } + + QCryptographicHash hash(QCryptographicHash::Md5); + if (!hash.addData(&f)) { + log::error("failed to calculate md5 for '{}'", m_path); + return {}; + } + + return hash.result().toHex(); +} + +#endif // _WIN32 + +// -- Process implementation -- + +Process::Process() + : m_pid(0) +{} + +#ifdef _WIN32 + +Process::Process(HANDLE h) : Process(::GetProcessId(h), 0, {}) {} + +Process::Process(DWORD pid, DWORD ppid, QString name) + : m_pid(pid), m_ppid(ppid), m_name(std::move(name)) +{} + +#else // Linux + +Process::Process(pid_t pid, pid_t ppid, QString name) + : m_pid(pid), m_ppid(ppid), m_name(std::move(name)) +{} + +#endif + +bool Process::isValid() const +{ + return (m_pid != 0); +} + +#ifdef _WIN32 +DWORD Process::pid() const +#else +pid_t Process::pid() const +#endif +{ + return m_pid; +} + +#ifdef _WIN32 +DWORD Process::ppid() const +#else +pid_t Process::ppid() const +#endif +{ + if (!m_ppid) { + m_ppid = getProcessParentID(m_pid); + } + + return *m_ppid; +} + +const QString& Process::name() const +{ + if (!m_name) { + m_name = getProcessName(m_pid); + } + + return *m_name; +} + +#ifdef _WIN32 + +HandlePtr Process::openHandleForWait() const +{ + const auto rights = + PROCESS_QUERY_LIMITED_INFORMATION | // exit code, image name, etc. + SYNCHRONIZE | // wait functions + PROCESS_SET_QUOTA | PROCESS_TERMINATE; // add to job + + // don't log errors, failure can happen if the process doesn't exist + return HandlePtr(OpenProcess(rights, FALSE, m_pid)); +} + +bool Process::canAccess() const +{ + HandlePtr h(OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, m_pid)); + + if (!h) { + const auto e = GetLastError(); + if (e == ERROR_ACCESS_DENIED) { + return false; + } + } + + return true; +} + +#else // Linux + +HandlePtr Process::openHandleForWait() const +{ + // On Linux, just wrap the pid. The caller can use waitpid() or + // kill(pid, 0) to check on the process. + if (kill(m_pid, 0) == 0) { + return HandlePtr(HandleCloser::pointer(m_pid)); + } + return {}; +} + +bool Process::canAccess() const +{ + // kill with signal 0 checks if we can send signals to the process + return (kill(m_pid, 0) == 0); +} + +#endif + +void Process::addChild(Process p) +{ + m_children.push_back(p); +} + +std::vector& Process::children() +{ + return m_children; +} + +const std::vector& Process::children() const +{ + return m_children; +} + +// -- Free functions -- + +#ifdef _WIN32 + +std::vector getLoadedModules() +{ + HandlePtr snapshot(CreateToolhelp32Snapshot(TH32CS_SNAPMODULE32 | TH32CS_SNAPMODULE, + GetCurrentProcessId())); + + if (snapshot.get() == INVALID_HANDLE_VALUE) { + const auto e = GetLastError(); + log::error("CreateToolhelp32Snapshot() failed, {}", formatSystemMessage(e)); + return {}; + } + + MODULEENTRY32 me = {}; + me.dwSize = sizeof(me); + + // first module, this shouldn't fail because there's at least the executable + if (!Module32First(snapshot.get(), &me)) { + const auto e = GetLastError(); + log::error("Module32First() failed, {}", formatSystemMessage(e)); + return {}; + } + + std::vector v; + + for (;;) { + const auto path = QString::fromWCharArray(me.szExePath); + if (!path.isEmpty()) { + v.push_back(Module(path, me.modBaseSize)); + } + + // next module + if (!Module32Next(snapshot.get(), &me)) { + const auto e = GetLastError(); + + // no more modules is not an error + if (e != ERROR_NO_MORE_FILES) { + log::error("Module32Next() failed, {}", formatSystemMessage(e)); + } + + break; + } + } + + // sorting by display name + std::sort(v.begin(), v.end(), [](auto&& a, auto&& b) { + return (a.displayPath().compare(b.displayPath(), Qt::CaseInsensitive) < 0); + }); + + return v; +} + +template +void forEachRunningProcess(F&& f) +{ + HandlePtr snapshot(CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)); + + if (snapshot.get() == INVALID_HANDLE_VALUE) { + const auto e = GetLastError(); + log::error("CreateToolhelp32Snapshot() failed, {}", formatSystemMessage(e)); + return; + } + + PROCESSENTRY32 entry = {}; + entry.dwSize = sizeof(entry); + + // first process, this shouldn't fail because there's at least one process + // running + if (!Process32First(snapshot.get(), &entry)) { + const auto e = GetLastError(); + log::error("Process32First() failed, {}", formatSystemMessage(e)); + return; + } + + for (;;) { + if (!f(entry)) { + break; + } + + // next process + if (!Process32Next(snapshot.get(), &entry)) { + const auto e = GetLastError(); + + // no more processes is not an error + if (e != ERROR_NO_MORE_FILES) + log::error("Process32Next() failed, {}", formatSystemMessage(e)); + + break; + } + } +} + +std::vector getRunningProcesses() +{ + std::vector v; + + forEachRunningProcess([&](auto&& entry) { + v.push_back(Process(entry.th32ProcessID, entry.th32ParentProcessID, + QString::fromStdWString(entry.szExeFile))); + + return true; + }); + + return v; +} + +void findChildren(Process& parent, const std::vector& processes) +{ + for (auto&& p : processes) { + if (p.ppid() == parent.pid()) { + Process child = p; + findChildren(child, processes); + + parent.addChild(child); + } + } +} + +Process getProcessTreeFromProcess(HANDLE h) +{ + Process root; + + const auto parentPID = ::GetProcessId(h); + const auto v = getRunningProcesses(); + + for (auto&& p : v) { + if (p.pid() == parentPID) { + Process child = p; + findChildren(child, v); + root.addChild(child); + break; + } + } + + return root; +} + +std::vector processesInJob(HANDLE h) +{ + const int MaxTries = 5; + + // doubled MaxTries times on failure + DWORD maxIds = 100; + + // for logging + DWORD lastCount = 0, lastAssigned = 0; + + for (int tries = 0; tries < MaxTries; ++tries) { + const DWORD idsSize = sizeof(ULONG_PTR) * maxIds; + const DWORD bufferSize = sizeof(JOBOBJECT_BASIC_PROCESS_ID_LIST) + idsSize; + + MallocPtr buffer(std::malloc(bufferSize)); + auto* ids = static_cast(buffer.get()); + + const auto r = QueryInformationJobObject(h, JobObjectBasicProcessIdList, ids, + bufferSize, nullptr); + + if (!r) { + const auto e = GetLastError(); + if (e != ERROR_MORE_DATA) { + log::error("failed to get process ids in job, {}", formatSystemMessage(e)); + return {}; + } + } + + if (ids->NumberOfProcessIdsInList >= ids->NumberOfAssignedProcesses) { + std::vector v; + for (DWORD i = 0; i < ids->NumberOfProcessIdsInList; ++i) { + v.push_back(ids->ProcessIdList[i]); + } + + return v; + } + + // try again with a larger buffer + maxIds *= 2; + + // for logging + lastCount = ids->NumberOfProcessIdsInList; + lastAssigned = ids->NumberOfAssignedProcesses; + } + + log::error("failed to get processes in job, can't get a buffer large enough, " + "{}/{} ids", + lastCount, lastAssigned); + + return {}; +} + +void findChildProcesses(Process& parent, std::vector& processes) +{ + // find all processes that are direct children of `parent` + auto itor = processes.begin(); + + while (itor != processes.end()) { + if (itor->ppid() == parent.pid()) { + parent.addChild(*itor); + itor = processes.erase(itor); + } else { + ++itor; + } + } + + // find all processes that are direct children of `parent`'s children + for (auto&& c : parent.children()) { + findChildProcesses(c, processes); + } +} + +Process getProcessTreeFromJob(HANDLE h) +{ + const auto ids = processesInJob(h); + if (ids.empty()) { + return {}; + } + + std::vector ps; + + forEachRunningProcess([&](auto&& entry) { + for (auto&& id : ids) { + if (entry.th32ProcessID == id) { + ps.push_back(Process(entry.th32ProcessID, entry.th32ParentProcessID, + QString::fromStdWString(entry.szExeFile))); + + break; + } + } + + return true; + }); + + Process root; + + { + // getting processes whose parent is not in the list + for (auto&& possibleRoot : ps) { + const auto ppid = possibleRoot.ppid(); + bool found = false; + + for (auto&& p : ps) { + if (p.pid() == ppid) { + found = true; + break; + } + } + + if (!found) { + // this is a root process + root.addChild(possibleRoot); + } + } + + // removing root processes from the list + auto newEnd = std::remove_if(ps.begin(), ps.end(), [&](auto&& p) { + for (auto&& rp : root.children()) { + if (rp.pid() == p.pid()) { + return true; + } + } + + return false; + }); + + ps.erase(newEnd, ps.end()); + } + + // at this point, `processes` should only contain processes that are direct + // or indirect children of the ones in `root` + + if (ps.empty()) { + // and that's all there is + return root; + } + + { + // recursively find children + for (auto&& r : root.children()) { + findChildProcesses(r, ps); + } + } + + return root; +} + +bool isJobHandle(HANDLE h) +{ + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION info = {}; + + const auto r = ::QueryInformationJobObject(h, JobObjectBasicAccountingInformation, + &info, sizeof(info), nullptr); + + return r; +} + +Process getProcessTree(HANDLE h) +{ + if (isJobHandle(h)) { + return getProcessTreeFromJob(h); + } else { + return getProcessTreeFromProcess(h); + } +} + +QString getProcessName(DWORD pid) +{ + HandlePtr h(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid)); + + if (!h) { + const auto e = GetLastError(); + log::error("can't get name of process {}, {}", pid, formatSystemMessage(e)); + return {}; + } + + return getProcessName(h.get()); +} + +QString getProcessName(HANDLE process) +{ + const QString badName = "unknown"; + + if (process == 0 || process == INVALID_HANDLE_VALUE) { + return badName; + } + + const DWORD bufferSize = MAX_PATH; + wchar_t buffer[bufferSize + 1] = {}; + + const auto realSize = ::GetProcessImageFileNameW(process, buffer, bufferSize); + + if (realSize == 0) { + const auto e = ::GetLastError(); + log::error("GetProcessImageFileNameW() failed, {}", formatSystemMessage(e)); + return badName; + } + + auto s = QString::fromWCharArray(buffer, realSize); + + const auto lastSlash = s.lastIndexOf("\\"); + if (lastSlash != -1) { + s = s.mid(lastSlash + 1); + } + + return s; +} + +DWORD getProcessParentID(DWORD pid) +{ + DWORD ppid = 0; + + forEachRunningProcess([&](auto&& entry) { + if (entry.th32ProcessID == pid) { + ppid = entry.th32ParentProcessID; + return false; + } + + return true; + }); + + return ppid; +} + +DWORD getProcessParentID(HANDLE handle) +{ + return getProcessParentID(GetProcessId(handle)); +} + +#else // Linux + +std::vector getLoadedModules() +{ + std::vector v; + + // Parse /proc/self/maps to find loaded shared libraries + std::ifstream maps("/proc/self/maps"); + if (!maps.is_open()) { + log::error("failed to open /proc/self/maps"); + return {}; + } + + std::set seen; + std::string line; + + while (std::getline(maps, line)) { + // Each line has format: address perms offset dev inode pathname + // We want the pathname (last field) if it's a .so file + auto spacePos = line.rfind(' '); + if (spacePos == std::string::npos) { + continue; + } + + std::string path = line.substr(spacePos + 1); + if (path.empty() || path[0] != '/') { + continue; + } + + QString qpath = QString::fromStdString(path); + + // Skip duplicates (same library mapped multiple times) + if (seen.count(qpath)) { + continue; + } + seen.insert(qpath); + + QFileInfo fi(qpath); + if (fi.exists()) { + v.push_back(Module(qpath, fi.size())); + } + } + + // sorting by display name + std::sort(v.begin(), v.end(), [](auto&& a, auto&& b) { + return (a.displayPath().compare(b.displayPath(), Qt::CaseInsensitive) < 0); + }); + + return v; +} + +std::vector getRunningProcesses() +{ + std::vector v; + + DIR* procDir = opendir("/proc"); + if (!procDir) { + log::error("failed to open /proc"); + return {}; + } + + struct dirent* entry; + while ((entry = readdir(procDir)) != nullptr) { + // Only process numeric directories (PIDs) + bool isNumeric = true; + for (const char* p = entry->d_name; *p; ++p) { + if (*p < '0' || *p > '9') { + isNumeric = false; + break; + } + } + + if (!isNumeric) { + continue; + } + + pid_t pid = static_cast(std::strtol(entry->d_name, nullptr, 10)); + + // Read process name from /proc/[pid]/comm + QString name; + { + std::string commPath = "/proc/" + std::string(entry->d_name) + "/comm"; + std::ifstream commFile(commPath); + if (commFile.is_open()) { + std::string commName; + std::getline(commFile, commName); + name = QString::fromStdString(commName); + } + } + + // Read parent PID from /proc/[pid]/status + pid_t ppid = 0; + { + std::string statusPath = "/proc/" + std::string(entry->d_name) + "/status"; + std::ifstream statusFile(statusPath); + if (statusFile.is_open()) { + std::string statusLine; + while (std::getline(statusFile, statusLine)) { + if (statusLine.compare(0, 5, "PPid:") == 0) { + ppid = static_cast( + std::strtol(statusLine.c_str() + 5, nullptr, 10)); + break; + } + } + } + } + + v.push_back(Process(pid, ppid, name)); + } + + closedir(procDir); + return v; +} + +void findChildren(Process& parent, const std::vector& processes) +{ + for (auto&& p : processes) { + if (p.ppid() == parent.pid()) { + Process child = p; + findChildren(child, processes); + + parent.addChild(child); + } + } +} + +Process getProcessTree(pid_t pid) +{ + Process root; + + const auto v = getRunningProcesses(); + + for (auto&& p : v) { + if (p.pid() == pid) { + Process child = p; + findChildren(child, v); + root.addChild(child); + break; + } + } + + return root; +} + +QString getProcessName(pid_t pid) +{ + std::string commPath = "/proc/" + std::to_string(pid) + "/comm"; + std::ifstream commFile(commPath); + + if (!commFile.is_open()) { + log::error("can't get name of process {}", pid); + return "unknown"; + } + + std::string name; + std::getline(commFile, name); + return QString::fromStdString(name); +} + +pid_t getProcessParentID(pid_t pid) +{ + std::string statusPath = "/proc/" + std::to_string(pid) + "/status"; + std::ifstream statusFile(statusPath); + + if (!statusFile.is_open()) { + return 0; + } + + std::string line; + while (std::getline(statusFile, line)) { + if (line.compare(0, 5, "PPid:") == 0) { + return static_cast(std::strtol(line.c_str() + 5, nullptr, 10)); + } + } + + return 0; +} + +#endif // _WIN32 + +} // namespace env diff --git a/src/src/envmodule.h b/src/src/envmodule.h new file mode 100644 index 0000000..1dfc927 --- /dev/null +++ b/src/src/envmodule.h @@ -0,0 +1,228 @@ +#ifndef ENV_MODULE_H +#define ENV_MODULE_H + +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#endif + +namespace env +{ + +#ifdef _WIN32 +// used by HandlePtr, calls CloseHandle() as the deleter +// +struct HandleCloser +{ + using pointer = HANDLE; + + void operator()(HANDLE h) + { + if (h != INVALID_HANDLE_VALUE) { + ::CloseHandle(h); + } + } +}; + +using HandlePtr = std::unique_ptr; +#else +// On Linux, HandlePtr uses HANDLE (void*) from windows_compat.h for +// compatibility with the rest of the codebase. Handles don't need +// closing on Linux since they are just compatibility shims. +struct HandleCloser +{ + using pointer = HANDLE; + + void operator()(HANDLE) { /* no-op on Linux */ } +}; + +using HandlePtr = std::unique_ptr; +#endif + +// represents one module +// +class Module +{ +public: + Module(QString path, std::size_t fileSize); + + // returns the module's path + // + const QString& path() const; + + // returns the module's path in lowercase and using forward slashes + // + QString displayPath() const; + + // returns the size in bytes, may be 0 + // + std::size_t fileSize() const; + + // returns the x.x.x.x version embedded from the version info, may be empty + // + const QString& version() const; + + // returns the FileVersion entry from the resource file, returns + // "(no version)" if not available + // + const QString& versionString() const; + + // returns the build date from the version info, or the creation time of the + // file on the filesystem, may be empty + // + const QDateTime& timestamp() const; + + // returns the md5 of the file, may be empty for system files + // + const QString& md5() const; + + // converts timestamp() to a string for display, returns "(no timestamp)" if + // not available + // + QString timestampString() const; + + // returns false for modules in system directories + // + bool interesting() const; + + // returns a string with all the above information on one line + // + QString toString() const; + +private: +#ifdef _WIN32 + // contains the information from the version resource + // + struct FileInfo + { + VS_FIXEDFILEINFO ffi; + QString fileDescription; + }; +#else + struct FileInfo + { + QString fileDescription; + }; +#endif + + QString m_path; + std::size_t m_fileSize; + QString m_version; + QDateTime m_timestamp; + QString m_versionString; + QString m_md5; + +#ifdef _WIN32 + // returns information from the version resource + // + FileInfo getFileInfo() const; + + // uses VS_FIXEDFILEINFO to build the version string + // + QString getVersion(const VS_FIXEDFILEINFO& fi) const; + + // uses the file date from VS_FIXEDFILEINFO if available, or gets the + // creation date on the file + // + QDateTime getTimestamp(const VS_FIXEDFILEINFO& fi) const; + + // gets VS_FIXEDFILEINFO from the file version info buffer + // + VS_FIXEDFILEINFO getFixedFileInfo(std::byte* buffer) const; + + // gets FileVersion from the file version info buffer + // + QString getFileDescription(std::byte* buffer) const; +#else + // returns information about the file (Linux: uses QFileInfo) + // + FileInfo getFileInfo() const; + + // gets the file timestamp from the filesystem + // + QDateTime getTimestamp() const; +#endif + + // returns the md5 hash unless the path is in a system directory + // + QString getMD5() const; +}; + +// represents one process +// +class Process +{ +public: + Process(); +#ifdef _WIN32 + explicit Process(HANDLE h); + Process(DWORD pid, DWORD ppid, QString name); +#else + Process(pid_t pid, pid_t ppid, QString name); +#endif + + bool isValid() const; + +#ifdef _WIN32 + DWORD pid() const; + DWORD ppid() const; +#else + pid_t pid() const; + pid_t ppid() const; +#endif + + const QString& name() const; + + HandlePtr openHandleForWait() const; + + // whether this process can be accessed; fails if the current process doesn't + // have the proper permissions + // + bool canAccess() const; + + void addChild(Process p); + std::vector& children(); + const std::vector& children() const; + +private: +#ifdef _WIN32 + DWORD m_pid; + mutable std::optional m_ppid; +#else + pid_t m_pid; + mutable std::optional m_ppid; +#endif + mutable std::optional m_name; + std::vector m_children; +}; + +std::vector getRunningProcesses(); +std::vector getLoadedModules(); + +#ifdef _WIN32 +// works for both jobs and processes +// +Process getProcessTree(HANDLE h); + +QString getProcessName(DWORD pid); +QString getProcessName(HANDLE process); + +DWORD getProcessParentID(DWORD pid); +DWORD getProcessParentID(HANDLE handle); +#else +// builds a process tree from a given pid +// +Process getProcessTree(pid_t pid); + +QString getProcessName(pid_t pid); +pid_t getProcessParentID(pid_t pid); +#endif + +} // namespace env + +#endif // ENV_MODULE_H diff --git a/src/src/envsecurity.cpp b/src/src/envsecurity.cpp new file mode 100644 index 0000000..8b6c6de --- /dev/null +++ b/src/src/envsecurity.cpp @@ -0,0 +1,764 @@ +#include "envsecurity.h" +#include "env.h" +#include "envmodule.h" +#include +#include + +#ifdef _WIN32 +#include +#include +#include +#include +#pragma comment(lib, "Wbemuuid.lib") + +#include +#include +#include +#pragma comment(lib, "advapi32.lib") +#else +#include +#include +#include +#endif + +namespace env +{ + +using namespace MOBase; + +#ifdef _WIN32 + +class WMI +{ +public: + class failed + {}; + + WMI(const std::string& ns) + { + try { + createLocator(); + createService(ns); + setSecurity(); + } catch (failed&) { + } + } + + template + void query(const std::string& q, F&& f) + { + if (!m_locator || !m_service) { + return; + } + + auto enumerator = getEnumerator(q); + if (!enumerator) { + return; + } + + for (;;) { + COMPtr object; + + { + IWbemClassObject* rawObject = nullptr; + ULONG count = 0; + auto ret = enumerator->Next(WBEM_INFINITE, 1, &rawObject, &count); + + if (count == 0 || !rawObject) { + break; + } + + if (FAILED(ret)) { + log::error("enum->next() failed, {}", formatSystemMessage(ret)); + break; + } + + object.reset(rawObject); + } + + f(object.get()); + } + } + +private: + COMPtr m_locator; + COMPtr m_service; + + void createLocator() + { + void* rawLocator = nullptr; + + const auto ret = CoCreateInstance(CLSID_WbemLocator, nullptr, CLSCTX_INPROC_SERVER, + IID_IWbemLocator, &rawLocator); + + if (FAILED(ret) || !rawLocator) { + log::error("CoCreateInstance for WbemLocator failed, {}", + formatSystemMessage(ret)); + + throw failed(); + } + + m_locator.reset(static_cast(rawLocator)); + } + + void createService(const std::string& ns) + { + IWbemServices* rawService = nullptr; + + const auto res = + m_locator->ConnectServer(_bstr_t(ns.c_str()), nullptr, nullptr, nullptr, 0, + nullptr, nullptr, &rawService); + + if (FAILED(res) || !rawService) { + log::debug("locator->ConnectServer() failed for namespace '{}', {}", ns, + formatSystemMessage(res)); + + throw failed(); + } + + m_service.reset(rawService); + } + + void setSecurity() + { + auto ret = CoSetProxyBlanket(m_service.get(), RPC_C_AUTHN_WINNT, RPC_C_AUTHZ_NONE, + nullptr, RPC_C_AUTHN_LEVEL_CALL, + RPC_C_IMP_LEVEL_IMPERSONATE, 0, EOAC_NONE); + + if (FAILED(ret)) { + log::error("CoSetProxyBlanket() failed, {}", formatSystemMessage(ret)); + throw failed(); + } + } + + COMPtr getEnumerator(const std::string& query) + { + IEnumWbemClassObject* rawEnumerator = NULL; + + auto ret = m_service->ExecQuery( + bstr_t("WQL"), bstr_t(query.c_str()), + WBEM_FLAG_FORWARD_ONLY | WBEM_FLAG_RETURN_IMMEDIATELY, NULL, &rawEnumerator); + + if (FAILED(ret) || !rawEnumerator) { + log::error("query '{}' failed, {}", query, formatSystemMessage(ret)); + return {}; + } + + return COMPtr(rawEnumerator); + } +}; + +#endif // _WIN32 + +SecurityProduct::SecurityProduct(QUuid guid, QString name, int provider, bool active, + bool upToDate) + : m_guid(std::move(guid)), m_name(std::move(name)), m_provider(provider), + m_active(active), m_upToDate(upToDate) +{} + +const QUuid& SecurityProduct::guid() const +{ + return m_guid; +} + +const QString& SecurityProduct::name() const +{ + return m_name; +} + +int SecurityProduct::provider() const +{ + return m_provider; +} + +bool SecurityProduct::active() const +{ + return m_active; +} + +bool SecurityProduct::upToDate() const +{ + return m_upToDate; +} + +QString SecurityProduct::toString() const +{ + QString s; + + if (m_name.isEmpty()) { + s += "(no name)"; + } else { + s += m_name; + } + + s += " (" + providerToString() + ")"; + + if (!m_active) { + s += ", inactive"; + } + + if (!m_upToDate) { + s += ", definitions outdated"; + } + + return s; +} + +QString SecurityProduct::providerToString() const +{ +#ifdef _WIN32 + QStringList ps; + + if (m_provider & WSC_SECURITY_PROVIDER_FIREWALL) { + ps.push_back("firewall"); + } + + if (m_provider & WSC_SECURITY_PROVIDER_AUTOUPDATE_SETTINGS) { + ps.push_back("autoupdate"); + } + + if (m_provider & WSC_SECURITY_PROVIDER_ANTIVIRUS) { + ps.push_back("antivirus"); + } + + if (m_provider & WSC_SECURITY_PROVIDER_ANTISPYWARE) { + ps.push_back("antispyware"); + } + + if (m_provider & WSC_SECURITY_PROVIDER_INTERNET_SETTINGS) { + ps.push_back("settings"); + } + + if (m_provider & WSC_SECURITY_PROVIDER_USER_ACCOUNT_CONTROL) { + ps.push_back("uac"); + } + + if (m_provider & WSC_SECURITY_PROVIDER_SERVICE) { + ps.push_back("service"); + } + + if (ps.empty()) { + return "doesn't provide anything"; + } + + return ps.join("|"); +#else + return "n/a"; +#endif +} + +#ifdef _WIN32 + +std::optional handleProduct(IWbemClassObject* o) +{ + VARIANT prop; + + // guid + auto ret = o->Get(L"instanceGuid", 0, &prop, 0, 0); + if (FAILED(ret)) { + log::error("failed to get instanceGuid, {}", formatSystemMessage(ret)); + return {}; + } + + if (prop.vt != VT_BSTR) { + log::error("instanceGuid is a {}, not a bstr", prop.vt); + return {}; + } + + const QUuid guid(QString::fromWCharArray(prop.bstrVal)); + VariantClear(&prop); + + // display name + QString displayName; + ret = o->Get(L"displayName", 0, &prop, 0, 0); + + if (FAILED(ret)) { + log::error("failed to get displayName, {}", formatSystemMessage(ret)); + } else if (prop.vt != VT_BSTR) { + log::error("displayName is a {}, not a bstr", prop.vt); + } else { + displayName = QString::fromWCharArray(prop.bstrVal); + } + + VariantClear(&prop); + + // product state + DWORD state = 0; + ret = o->Get(L"productState", 0, &prop, 0, 0); + + if (FAILED(ret)) { + log::error("failed to get productState, {}", formatSystemMessage(ret)); + } else { + if (prop.vt == VT_I4) { + state = prop.lVal; + } else if (prop.vt == VT_UI4) { + state = prop.ulVal; + } else if (prop.vt == VT_UI1) { + state = prop.bVal; + } else if (prop.vt == VT_NULL) { + log::warn("productState is null"); + } else { + log::error("productState is a {}, not a VT_I4 or a VT_UI4", prop.vt); + } + } + + VariantClear(&prop); + + const auto provider = static_cast((state >> 16) & 0xff); + const auto scanner = (state >> 8) & 0xff; + const auto definitions = state & 0xff; + + const bool active = ((scanner & 0x10) != 0); + const bool upToDate = (definitions == 0); + + return SecurityProduct(guid, displayName, provider, active, upToDate); +} + +std::vector getSecurityProductsFromWMI() +{ + std::map map; + + auto f = [&](auto* o) { + if (auto p = handleProduct(o)) { + map.emplace(p->guid(), std::move(*p)); + } + }; + + { + WMI wmi("root\\SecurityCenter2"); + wmi.query("select * from AntivirusProduct", f); + wmi.query("select * from FirewallProduct", f); + wmi.query("select * from AntiSpywareProduct", f); + } + + { + WMI wmi("root\\SecurityCenter"); + wmi.query("select * from AntivirusProduct", f); + wmi.query("select * from FirewallProduct", f); + wmi.query("select * from AntiSpywareProduct", f); + } + + std::vector v; + + for (auto&& p : map) { + v.push_back(p.second); + } + + return v; +} + +std::optional getWindowsFirewall() +{ + HRESULT hr = 0; + + COMPtr policy; + + { + void* rawPolicy = nullptr; + + hr = CoCreateInstance(__uuidof(NetFwPolicy2), nullptr, CLSCTX_INPROC_SERVER, + __uuidof(INetFwPolicy2), &rawPolicy); + + if (FAILED(hr) || !rawPolicy) { + log::error("CoCreateInstance for NetFwPolicy2 failed, {}", + formatSystemMessage(hr)); + + return {}; + } + + policy.reset(static_cast(rawPolicy)); + } + + VARIANT_BOOL enabledVariant; + + if (policy) { + hr = policy->get_FirewallEnabled(NET_FW_PROFILE2_PUBLIC, &enabledVariant); + if (FAILED(hr)) { + if (hr != EPT_S_NOT_REGISTERED && hr != 0x800706d9) { + log::debug("get_FirewallEnabled failed, {}", formatSystemMessage(hr)); + } + + return {}; + } + } + + const auto enabled = (enabledVariant != VARIANT_FALSE); + if (!enabled) { + return {}; + } + + return SecurityProduct({}, "Windows Firewall", WSC_SECURITY_PROVIDER_FIREWALL, true, + true); +} + +std::vector getSecurityProducts() +{ + std::vector v; + + { + auto fromWMI = getSecurityProductsFromWMI(); + v.insert(v.end(), std::make_move_iterator(fromWMI.begin()), + std::make_move_iterator(fromWMI.end())); + } + + if (auto p = getWindowsFirewall()) { + v.push_back(std::move(*p)); + } + + return v; +} + +class failed +{ +public: + failed(DWORD e, QString what) + : m_what(what + ", " + QString::fromStdWString(formatSystemMessage(e))) + {} + + QString what() const { return m_what; } + +private: + QString m_what; +}; + +MallocPtr getSecurityDescriptor(const QString& path) +{ + const auto wpath = path.toStdWString(); + BOOL ret = FALSE; + + DWORD length = 0; + ret = ::GetFileSecurityW(wpath.c_str(), + DACL_SECURITY_INFORMATION | OWNER_SECURITY_INFORMATION, + nullptr, 0, &length); + + if (!ret || length == 0) { + const auto e = GetLastError(); + + if (e != ERROR_INSUFFICIENT_BUFFER) { + if (e == ERROR_ACCESS_DENIED) { + throw failed(e, "cannot get security descriptor"); + } else { + throw failed(e, "GetFileSecurity() for length failed"); + } + } + } + + MallocPtr sd( + static_cast(std::malloc(length))); + + std::memset(sd.get(), 0, length); + + ret = ::GetFileSecurityW(wpath.c_str(), + DACL_SECURITY_INFORMATION | OWNER_SECURITY_INFORMATION, + sd.get(), length, &length); + + if (!ret) { + const auto e = GetLastError(); + throw failed(e, "GetFileSecurity()"); + } + + return sd; +} + +PACL getDacl(SECURITY_DESCRIPTOR* sd) +{ + BOOL present = FALSE; + BOOL daclDefaulted = FALSE; + PACL acl = nullptr; + + BOOL ret = ::GetSecurityDescriptorDacl(sd, &present, &acl, &daclDefaulted); + + if (!ret) { + const auto e = GetLastError(); + throw failed(e, "GetSecurityDescriptorDacl()"); + } + + if (!present) { + return nullptr; + } + + return acl; +} + +PSID getFileOwner(SECURITY_DESCRIPTOR* sd) +{ + BOOL ownerDefaulted = FALSE; + PSID owner; + + BOOL ret = ::GetSecurityDescriptorOwner(sd, &owner, &ownerDefaulted); + + if (!ret) { + const auto e = GetLastError(); + throw failed(e, "GetSecurityDescriptionOwner()"); + } + + return owner; +} + +MallocPtr getCurrentUser() +{ + HANDLE hnd = ::GetCurrentProcess(); + HANDLE rawToken = 0; + + BOOL ret = ::OpenProcessToken(hnd, TOKEN_QUERY, &rawToken); + if (!ret) { + const auto e = GetLastError(); + throw(e, "OpenProcessToken()"); + } + + HandlePtr token(rawToken); + + DWORD retsize = 0; + ret = ::GetTokenInformation(token.get(), TokenUser, 0, 0, &retsize); + + if (!ret) { + const auto e = GetLastError(); + if (e != ERROR_INSUFFICIENT_BUFFER) { + throw failed(e, "GetTokenInformation() for length"); + } + } + + MallocPtr tokenBuffer(std::malloc(retsize)); + ret = ::GetTokenInformation(token.get(), TokenUser, tokenBuffer.get(), retsize, + &retsize); + + if (!ret) { + const auto e = GetLastError(); + throw failed(e, "GetTokenInformation()"); + } + + PSID tokenSid = ((PTOKEN_USER)(tokenBuffer.get()))->User.Sid; + DWORD sidLen = ::GetLengthSid(tokenSid); + MallocPtr currentUserSID((SID*)(malloc(sidLen))); + + ret = ::CopySid(sidLen, currentUserSID.get(), tokenSid); + + if (!ret) { + const auto e = GetLastError(); + throw failed(e, "CopySid()"); + } + + return currentUserSID; +} + +ACCESS_MASK getEffectiveRights(ACL* dacl, PSID sid) +{ + TRUSTEEW trustee = {}; + BuildTrusteeWithSid(&trustee, sid); + + ACCESS_MASK access = 0; + DWORD ret = ::GetEffectiveRightsFromAclW(dacl, &trustee, &access); + + if (ret != ERROR_SUCCESS) { + throw failed(ret, "GetEffectiveRightsFromAclW()"); + } + + return access; +} + +QString getUsername(PSID owner) +{ + DWORD nameSize = 0, domainSize = 0; + auto use = SidTypeUnknown; + + BOOL ret = + LookupAccountSidW(nullptr, owner, nullptr, &nameSize, nullptr, &domainSize, &use); + + if (!ret) { + const auto e = GetLastError(); + + if (e != ERROR_INSUFFICIENT_BUFFER) { + throw failed(e, "LookupAccountSid() for sizes"); + } + } + + auto wsName = std::make_unique(nameSize); + auto wsDomain = std::make_unique(domainSize); + + ret = LookupAccountSidW(nullptr, owner, wsName.get(), &nameSize, wsDomain.get(), + &domainSize, &use); + + if (!ret) { + const auto e = GetLastError(); + throw failed(e, "LookupAccountSid()"); + } + + const QString name = QString::fromWCharArray(wsName.get(), nameSize); + const QString domain = QString::fromWCharArray(wsDomain.get(), domainSize); + + if (!name.isEmpty() && !domain.isEmpty()) { + return domain + "\\" + name; + } else { + // either or both are empty + return name + domain; + } +} + +FileRights makeFileRights(ACCESS_MASK m) +{ + FileRights fr; + + if (m & FILE_GENERIC_READ) { + fr.list.push_back("file_generic_read"); + } else { + if (m & READ_CONTROL) { + fr.list.push_back("read_ctrl"); + } + + if (m & FILE_READ_DATA) { + fr.list.push_back("read_data"); + } + + if (m & FILE_READ_ATTRIBUTES) { + fr.list.push_back("read_atts"); + } + + if (m & FILE_READ_EA) { + fr.list.push_back("read_ex_atts"); + } + + if (m & SYNCHRONIZE) { + fr.list.push_back("sync"); + } + } + + if (m & FILE_GENERIC_WRITE) { + fr.list.push_back("file_generic_write"); + } else { + if (m & FILE_WRITE_DATA) { + fr.list.push_back("write_data"); + } + + if (m & FILE_WRITE_ATTRIBUTES) { + fr.list.push_back("write_atts"); + } + + if (m & FILE_WRITE_EA) { + fr.list.push_back("write_ex_atts"); + } + + if (m & FILE_APPEND_DATA) { + fr.list.push_back("append_data"); + } + } + + if (m & FILE_GENERIC_EXECUTE) { + fr.list.push_back("file_generic_execute"); + fr.hasExecute = true; + } else { + if (m & FILE_EXECUTE) { + fr.list.push_back("execute"); + fr.hasExecute = true; + } + } + + if (m & DELETE) { + fr.list.push_back("delete"); + } + + if (m & WRITE_DAC) { + fr.list.push_back("write_dac"); + } + + if (m & WRITE_OWNER) { + fr.list.push_back("write_owner"); + } + + if (m & GENERIC_ALL) { + fr.list.push_back("generic_all"); + } + + if (m & GENERIC_WRITE) { + fr.list.push_back("generic_write"); + } + + if (m & GENERIC_READ) { + fr.list.push_back("generic_read"); + } + + const auto normalRights = STANDARD_RIGHTS_ALL | FILE_GENERIC_READ | + FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | + FILE_DELETE_CHILD; + + if (m == normalRights) { + fr.normalRights = true; + } + + return fr; +} + +FileSecurity getFileSecurity(const QString& path) +{ + FileSecurity fs; + + try { + auto sd = getSecurityDescriptor(path); + auto dacl = getDacl(sd.get()); + auto currentUser = getCurrentUser(); + auto owner = getFileOwner(sd.get()); + auto access = getEffectiveRights(dacl, currentUser.get()); + + fs.rights = makeFileRights(access); + + if (EqualSid(owner, currentUser.get())) { + fs.owner = "(this user)"; + } else { + fs.owner = getUsername(owner); + } + } catch (failed& f) { + fs.error = f.what(); + } + + return fs; +} + +#else // Linux + +std::vector getSecurityProducts() +{ + // No equivalent on Linux + return {}; +} + +FileSecurity getFileSecurity(const QString& path) +{ + FileSecurity fs; + + struct stat st; + if (stat(path.toStdString().c_str(), &st) != 0) { + fs.error = QString("stat() failed for '%1': %2").arg(path).arg(strerror(errno)); + return fs; + } + + // Owner + struct passwd* pw = getpwuid(st.st_uid); + if (pw) { + uid_t currentUid = getuid(); + if (st.st_uid == currentUid) { + fs.owner = "(this user)"; + } else { + fs.owner = QString::fromUtf8(pw->pw_name); + } + } else { + fs.owner = QString::number(st.st_uid); + } + + // Rights + if (st.st_mode & S_IRUSR) fs.rights.list.push_back("read"); + if (st.st_mode & S_IWUSR) fs.rights.list.push_back("write"); + if (st.st_mode & S_IXUSR) { + fs.rights.list.push_back("execute"); + fs.rights.hasExecute = true; + } + + // Check if current user has access + if (access(path.toStdString().c_str(), R_OK | W_OK | X_OK) == 0) { + fs.rights.normalRights = true; + } + + return fs; +} + +#endif // _WIN32 + +} // namespace env diff --git a/src/src/envsecurity.h b/src/src/envsecurity.h new file mode 100644 index 0000000..b76d970 --- /dev/null +++ b/src/src/envsecurity.h @@ -0,0 +1,71 @@ +#ifndef ENV_SECURITY_H +#define ENV_SECURITY_H + +#include +#include + +namespace env +{ + +// represents a security product, such as an antivirus or a firewall +// +class SecurityProduct +{ +public: + SecurityProduct(QUuid guid, QString name, int provider, bool active, bool upToDate); + + // guid + // + const QUuid& guid() const; + + // display name of the product + // + const QString& name() const; + + // a bunch of provider flags + // + int provider() const; + + // whether the product is active + // + bool active() const; + + // whether its definitions are up-to-date + // + bool upToDate() const; + + // string representation of the above + // + QString toString() const; + +private: + QUuid m_guid; + QString m_name; + int m_provider; + bool m_active; + bool m_upToDate; + + QString providerToString() const; +}; + +std::vector getSecurityProducts(); + +struct FileRights +{ + QStringList list; + bool hasExecute = false; + bool normalRights = false; +}; + +struct FileSecurity +{ + QString owner; + FileRights rights; + QString error; +}; + +FileSecurity getFileSecurity(const QString& file); + +} // namespace env + +#endif // ENV_SECURITY_H diff --git a/src/src/envshell.cpp b/src/src/envshell.cpp new file mode 100644 index 0000000..f8e1ec9 --- /dev/null +++ b/src/src/envshell.cpp @@ -0,0 +1,610 @@ +#ifdef _WIN32 + +#include + +#include +#include +#include + +#include "envshell.h" + +namespace env +{ + +using namespace MOBase; + +const int QCM_FIRST = 1; +const int QCM_LAST = 0x7ff; + +class MenuFailed : public std::runtime_error +{ +public: + MenuFailed(HRESULT r, const std::string& what) + : runtime_error( + std::format("{}, {}", what, + QString::fromStdWString(formatSystemMessage(r)).toStdString())) + {} +}; + +class DummyMenu +{ +public: + DummyMenu(QString s) : m_what(s) {} + + const QString& what() const { return m_what; } + +private: + QString m_what; +}; + +struct IdlsFreer +{ + const std::vector& v; + + IdlsFreer(const std::vector& v) : v(v) {} + + ~IdlsFreer() + { + for (auto&& idl : v) { + ::CoTaskMemFree(const_cast(idl)); + } + } +}; + +class WndProcFilter : public QAbstractNativeEventFilter +{ +public: + using function_type = + std::function; + + WndProcFilter(function_type f) : m_f(std::move(f)) {} + + bool nativeEventFilter(const QByteArray& eventType, void* message, + qintptr* result) override + { + MSG* msg = (MSG*)message; + if (!msg) { + return false; + } + + LRESULT lr = 0; + + const bool r = m_f(msg->hwnd, msg->message, msg->wParam, msg->lParam, &lr); + + if (result) { + *result = lr; + } + + return r; + } + +private: + function_type m_f; +}; + +HWND getHWND(QMainWindow* mw) +{ + if (mw) { + return (HWND)mw->winId(); + } else { + return 0; + } +} + +// adapted from +// https://devblogs.microsoft.com/oldnewthing/20040928-00/?p=37723 +// +HRESULT IContextMenu_GetCommandString(IContextMenu* pcm, UINT_PTR idCmd, UINT uFlags, + UINT* pwReserved, LPWSTR pszName, UINT cchMax) +{ + // Callers are expected to be using Unicode. + if (!(uFlags & GCS_UNICODE)) { + return E_INVALIDARG; + } + + if (cchMax <= 1) { + return E_FAIL; + } + + cchMax--; + + pszName[0] = L'\0'; + + HRESULT hr = pcm->GetCommandString(idCmd, uFlags, pwReserved, (LPSTR)pszName, cchMax); + + if (SUCCEEDED(hr) && pszName[0] == L'\0') { + hr = E_NOTIMPL; + } + + if (FAILED(hr)) { + LPSTR pszAnsi = (LPSTR)LocalAlloc(LMEM_FIXED, (cchMax + 1) * sizeof(CHAR)); + + if (pszAnsi) { + pszAnsi[0] = '\0'; + + hr = pcm->GetCommandString(idCmd, uFlags & ~GCS_UNICODE, pwReserved, pszAnsi, + cchMax); + + if (SUCCEEDED(hr) && pszAnsi[0] == '\0') { + hr = E_NOTIMPL; + } + + if (SUCCEEDED(hr)) { + if (MultiByteToWideChar(CP_ACP, 0, pszAnsi, -1, pszName, cchMax) == 0) { + hr = E_FAIL; + } + } + + LocalFree(pszAnsi); + + } else { + hr = E_OUTOFMEMORY; + } + } + + return hr; +} + +ShellMenu::ShellMenu(QMainWindow* mw) : m_mw(mw) {} + +void ShellMenu::addFile(QFileInfo fi) +{ + m_files.emplace_back(std::move(fi)); +} + +int ShellMenu::fileCount() const +{ + return static_cast(m_files.size()); +} + +void ShellMenu::exec(const QPoint& pos) +{ + HMENU menu = getMenu(); + if (!menu) { + return; + } + + try { + const auto hwnd = getHWND(m_mw); + + auto filter = std::make_unique( + [&](HWND h, UINT m, WPARAM wp, LPARAM lp, LRESULT* out) { + return wndProc(h, m, wp, lp, out); + }); + + QCoreApplication::instance()->installNativeEventFilter(filter.get()); + + const int cmd = + TrackPopupMenuEx(menu, TPM_RETURNCMD, pos.x(), pos.y(), hwnd, nullptr); + + if (m_mw) { + if (auto* sb = m_mw->statusBar()) { + sb->clearMessage(); + } + } + + if (cmd <= 0) { + return; + } + + invoke(pos, cmd - QCM_FIRST); + } catch (MenuFailed& e) { + if (m_files.size() == 1) { + log::error("can't exec shell menu for '{}': {}", + QDir::toNativeSeparators(m_files[0].absoluteFilePath()), e.what()); + } else { + log::error("can't exec shell menu for {} files: {}", m_files.size(), e.what()); + } + } +} + +HMENU ShellMenu::getMenu() +{ + if (!m_menu) { + create(); + } + + return m_menu.get(); +} + +bool ShellMenu::wndProc(HWND h, UINT m, WPARAM wp, LPARAM lp, LRESULT* out) +{ + if (m == WM_MENUSELECT) { + HANDLE_WM_MENUSELECT(h, wp, lp, onMenuSelect); + return true; + } + + if (m_cm3) { + const auto r = m_cm3->HandleMenuMsg2(m, wp, lp, out); + + if (SUCCEEDED(r)) { + return true; + } + } + + if (m_cm2) { + const auto r = m_cm2->HandleMenuMsg(m, wp, lp); + + if (SUCCEEDED(r)) { + if (out) { + *out = 0; + } + + return true; + } + } + + return false; +} + +void ShellMenu::onMenuSelect(HWND hwnd, HMENU hmenu, int item, HMENU hmenuPopup, + UINT flags) +{ + if (m_cm && item >= QCM_FIRST && item <= QCM_LAST) { + WCHAR szBuf[MAX_PATH]; + + const auto r = IContextMenu_GetCommandString(m_cm.get(), item - QCM_FIRST, + GCS_HELPTEXTW, NULL, szBuf, MAX_PATH); + + if (FAILED(r)) { + lstrcpynW(szBuf, L"No help available.", MAX_PATH); + } + + if (m_mw) { + if (auto* sb = m_mw->statusBar()) { + sb->showMessage(QString::fromWCharArray(szBuf)); + } + } + } +} + +void ShellMenu::create() +{ + if (m_files.empty()) { + log::warn("showShellMenu(): no files given"); + return; + } + + try { + auto idls = createIdls(m_files); + + if (idls.empty()) { + log::error("no idls, can't create context menu"); + return; + } + + IdlsFreer freer(idls); + + auto array = createItemArray(idls); + + createContextMenu(array.get()); + createPopupMenu(m_cm.get()); + } catch (DummyMenu& dm) { + m_menu = createDummyMenu(dm.what()); + } catch (MenuFailed& e) { + if (m_files.size() == 1) { + log::error("can't create shell menu for '{}': {}", + QDir::toNativeSeparators(m_files[0].absoluteFilePath()), e.what()); + } else { + log::error("can't create shell menu for {} files: {}", m_files.size(), e.what()); + } + + m_menu = createDummyMenu(QObject::tr("No menu available")); + } +} + +HMenuPtr ShellMenu::createDummyMenu(const QString& what) +{ + try { + HMENU menu = CreatePopupMenu(); + if (!menu) { + const auto e = GetLastError(); + throw MenuFailed(e, "CreatePopupMenu failed"); + } + + if (!AppendMenuW(menu, MF_STRING | MF_DISABLED, 0, what.toStdWString().c_str())) { + const auto e = GetLastError(); + throw MenuFailed(e, "AppendMenuW failed"); + } + + return HMenuPtr(menu); + } catch (MenuFailed& e) { + log::error("{}", what); + log::error("additionally, creating the dummy menu failed: {}", e.what()); + + return {}; + } +} + +std::vector ShellMenu::createIdls(const std::vector& files) +{ + std::vector idls; + std::optional parent; + + for (auto&& f : files) { + const auto path = QDir::toNativeSeparators(f.absoluteFilePath()).toStdWString(); + + if (!parent) { + parent = f.absoluteDir(); + } else { + if (*parent != f.absoluteDir()) { + throw DummyMenu(QObject::tr("Selected files must be in the same directory")); + } + } + + auto item = createShellItem(path); + auto pidlist = getPersistIDList(item.get()); + auto absIdl = getIDList(pidlist.get()); + + idls.push_back(absIdl.release()); + } + + return idls; +} + +COMPtr ShellMenu::createItemArray(std::vector& idls) +{ + IShellItemArray* array = nullptr; + auto r = SHCreateShellItemArrayFromIDLists(static_cast(idls.size()), &idls[0], + &array); + + if (FAILED(r)) { + throw MenuFailed(r, "SHCreateShellItemArrayFromIDLists failed"); + } + + return COMPtr(array); +} + +void ShellMenu::createContextMenu(IShellItemArray* array) +{ + IContextMenu* cm = nullptr; + + auto r = + array->BindToHandler(nullptr, BHID_SFUIObject, IID_IContextMenu, (void**)&cm); + + if (FAILED(r)) { + throw MenuFailed(r, "BindToHandler failed"); + } + + m_cm.reset(cm); + + { + IContextMenu2* cm2 = nullptr; + if (SUCCEEDED(m_cm->QueryInterface(IID_IContextMenu2, (void**)&cm2))) { + m_cm2.reset(cm2); + } + } + + { + IContextMenu3* cm3 = nullptr; + if (SUCCEEDED(m_cm->QueryInterface(IID_IContextMenu3, (void**)&cm3))) { + m_cm3.reset(cm3); + } + } +} + +void ShellMenu::createPopupMenu(IContextMenu* cm) +{ + HMENU hmenu = CreatePopupMenu(); + if (!hmenu) { + const auto e = GetLastError(); + throw MenuFailed(e, "CreatePopupMenu failed"); + } + + const auto r = cm->QueryContextMenu(hmenu, 0, QCM_FIRST, QCM_LAST, CMF_EXTENDEDVERBS); + + if (FAILED(r)) { + throw MenuFailed(r, "QueryContextMenu failed"); + } + + m_menu.reset(hmenu); +} + +COMPtr ShellMenu::createShellItem(const std::wstring& path) +{ + IShellItem* item = nullptr; + + auto r = + SHCreateItemFromParsingName(path.c_str(), nullptr, IID_IShellItem, (void**)&item); + + if (FAILED(r)) { + throw MenuFailed(r, "SHCreateItemFromParsingName failed"); + } + + return COMPtr(item); +} + +COMPtr ShellMenu::getPersistIDList(IShellItem* item) +{ + IPersistIDList* idl = nullptr; + auto r = item->QueryInterface(IID_IPersistIDList, (void**)&idl); + + if (FAILED(r)) { + throw MenuFailed(r, "QueryInterface IID_IPersistIDList failed"); + } + + return COMPtr(idl); +} + +CoTaskMemPtr ShellMenu::getIDList(IPersistIDList* pidlist) +{ + LPITEMIDLIST absIdl = nullptr; + auto r = pidlist->GetIDList(&absIdl); + + if (FAILED(r)) { + throw MenuFailed(r, "GetIDList failed"); + } + + return CoTaskMemPtr(absIdl); +} + +void ShellMenu::invoke(const QPoint& p, int cmd) +{ + const auto hwnd = getHWND(m_mw); + + CMINVOKECOMMANDINFOEX info = {}; + + info.cbSize = sizeof(info); + info.fMask = CMIC_MASK_UNICODE | CMIC_MASK_PTINVOKE; + info.hwnd = hwnd; + info.lpVerb = MAKEINTRESOURCEA(cmd); + info.lpVerbW = MAKEINTRESOURCEW(cmd); + info.nShow = SW_SHOWNORMAL; + info.ptInvoke = {p.x(), p.y()}; + + const auto m = QApplication::queryKeyboardModifiers(); + + if (m & Qt::ShiftModifier) { + info.fMask |= CMIC_MASK_SHIFT_DOWN; + } + + if (m & Qt::ControlModifier) { + info.fMask |= CMIC_MASK_CONTROL_DOWN; + } + + const auto r = m_cm->InvokeCommand((CMINVOKECOMMANDINFO*)&info); + + if (FAILED(r)) { + throw MenuFailed(r, std::format("InvokeCommand failed, verb={}", cmd)); + } +} + +ShellMenuCollection::ShellMenuCollection(QMainWindow* mw) : m_mw(mw), m_active(nullptr) +{} + +void ShellMenuCollection::addDetails(QString s) +{ + m_details.emplace_back(std::move(s)); +} + +void ShellMenuCollection::add(QString name, ShellMenu m) +{ + m_menus.push_back({name, std::move(m)}); +} + +void ShellMenuCollection::exec(const QPoint& pos) +{ + HMENU menu = ::CreatePopupMenu(); + if (!menu) { + const auto e = GetLastError(); + + log::error("CreatePopupMenu for merged menus failed, {}", formatSystemMessage(e)); + + return; + } + + if (!m_details.empty()) { + for (auto&& d : m_details) { + const auto s = d.toStdWString(); + const auto r = AppendMenuW(menu, MF_STRING | MF_DISABLED, 0, s.c_str()); + + if (!r) { + const auto e = GetLastError(); + log::error("AppendMenuW failed for details '{}', {}", d, + formatSystemMessage(e)); + } + } + + const auto r = AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + if (!r) { + const auto e = GetLastError(); + log::error("AppendMenuW failed for separator, {}", formatSystemMessage(e)); + } + } + + for (auto&& m : m_menus) { + auto hmenu = m.menu.getMenu(); + if (!hmenu) { + continue; + } + + const auto r = + AppendMenuW(menu, MF_POPUP | MF_STRING, reinterpret_cast(hmenu), + m.name.toStdWString().c_str()); + + if (!r) { + const auto e = GetLastError(); + + log::error("AppendMenuW failed for merged menu {}, {}", m.name, + formatSystemMessage(e)); + + continue; + } + } + + auto hwnd = getHWND(m_mw); + + auto filter = std::make_unique( + [&](HWND h, UINT m, WPARAM wp, LPARAM lp, LRESULT* out) { + return wndProc(h, m, wp, lp, out); + }); + + QCoreApplication::instance()->installNativeEventFilter(filter.get()); + + const int cmd = + TrackPopupMenuEx(menu, TPM_RETURNCMD, pos.x(), pos.y(), hwnd, nullptr); + + if (m_mw) { + if (auto* sb = m_mw->statusBar()) { + sb->clearMessage(); + } + } + + if (cmd <= 0) { + return; + } + + if (!m_active) { + log::debug("SMC: command {} selected without active submenu", cmd); + return; + } + + const auto realCmd = cmd - QCM_FIRST; + + log::debug("SMC: invoking {} on {}", realCmd, m_active->name); + m_active->menu.invoke(pos, realCmd); +} + +bool ShellMenuCollection::wndProc(HWND h, UINT m, WPARAM wp, LPARAM lp, LRESULT* out) +{ + if (m == WM_MENUSELECT) { + auto* oldActive = m_active; + m_active = nullptr; + + HANDLE_WM_MENUSELECT(h, wp, lp, onMenuSelect); + + if (!m_active && oldActive) { + m_active = oldActive; + } else if (m_active && m_active == oldActive) { + return true; + } else if (m_active && m_active != oldActive) { + log::debug("SMC: switching to {}", m_active->name); + } + } + + if (!m_active) { + return false; + } + + return m_active->menu.wndProc(h, m, wp, lp, out); +} + +void ShellMenuCollection::onMenuSelect(HWND hwnd, HMENU hmenu, int item, + HMENU hmenuPopup, UINT flags) +{ + for (auto&& m : m_menus) { + if (m.menu.getMenu() == hmenuPopup) { + m_active = &m; + break; + } + } +} + +} // namespace env + +#else // Linux + +// The header (envshell.h) provides stub class declarations for Linux. +// No additional implementation is needed since the stubs are inline in the header. + +#endif // _WIN32 diff --git a/src/src/envshell.h b/src/src/envshell.h new file mode 100644 index 0000000..a390f3a --- /dev/null +++ b/src/src/envshell.h @@ -0,0 +1,115 @@ +#ifndef ENV_SHELL_H +#define ENV_SHELL_H + +#include "env.h" +#include +#include + +namespace env +{ + +#ifdef _WIN32 + +class ShellMenu +{ +public: + ShellMenu(QMainWindow* mw); + + // noncopyable + ShellMenu(const ShellMenu&) = delete; + ShellMenu& operator=(const ShellMenu&) = delete; + ShellMenu(ShellMenu&&) = default; + ShellMenu& operator=(ShellMenu&&) = default; + + void addFile(QFileInfo fi); + int fileCount() const; + + void exec(const QPoint& pos); + HMENU getMenu(); + bool wndProc(HWND hwnd, UINT m, WPARAM wp, LPARAM lp, LRESULT* out); + void invoke(const QPoint& p, int cmd); + +private: + QMainWindow* m_mw; + std::vector m_files; + COMPtr m_cm; + COMPtr m_cm2; + COMPtr m_cm3; + HMenuPtr m_menu; + + void create(); + + std::vector createIdls(const std::vector& files); + COMPtr createItemArray(std::vector& idls); + + void createContextMenu(IShellItemArray* array); + void createPopupMenu(IContextMenu* cm); + + COMPtr createShellItem(const std::wstring& path); + COMPtr getPersistIDList(IShellItem* item); + CoTaskMemPtr getIDList(IPersistIDList* pidlist); + HMenuPtr createDummyMenu(const QString& what); + + void onMenuSelect(HWND hwnd, HMENU hmenu, int item, HMENU hmenuPopup, UINT flags); +}; + +class ShellMenuCollection +{ +public: + ShellMenuCollection(QMainWindow* mw); + + void addDetails(QString s); + void add(QString name, ShellMenu m); + + void exec(const QPoint& pos); + +private: + struct MenuInfo + { + QString name; + ShellMenu menu; + }; + + QMainWindow* m_mw; + std::vector m_details; + std::vector m_menus; + MenuInfo* m_active; + + bool wndProc(HWND hwnd, UINT m, WPARAM wp, LPARAM lp, LRESULT* out); + + void onMenuSelect(HWND hwnd, HMENU hmenu, int item, HMENU hmenuPopup, UINT flags); +}; + +#else // Linux + +// Stub implementations for Linux - shell context menus are Windows-only +class ShellMenu +{ +public: + ShellMenu(QMainWindow*) {} + + ShellMenu(const ShellMenu&) = delete; + ShellMenu& operator=(const ShellMenu&) = delete; + ShellMenu(ShellMenu&&) = default; + ShellMenu& operator=(ShellMenu&&) = default; + + void addFile(QFileInfo) {} + int fileCount() const { return 0; } + void exec(const QPoint&) {} +}; + +class ShellMenuCollection +{ +public: + ShellMenuCollection(QMainWindow*) {} + + void addDetails(QString) {} + void add(QString, ShellMenu) {} + void exec(const QPoint&) {} +}; + +#endif // _WIN32 + +} // namespace env + +#endif // ENV_SHELL_H diff --git a/src/src/envshortcut.cpp b/src/src/envshortcut.cpp new file mode 100644 index 0000000..1bb39f6 --- /dev/null +++ b/src/src/envshortcut.cpp @@ -0,0 +1,460 @@ +#include "envshortcut.h" + +#ifdef _WIN32 + +#include "env.h" +#include "executableslist.h" +#include "filesystemutilities.h" +#include "instancemanager.h" +#include +#include + +namespace env +{ + +using namespace MOBase; + +class ShellLinkException +{ +public: + ShellLinkException(QString s) : m_what(std::move(s)) {} + + const QString& what() const { return m_what; } + +private: + QString m_what; +}; + +// just a wrapper around IShellLink operations that throws ShellLinkException +// on errors +// +class ShellLinkWrapper +{ +public: + ShellLinkWrapper() + { + m_link = createShellLink(); + m_file = createPersistFile(); + } + + void setPath(const QString& s) + { + if (s.isEmpty()) { + throw ShellLinkException("path cannot be empty"); + } + + const auto r = m_link->SetPath(s.toStdWString().c_str()); + throwOnFail(r, QString("failed to set target path '%1'").arg(s)); + } + + void setArguments(const QString& s) + { + const auto r = m_link->SetArguments(s.toStdWString().c_str()); + throwOnFail(r, QString("failed to set arguments '%1'").arg(s)); + } + + void setDescription(const QString& s) + { + if (s.isEmpty()) { + return; + } + + const auto r = m_link->SetDescription(s.toStdWString().c_str()); + throwOnFail(r, QString("failed to set description '%1'").arg(s)); + } + + void setIcon(const QString& file, int i) + { + if (file.isEmpty()) { + return; + } + + const auto r = m_link->SetIconLocation(file.toStdWString().c_str(), i); + throwOnFail(r, QString("failed to set icon '%1' @ %2").arg(file).arg(i)); + } + + void setWorkingDirectory(const QString& s) + { + if (s.isEmpty()) { + return; + } + + const auto r = m_link->SetWorkingDirectory(s.toStdWString().c_str()); + throwOnFail(r, QString("failed to set working directory '%1'").arg(s)); + } + + void save(const QString& path) + { + const auto r = m_file->Save(path.toStdWString().c_str(), TRUE); + throwOnFail(r, QString("failed to save link '%1'").arg(path)); + } + +private: + COMPtr m_link; + COMPtr m_file; + + void throwOnFail(HRESULT r, const QString& s) + { + if (FAILED(r)) { + throw ShellLinkException(QString("%1, %2").arg(s).arg(formatSystemMessage(r))); + } + } + + COMPtr createShellLink() + { + void* link = nullptr; + + const auto r = CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, + IID_IShellLink, &link); + + throwOnFail(r, "failed to create IShellLink instance"); + + if (!link) { + throw ShellLinkException("creating IShellLink worked, pointer is null"); + } + + return COMPtr(static_cast(link)); + } + + COMPtr createPersistFile() + { + void* file = nullptr; + + const auto r = m_link->QueryInterface(IID_IPersistFile, &file); + throwOnFail(r, "failed to get IPersistFile interface"); + + if (!file) { + throw ShellLinkException("querying IPersistFile worked, pointer is null"); + } + + return COMPtr(static_cast(file)); + } +}; + +Shortcut::Shortcut() : m_iconIndex(0) {} + +Shortcut::Shortcut(const Executable& exe) : Shortcut() +{ + const auto i = *InstanceManager::singleton().currentInstance(); + + m_name = MOBase::sanitizeFileName(exe.title()); + m_target = QFileInfo(qApp->applicationFilePath()).absoluteFilePath(); + + m_arguments = QString("\"moshortcut://%1:%2\"") + .arg(i.isPortable() ? "" : i.displayName()) + .arg(exe.title()); + + m_description = QString("Run %1 with ModOrganizer").arg(exe.title()); + + if (exe.usesOwnIcon()) { + m_icon = exe.binaryInfo().absoluteFilePath(); + } + + m_workingDirectory = qApp->applicationDirPath(); +} + +Shortcut& Shortcut::name(const QString& s) +{ + m_name = MOBase::sanitizeFileName(s); + return *this; +} + +Shortcut& Shortcut::target(const QString& s) +{ + m_target = s; + return *this; +} + +Shortcut& Shortcut::arguments(const QString& s) +{ + m_arguments = s; + return *this; +} + +Shortcut& Shortcut::description(const QString& s) +{ + m_description = s; + return *this; +} + +Shortcut& Shortcut::icon(const QString& s, int index) +{ + m_icon = s; + m_iconIndex = index; + return *this; +} + +Shortcut& Shortcut::workingDirectory(const QString& s) +{ + m_workingDirectory = s; + return *this; +} + +bool Shortcut::exists(Locations loc) const +{ + const auto path = shortcutPath(loc); + if (path.isEmpty()) { + return false; + } + + return QFileInfo(path).exists(); +} + +bool Shortcut::toggle(Locations loc) +{ + if (exists(loc)) { + return remove(loc); + } else { + return add(loc); + } +} + +bool Shortcut::add(Locations loc) +{ + log::debug("adding shortcut to {}:\n" + " . name: '{}'\n" + " . target: '{}'\n" + " . arguments: '{}'\n" + " . description: '{}'\n" + " . icon: '{}' @ {}\n" + " . working directory: '{}'", + toString(loc), m_name, m_target, m_arguments, m_description, m_icon, + m_iconIndex, m_workingDirectory); + + if (m_target.isEmpty()) { + log::error("shortcut: target is empty"); + return false; + } + + const auto path = shortcutPath(loc); + if (path.isEmpty()) { + return false; + } + + log::debug("shorcut file will be saved at '{}'", path); + + try { + ShellLinkWrapper link; + + link.setPath(m_target); + link.setArguments(m_arguments); + link.setDescription(m_description); + link.setIcon(m_icon, m_iconIndex); + link.setWorkingDirectory(m_workingDirectory); + + link.save(path); + + return true; + } catch (ShellLinkException& e) { + log::error("{}\nshortcut file was not saved", e.what()); + } + + return false; +} + +bool Shortcut::remove(Locations loc) +{ + log::debug("removing shortcut for '{}' from {}", m_name, toString(loc)); + + const auto path = shortcutPath(loc); + if (path.isEmpty()) { + return false; + } + + log::debug("path to shortcut file is '{}'", path); + + if (!QFile::exists(path)) { + log::error("can't remove shortcut '{}', file not found", path); + return false; + } + + if (!MOBase::shellDelete({path})) { + const auto e = ::GetLastError(); + + log::error("failed to remove shortcut '{}', {}", path, formatSystemMessage(e)); + + return false; + } + + return true; +} + +QString Shortcut::shortcutPath(Locations loc) const +{ + const auto dir = shortcutDirectory(loc); + if (dir.isEmpty()) { + return {}; + } + + const auto file = shortcutFilename(); + if (file.isEmpty()) { + return {}; + } + + return dir + QDir::separator() + file; +} + +QString Shortcut::shortcutDirectory(Locations loc) const +{ + QString dir; + + try { + switch (loc) { + case Desktop: + dir = MOBase::getDesktopDirectory(); + break; + + case StartMenu: + dir = MOBase::getStartMenuDirectory(); + break; + + case None: + default: + log::error("shortcut: bad location {}", loc); + break; + } + } catch (std::exception&) { + } + + return QDir::toNativeSeparators(dir); +} + +QString Shortcut::shortcutFilename() const +{ + if (m_name.isEmpty()) { + log::error("shortcut name is empty"); + return {}; + } + + return m_name + ".lnk"; +} + +QString toString(Shortcut::Locations loc) +{ + switch (loc) { + case Shortcut::None: + return "none"; + + case Shortcut::Desktop: + return "desktop"; + + case Shortcut::StartMenu: + return "start menu"; + + default: + return QString("? (%1)").arg(static_cast(loc)); + } +} + +} // namespace env + +#else // Linux + +#include +#include +#include +#include + +class Executable; + +namespace env +{ + +Shortcut::Shortcut() : m_iconIndex(0) {} + +Shortcut::Shortcut(const Executable&) : Shortcut() {} + +Shortcut& Shortcut::name(const QString& s) +{ + m_name = s; + return *this; +} + +Shortcut& Shortcut::target(const QString& s) +{ + m_target = s; + return *this; +} + +Shortcut& Shortcut::arguments(const QString& s) +{ + m_arguments = s; + return *this; +} + +Shortcut& Shortcut::description(const QString& s) +{ + m_description = s; + return *this; +} + +Shortcut& Shortcut::icon(const QString& s, int index) +{ + m_icon = s; + m_iconIndex = index; + return *this; +} + +Shortcut& Shortcut::workingDirectory(const QString& s) +{ + m_workingDirectory = s; + return *this; +} + +bool Shortcut::exists(Locations) const +{ + // .lnk shortcuts are a Windows concept; no-op on Linux + return false; +} + +bool Shortcut::toggle(Locations) +{ + return false; +} + +bool Shortcut::add(Locations) +{ + // TODO: could create .desktop files on Linux in the future + return false; +} + +bool Shortcut::remove(Locations) +{ + return false; +} + +QString Shortcut::shortcutPath(Locations) const +{ + return {}; +} + +QString Shortcut::shortcutDirectory(Locations) const +{ + return {}; +} + +QString Shortcut::shortcutFilename() const +{ + return {}; +} + +QString toString(Shortcut::Locations loc) +{ + switch (loc) { + case Shortcut::None: + return "none"; + + case Shortcut::Desktop: + return "desktop"; + + case Shortcut::StartMenu: + return "start menu"; + + default: + return QString("? (%1)").arg(static_cast(loc)); + } +} + +} // namespace env + +#endif // _WIN32 diff --git a/src/src/envshortcut.h b/src/src/envshortcut.h new file mode 100644 index 0000000..43830b4 --- /dev/null +++ b/src/src/envshortcut.h @@ -0,0 +1,107 @@ +#ifndef ENV_SHORTCUT_H +#define ENV_SHORTCUT_H + +#include + +class Executable; + +namespace env +{ + +// an application shortcut that can be either on the desktop or the start menu +// +class Shortcut +{ +public: + // location of a shortcut + // + enum Locations + { + None = 0, + + // on the desktop + Desktop, + + // in the start menu + StartMenu + }; + + // empty shortcut + // + Shortcut(); + + // shortcut from an executable + // + explicit Shortcut(const Executable& exe); + + // sets the name of the shortcut, shown on icons and start menu entries + // + Shortcut& name(const QString& s); + + // the program to start + // + Shortcut& target(const QString& s); + + // arguments to pass + // + Shortcut& arguments(const QString& s); + + // shows in the status bar of explorer, for example + // + Shortcut& description(const QString& s); + + // path to a binary that contains the icon and its index + // + Shortcut& icon(const QString& s, int index = 0); + + // "start in" option for this shortcut + // + Shortcut& workingDirectory(const QString& s); + + // returns whether this shortcut already exists at the given location; this + // does not check whether the shortcut parameters are different, it merely if + // the .lnk file exists + // + bool exists(Locations loc) const; + + // calls remove() if exists(), or add() + // + bool toggle(Locations loc); + + // adds the shortcut to the given location + // + bool add(Locations loc); + + // removes the shortcut from the given location + // + bool remove(Locations loc); + +private: + QString m_name; + QString m_target; + QString m_arguments; + QString m_description; + QString m_icon; + int m_iconIndex; + QString m_workingDirectory; + + // returns the path where the shortcut file should be saved + // + QString shortcutPath(Locations loc) const; + + // returns the directory where the shortcut file should be saved + // + QString shortcutDirectory(Locations loc) const; + + // returns the filename of the shortcut file that should be used when saving + // + QString shortcutFilename() const; +}; + +// returns a string representation of the given location +// +QString toString(Shortcut::Locations loc); + +} // namespace env + +#endif // ENV_SHORTCUT_H diff --git a/src/src/envwindows.cpp b/src/src/envwindows.cpp new file mode 100644 index 0000000..24f98cf --- /dev/null +++ b/src/src/envwindows.cpp @@ -0,0 +1,305 @@ +#include "envwindows.h" +#include "env.h" +#include "envmodule.h" +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#include +#endif + +namespace env +{ + +using namespace MOBase; + +#ifdef _WIN32 + +WindowsInfo::WindowsInfo() +{ + // loading ntdll.dll, the functions will be found with GetProcAddress() + LibraryPtr ntdll(LoadLibraryW(L"ntdll.dll")); + + if (!ntdll) { + log::error("failed to load ntdll.dll while getting version"); + return; + } else { + m_reported = getReportedVersion(ntdll.get()); + m_real = getRealVersion(ntdll.get()); + } + + m_release = getRelease(); + m_elevated = getElevated(); +} + +WindowsInfo::Version WindowsInfo::getReportedVersion(HINSTANCE ntdll) const +{ + using RtlGetVersionType = NTSTATUS(NTAPI)(PRTL_OSVERSIONINFOW); + + auto* RtlGetVersion = + reinterpret_cast(GetProcAddress(ntdll, "RtlGetVersion")); + + if (!RtlGetVersion) { + log::error("RtlGetVersion() not found in ntdll.dll"); + return {}; + } + + OSVERSIONINFOEX vi = {}; + vi.dwOSVersionInfoSize = sizeof(vi); + + RtlGetVersion((RTL_OSVERSIONINFOW*)&vi); + + return {vi.dwMajorVersion, vi.dwMinorVersion, vi.dwBuildNumber}; +} + +WindowsInfo::Version WindowsInfo::getRealVersion(HINSTANCE ntdll) const +{ + using RtlGetNtVersionNumbersType = void(NTAPI)(DWORD*, DWORD*, DWORD*); + + auto* RtlGetNtVersionNumbers = reinterpret_cast( + GetProcAddress(ntdll, "RtlGetNtVersionNumbers")); + + if (!RtlGetNtVersionNumbers) { + log::error("RtlGetNtVersionNumbers not found in ntdll.dll"); + return {}; + } + + DWORD major = 0, minor = 0, build = 0; + RtlGetNtVersionNumbers(&major, &minor, &build); + + // for whatever reason, the build number has 0xf0000000 set + build = 0x0fffffff & build; + + return {major, minor, build}; +} + +WindowsInfo::Release WindowsInfo::getRelease() const +{ + QSettings settings( + R"(HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion)", + QSettings::NativeFormat); + + Release r; + + r.buildLab = settings.value("BuildLabEx", "").toString(); + if (r.buildLab.isEmpty()) { + r.buildLab = settings.value("BuildLab", "").toString(); + if (r.buildLab.isEmpty()) { + r.buildLab = settings.value("BuildBranch", "").toString(); + } + } + + r.ID = settings.value("DisplayVersion", "").toString(); + if (r.ID.isEmpty()) { + r.ID = settings.value("ReleaseId", "").toString(); + } + + r.UBR = settings.value("UBR", 0).toUInt(); + + return r; +} + +std::optional WindowsInfo::getElevated() const +{ + HandlePtr token; + + { + HANDLE rawToken = 0; + + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &rawToken)) { + const auto e = GetLastError(); + + log::error("while trying to check if process is elevated, " + "OpenProcessToken() failed: {}", + formatSystemMessage(e)); + + return {}; + } + + token.reset(rawToken); + } + + TOKEN_ELEVATION e = {}; + DWORD size = sizeof(TOKEN_ELEVATION); + + if (!GetTokenInformation(token.get(), TokenElevation, &e, sizeof(e), &size)) { + const auto e = GetLastError(); + + log::error("while trying to check if process is elevated, " + "GetTokenInformation() failed: {}", + formatSystemMessage(e)); + + return {}; + } + + return (e.TokenIsElevated != 0); +} + +#else // Linux + +WindowsInfo::WindowsInfo() +{ + m_reported = getKernelVersion(); + m_real = m_reported; + m_release = getRelease(); + m_elevated = getElevated(); +} + +WindowsInfo::Version WindowsInfo::getKernelVersion() const +{ + struct utsname uts; + if (uname(&uts) != 0) { + log::error("uname() failed"); + return {}; + } + + Version v; + + // Parse kernel version string like "6.18.9-2-cachyos" + QString kver = QString::fromUtf8(uts.release); + QStringList parts = kver.split('.'); + + if (parts.size() >= 1) v.major = parts[0].toUInt(); + if (parts.size() >= 2) v.minor = parts[1].toUInt(); + if (parts.size() >= 3) { + // Build may contain suffix like "9-2-cachyos", take numeric prefix + QString buildStr = parts[2]; + int dashPos = buildStr.indexOf('-'); + if (dashPos >= 0) { + buildStr = buildStr.left(dashPos); + } + v.build = buildStr.toUInt(); + } + + return v; +} + +WindowsInfo::Release WindowsInfo::getRelease() const +{ + Release r; + + // Parse /etc/os-release + std::ifstream osRelease("/etc/os-release"); + if (osRelease.is_open()) { + std::string line; + while (std::getline(osRelease, line)) { + // Lines are KEY=VALUE or KEY="VALUE" + auto eqPos = line.find('='); + if (eqPos == std::string::npos) continue; + + std::string key = line.substr(0, eqPos); + std::string val = line.substr(eqPos + 1); + + // Strip quotes + if (val.size() >= 2 && val.front() == '"' && val.back() == '"') { + val = val.substr(1, val.size() - 2); + } + + if (key == "PRETTY_NAME") { + r.buildLab = QString::fromStdString(val); + } else if (key == "VERSION_ID") { + r.ID = QString::fromStdString(val); + } + } + } + + return r; +} + +std::optional WindowsInfo::getElevated() const +{ + return (geteuid() == 0); +} + +#endif // _WIN32 + +bool WindowsInfo::compatibilityMode() const +{ +#ifdef _WIN32 + if (m_real == Version()) { + // don't know the real version, can't guess compatibility mode + return false; + } + + return (m_real != m_reported); +#else + // compatibility mode doesn't apply on Linux + return false; +#endif +} + +const WindowsInfo::Version& WindowsInfo::reportedVersion() const +{ + return m_reported; +} + +const WindowsInfo::Version& WindowsInfo::realVersion() const +{ + return m_real; +} + +const WindowsInfo::Release& WindowsInfo::release() const +{ + return m_release; +} + +std::optional WindowsInfo::isElevated() const +{ + return m_elevated; +} + +QString WindowsInfo::toString() const +{ + QStringList sl; + + const QString reported = m_reported.toString(); + const QString real = m_real.toString(); + + // version + sl.push_back("version " + reported); + + // real version if different + if (compatibilityMode()) { + sl.push_back("real version " + real); + } + +#ifdef _WIN32 + // build.UBR, such as 17763.557 + if (m_release.UBR != 0) { + DWORD build = 0; + + if (compatibilityMode()) { + build = m_real.build; + } else { + build = m_reported.build; + } + + sl.push_back(QString("%1.%2").arg(build).arg(m_release.UBR)); + } +#endif + + // release ID + if (!m_release.ID.isEmpty()) { + sl.push_back("release " + m_release.ID); + } + + // buildlab string / distro name + if (!m_release.buildLab.isEmpty()) { + sl.push_back(m_release.buildLab); + } + + // elevated + QString elevated = "?"; + if (m_elevated.has_value()) { + elevated = (*m_elevated ? "yes" : "no"); + } + + sl.push_back("elevated: " + elevated); + + return sl.join(", "); +} + +} // namespace env diff --git a/src/src/envwindows.h b/src/src/envwindows.h new file mode 100644 index 0000000..938bf3f --- /dev/null +++ b/src/src/envwindows.h @@ -0,0 +1,111 @@ +#ifndef ENV_WINDOWS_H +#define ENV_WINDOWS_H + +#include +#include + +namespace env +{ + +// a variety of information on the operating system +// +class WindowsInfo +{ +public: + struct Version + { +#ifdef _WIN32 + DWORD major = 0, minor = 0, build = 0; +#else + uint32_t major = 0, minor = 0, build = 0; +#endif + + QString toString() const + { + return QString("%1.%2.%3").arg(major).arg(minor).arg(build); + } + + friend bool operator==(const Version& a, const Version& b) + { + return a.major == b.major && a.minor == b.minor && a.build == b.build; + } + + friend bool operator!=(const Version& a, const Version& b) { return !(a == b); } + }; + + struct Release + { + // the BuildLab entry from the registry (Windows) or distro name (Linux), + // may be empty + QString buildLab; + + // release ID such as 1809 (Windows) or VERSION_ID (Linux), may be empty + QString ID; + + // some sub-build number, may be empty +#ifdef _WIN32 + DWORD UBR; +#else + uint32_t UBR; +#endif + + Release() : UBR(0) {} + }; + + WindowsInfo(); + + // tries to guess whether this process is running in compatibility mode + // + bool compatibilityMode() const; + + // returns the OS version + // + const Version& reportedVersion() const; + + // tries to guess the real version, can be empty + // + const Version& realVersion() const; + + // various information about the current release + // + const Release& release() const; + + // whether this process is running as administrator/root, may be empty if the + // information is not available + std::optional isElevated() const; + + // returns a string with all the above information on one line + // + QString toString() const; + +private: + Version m_reported, m_real; + Release m_release; + std::optional m_elevated; + +#ifdef _WIN32 + // uses RtlGetVersion() to get the version number as reported by Windows + // + Version getReportedVersion(HINSTANCE ntdll) const; + + // uses RtlGetNtVersionNumbers() to get the real version number + // + Version getRealVersion(HINSTANCE ntdll) const; +#else + // uses uname() to get the kernel version + // + Version getKernelVersion() const; +#endif + + // gets various information from the registry (Windows) or /etc/os-release (Linux) + // + Release getRelease() const; + + // gets whether the process is elevated + // + std::optional getElevated() const; +}; + +} // namespace env + +#endif // ENV_WINDOWS_H diff --git a/src/src/executableslist.cpp b/src/src/executableslist.cpp new file mode 100644 index 0000000..ea7a2e5 --- /dev/null +++ b/src/src/executableslist.cpp @@ -0,0 +1,496 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "executableslist.h" + +#include "iplugingame.h" +#include "settings.h" +#include "utility.h" +#include + +#include +#include +#include +#include + +#include + +using namespace MOBase; + +ExecutablesList::iterator ExecutablesList::begin() +{ + return m_Executables.begin(); +} + +ExecutablesList::const_iterator ExecutablesList::begin() const +{ + return m_Executables.begin(); +} + +ExecutablesList::iterator ExecutablesList::end() +{ + return m_Executables.end(); +} + +ExecutablesList::const_iterator ExecutablesList::end() const +{ + return m_Executables.end(); +} + +std::size_t ExecutablesList::size() const +{ + return m_Executables.size(); +} + +bool ExecutablesList::empty() const +{ + return m_Executables.empty(); +} + +void ExecutablesList::load(const MOBase::IPluginGame* game, const Settings& s) +{ + log::debug("loading executables"); + + m_Executables.clear(); + + // whether the executable list in the .ini is still using the old custom + // executables from 2.2.0, see upgradeFromCustom() + bool needsUpgrade = false; + + for (auto& map : s.executables()) { + Executable::Flags flags; + + if (map["toolbar"].toBool()) + flags |= Executable::ShowInToolbar; + + if (map["ownicon"].toBool()) + flags |= Executable::UseApplicationIcon; + + if (map["minimizeToSystemTray"].toBool()) + flags |= Executable::MinimizeToSystemTray; + + if (map["hide"].toBool()) + flags |= Executable::Hide; + + if (map.contains("custom")) { + // the "custom" setting only exists in older versions + needsUpgrade = true; + } + + setExecutable(Executable() + .title(map["title"].toString()) + .binaryInfo(QFileInfo(map["binary"].toString())) + .arguments(map["arguments"].toString()) + .steamAppID(map["steamAppID"].toString()) + .workingDirectory(map["workingDirectory"].toString()) + .flags(flags)); + } + + addFromPlugin(game, IgnoreExisting); + + if (needsUpgrade) + upgradeFromCustom(game); + + dump(); +} + +void ExecutablesList::store(Settings& s) +{ + std::vector> v; + + for (const auto& item : *this) { + std::map map; + + map["title"] = item.title(); + map["toolbar"] = item.isShownOnToolbar(); + map["ownicon"] = item.usesOwnIcon(); + map["hide"] = item.hide(); + map["binary"] = item.binaryInfo().filePath(); + map["arguments"] = item.arguments(); + map["workingDirectory"] = item.workingDirectory(); + map["steamAppID"] = item.steamAppID(); + map["minimizeToSystemTray"] = item.minimizeToSystemTray(); + + v.push_back(std::move(map)); + } + + s.setExecutables(v); +} + +std::vector +ExecutablesList::getPluginExecutables(MOBase::IPluginGame const* game) const +{ + Q_ASSERT(game != nullptr); + + std::vector v; + + for (const ExecutableInfo& info : game->executables()) { + if (!info.isValid()) { + continue; + } + + v.push_back({info, Executable::UseApplicationIcon}); + } + + const QFileInfo eppBin(QCoreApplication::applicationDirPath() + + "/explorer++/Explorer++.exe"); + + if (eppBin.exists()) { + const auto args = QString("\"%1\"").arg( + QDir::toNativeSeparators(game->dataDirectory().absolutePath())); + + const auto exe = Executable() + .title("Explore Virtual Folder") + .binaryInfo(eppBin) + .arguments(args) + .workingDirectory(eppBin.absolutePath()) + .flags(Executable::UseApplicationIcon); + + v.push_back(exe); + } + + return v; +} + +void ExecutablesList::resetFromPlugin(MOBase::IPluginGame const* game) +{ + log::debug("resetting plugin executables"); + + Q_ASSERT(game != nullptr); + + for (const auto& exe : getPluginExecutables(game)) { + setExecutable(exe, MoveExisting); + } +} + +void ExecutablesList::addFromPlugin(IPluginGame const* game, SetFlags flags) +{ + Q_ASSERT(game != nullptr); + + for (const auto& exe : getPluginExecutables(game)) { + setExecutable(exe, flags); + } +} + +const Executable& ExecutablesList::get(const QString& title) const +{ + for (const auto& exe : m_Executables) { + if (exe.title() == title) { + return exe; + } + } + + throw std::runtime_error( + QString("executable not found: %1").arg(title).toLocal8Bit().constData()); +} + +Executable& ExecutablesList::get(const QString& title) +{ + return const_cast(std::as_const(*this).get(title)); +} + +Executable& ExecutablesList::getByBinary(const QFileInfo& info) +{ + for (Executable& exe : m_Executables) { + if (exe.binaryInfo() == info) { + return exe; + } + } + throw std::runtime_error("invalid info"); +} + +ExecutablesList::iterator ExecutablesList::find(const QString& title, bool ci) +{ + const auto cif = ci ? Qt::CaseInsensitive : Qt::CaseSensitive; + + return std::find_if(begin(), end(), [&](auto&& e) { + return (e.title().compare(title, cif) == 0); + }); +} + +ExecutablesList::const_iterator ExecutablesList::find(const QString& title, + bool ci) const +{ + const auto cif = ci ? Qt::CaseInsensitive : Qt::CaseSensitive; + + return std::find_if(begin(), end(), [&](auto&& e) { + return (e.title().compare(title, cif) == 0); + }); +} + +bool ExecutablesList::titleExists(const QString& title) const +{ + auto test = [&](const Executable& exe) { + return exe.title() == title; + }; + return std::find_if(m_Executables.begin(), m_Executables.end(), test) != + m_Executables.end(); +} + +void ExecutablesList::setExecutable(const Executable& exe) +{ + setExecutable(exe, MergeExisting); +} + +void ExecutablesList::setExecutable(const Executable& exe, SetFlags flags) +{ + auto itor = find(exe.title()); + + if (itor != end()) { + if (flags == IgnoreExisting) { + return; + } + + if (flags == MoveExisting) { + const auto newTitle = makeNonConflictingTitle(exe.title()); + if (!newTitle) { + log::error("executable '{}' was in the way but could not be renamed", + exe.title()); + + return; + } + + log::warn("executable '{}' was in the way and was renamed to '{}'", itor->title(), + *newTitle); + + itor->title(*newTitle); + itor = end(); + } + } + + if (itor == m_Executables.end()) { + m_Executables.push_back(exe); + } else { + itor->mergeFrom(exe); + } +} + +void ExecutablesList::remove(const QString& title) +{ + auto itor = find(title); + if (itor != m_Executables.end()) { + m_Executables.erase(itor); + } +} + +std::optional ExecutablesList::makeNonConflictingTitle(const QString& prefix) +{ + const int max = 100; + + QString title = prefix; + + for (int i = 1; i < max; ++i) { + if (!titleExists(title)) { + return title; + } + + title = prefix + QString(" (%1)").arg(i); + } + + log::error("ran out of executable titles for prefix '{}'", prefix); + return {}; +} + +void ExecutablesList::upgradeFromCustom(MOBase::IPluginGame const* game) +{ + log::debug("upgrading executables list"); + + Q_ASSERT(game != nullptr); + + // prior to 2.2.1, plugin executables were special in the sense that they + // did not store certain settings, like the binary info and working directory; + // those were filled in when MO started, but never saved + // + // this interferes with the new executables list, which is completely + // customizable, because plugin executables are only added to the list when + // they don't exist at all and are ignored otherwise, leaving some of the + // fields completely blank + // + // when the "custom" setting is found in the .ini file (see load()), it is + // assumed that the older scheme is still present; in that case, the plugin + // executables force their binary info and working directory into the existing + // executables one last time + // + // from that point on, plugin executables are ignored unless they're + // completely missing from the list, allowing users to customize them as they + // want + + for (const auto& exe : getPluginExecutables(game)) { + auto itor = find(exe.title()); + if (itor == end()) { + continue; + } + + if (!itor->binaryInfo().exists()) { + itor->binaryInfo(exe.binaryInfo()); + } + + if (itor->workingDirectory().isEmpty()) { + itor->workingDirectory(exe.workingDirectory()); + } + } +} + +void ExecutablesList::dump() const +{ + for (const auto& e : m_Executables) { + QStringList flags; + + if (e.flags() & Executable::ShowInToolbar) { + flags.push_back("toolbar"); + } + + if (e.flags() & Executable::UseApplicationIcon) { + flags.push_back("icon"); + } + + if (e.flags() & Executable::Hide) { + flags.push_back("hide"); + } + + if (e.flags() & Executable::MinimizeToSystemTray) { + flags.push_back("minimizeToSystemTray"); + } + + log::debug(" . executable '{}'\n" + " binary: {}\n" + " arguments: {}\n" + " steam ID: {}\n" + " directory: {}\n" + " flags: {} ({})", + e.title(), e.binaryInfo().filePath(), e.arguments(), e.steamAppID(), + e.workingDirectory(), flags.join("|"), e.flags()); + } +} + +Executable::Executable(QString title) : m_title(title) {} + +Executable::Executable(const MOBase::ExecutableInfo& info, Flags flags) + : m_title(info.title()), m_binaryInfo(info.binary()), + m_arguments(info.arguments().join(" ")), m_steamAppID(info.steamAppID()), + m_workingDirectory(info.workingDirectory().path()), m_flags(flags) +{} + +const QString& Executable::title() const +{ + return m_title; +} + +const QFileInfo& Executable::binaryInfo() const +{ + return m_binaryInfo; +} + +const QString& Executable::arguments() const +{ + return m_arguments; +} + +const QString& Executable::steamAppID() const +{ + return m_steamAppID; +} + +const QString& Executable::workingDirectory() const +{ + return m_workingDirectory; +} + +Executable::Flags Executable::flags() const +{ + return m_flags; +} + +Executable& Executable::title(const QString& s) +{ + m_title = s; + return *this; +} + +Executable& Executable::binaryInfo(const QFileInfo& fi) +{ + m_binaryInfo = fi; + return *this; +} + +Executable& Executable::arguments(const QString& s) +{ + m_arguments = s; + return *this; +} + +Executable& Executable::steamAppID(const QString& s) +{ + m_steamAppID = s; + return *this; +} + +Executable& Executable::workingDirectory(const QString& s) +{ + m_workingDirectory = s; + return *this; +} + +Executable& Executable::flags(Flags f) +{ + m_flags = f; + return *this; +} + +bool Executable::isShownOnToolbar() const +{ + return m_flags.testFlag(ShowInToolbar); +} + +void Executable::setShownOnToolbar(bool state) +{ + if (state) { + m_flags |= ShowInToolbar; + } else { + m_flags &= ~ShowInToolbar; + } +} + +bool Executable::usesOwnIcon() const +{ + return m_flags.testFlag(UseApplicationIcon); +} + +bool Executable::minimizeToSystemTray() const +{ + return m_flags.testFlag(MinimizeToSystemTray); +} + +bool Executable::hide() const +{ + return m_flags.testFlag(Hide); +} + +void Executable::mergeFrom(const Executable& other) +{ + // this happens after executables are loaded from settings and plugin + // executables are being added, or when users are modifying executables + + m_title = other.title(); + m_binaryInfo = other.binaryInfo(); + m_arguments = other.arguments(); + m_steamAppID = other.steamAppID(); + m_workingDirectory = other.workingDirectory(); + m_flags = other.flags(); +} diff --git a/src/src/executableslist.h b/src/src/executableslist.h new file mode 100644 index 0000000..b402461 --- /dev/null +++ b/src/src/executableslist.h @@ -0,0 +1,234 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef EXECUTABLESLIST_H +#define EXECUTABLESLIST_H + +#include "executableinfo.h" + +#include +#include + +#include +#include + +#include + +namespace MOBase +{ +class IPluginGame; +class ExecutableInfo; +} // namespace MOBase +class Settings; + +/*! + * @brief Information about an executable + **/ +class Executable : public MOBase::IExecutable +{ +public: + enum Flag + { + ShowInToolbar = 0x02, + UseApplicationIcon = 0x04, + Hide = 0x08, + MinimizeToSystemTray = 0x16 + }; + + Q_DECLARE_FLAGS(Flags, Flag); + + Executable(QString title = {}); + + /** + * @brief Executable from plugin + */ + Executable(const MOBase::ExecutableInfo& info, Flags flags); + + const QString& title() const override; + const QFileInfo& binaryInfo() const override; + const QString& arguments() const override; + const QString& steamAppID() const override; + const QString& workingDirectory() const override; + Flags flags() const; + + Executable& title(const QString& s); + Executable& binaryInfo(const QFileInfo& fi); + Executable& arguments(const QString& s); + Executable& steamAppID(const QString& s); + Executable& workingDirectory(const QString& s); + Executable& flags(Flags f); + + bool isShownOnToolbar() const; + void setShownOnToolbar(bool state); + bool usesOwnIcon() const; + bool minimizeToSystemTray() const; + bool hide() const; + + void mergeFrom(const Executable& other); + +private: + QString m_title; + QFileInfo m_binaryInfo; + QString m_arguments; + QString m_steamAppID; + QString m_workingDirectory; + Flags m_flags; +}; + +/*! + * @brief List of executables configured to by started from MO + **/ +class ExecutablesList +{ +public: + using vector_type = std::vector; + using iterator = vector_type::iterator; + using const_iterator = vector_type::const_iterator; + + /** + * standard container interface + */ + iterator begin(); + const_iterator begin() const; + iterator end(); + const_iterator end() const; + std::size_t size() const; + bool empty() const; + + /** + * @brief initializes the list from the settings and the given plugin + **/ + void load(const MOBase::IPluginGame* game, const Settings& settings); + + /** + * @brief re-adds all the executables from the plugin and renames existing + * executables that are in the way + **/ + void resetFromPlugin(MOBase::IPluginGame const* game); + + /** + * @brief writes the current list to the settings + */ + void store(Settings& settings); + + /** + * @brief get an executable by name + * + * @param title the title of the executable to look up + * @return the executable + * @exception runtime_error will throw an exception if the executable is not found + **/ + const Executable& get(const QString& title) const; + + /** + * @brief find an executable by its name + * + * @param title the title of the executable to look up + * @return the executable + * @exception runtime_error will throw an exception if the name is not correct + **/ + Executable& get(const QString& title); + + /** + * @brief find an executable by a fileinfo structure + * @param info the info object to search for + * @return the executable + * @exception runtime_error will throw an exception if the name is not correct + */ + Executable& getByBinary(const QFileInfo& info); + + /** + * @brief returns an iterator for the given executable by title, or end() + */ + iterator find(const QString& title, bool caseSensitive = true); + const_iterator find(const QString& title, bool caseSensitive = true) const; + + /** + * @brief determine if an executable exists + * @param title the title of the executable to look up + * @return true if the executable exists, false otherwise + **/ + bool titleExists(const QString& title) const; + + /** + * @brief add a new executable to the list + * @param executable + */ + void setExecutable(const Executable& executable); + + /** + * @brief remove the executable with the specified file name. This needs to + * be an absolute file path + * + * @param title title of the executable to remove + * @note if the executable name is invalid, nothing happens. There is no way + * to determine if this was successful + **/ + void remove(const QString& title); + + /** + * returns a title that starts with the given prefix and does not clash with + * an existing executable, may fail + */ + std::optional makeNonConflictingTitle(const QString& prefix); + +private: + enum SetFlags + { + // executables having the same name as existing ones are ignored + IgnoreExisting = 1, + + // executables having the same name are merged + MergeExisting, + + // an existing executable with the same name is renamed + MoveExisting + }; + + std::vector m_Executables; + + /** + * @brief add the executables preconfigured for this game + **/ + void addFromPlugin(MOBase::IPluginGame const* game, SetFlags flags); + + /** + * @brief add a new executable to the list + * @param executable + */ + void setExecutable(const Executable& exe, SetFlags flags); + + /** + * returns the executables provided by the game plugin + **/ + std::vector getPluginExecutables(MOBase::IPluginGame const* game) const; + + /** + * called when MO is still using the old custom executables from 2.2.0 + **/ + void upgradeFromCustom(const MOBase::IPluginGame* game); + + // logs all executables + // + void dump() const; +}; + +Q_DECLARE_OPERATORS_FOR_FLAGS(Executable::Flags) + +#endif // EXECUTABLESLIST_H diff --git a/src/src/executableslistproxy.cpp b/src/src/executableslistproxy.cpp new file mode 100644 index 0000000..75df736 --- /dev/null +++ b/src/src/executableslistproxy.cpp @@ -0,0 +1,63 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "executableslistproxy.h" + +#include "executableslist.h" + +#include +#include + +#include +#include + +ExecutablesListProxy::ExecutablesListProxy(ExecutablesList* executablesList) + : m_Proxied(executablesList) +{} + +std::generator ExecutablesListProxy::executables() const +{ + for (const auto& exe : *m_Proxied) { + co_yield exe; + } +} + +const MOBase::IExecutable* ExecutablesListProxy::getByTitle(const QString& title) const +{ + try { + return &m_Proxied->get(title); + } catch (const std::runtime_error&) { + return nullptr; + } +} + +const MOBase::IExecutable* +ExecutablesListProxy::getByBinary(const QFileInfo& info) const +{ + try { + return &m_Proxied->getByBinary(info); + } catch (const std::runtime_error&) { + return nullptr; + } +} + +bool ExecutablesListProxy::contains(const QString& title) const +{ + return m_Proxied->titleExists(title); +} diff --git a/src/src/executableslistproxy.h b/src/src/executableslistproxy.h new file mode 100644 index 0000000..ac8e299 --- /dev/null +++ b/src/src/executableslistproxy.h @@ -0,0 +1,46 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef EXECUTABLESLISTPROXY_H +#define EXECUTABLESLISTPROXY_H + +#include "executableslist.h" + +#include + +#include +#include + +#include +#include + +class ExecutablesListProxy : public MOBase::IExecutablesList +{ +public: + ExecutablesListProxy(ExecutablesList* executablesList); + std::generator executables() const override; + const MOBase::IExecutable* getByTitle(const QString& title) const override; + const MOBase::IExecutable* getByBinary(const QFileInfo& info) const override; + bool contains(const QString& title) const override; + +private: + ExecutablesList* m_Proxied; +}; + +#endif // EXECUTABLESLISTPROXY_H diff --git a/src/src/filedialogmemory.cpp b/src/src/filedialogmemory.cpp new file mode 100644 index 0000000..390843f --- /dev/null +++ b/src/src/filedialogmemory.cpp @@ -0,0 +1,83 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "filedialogmemory.h" +#include "settings.h" +#include + +static std::map g_Cache; + +void FileDialogMemory::save(Settings& s) +{ + s.paths().setRecent(g_Cache); +} + +void FileDialogMemory::restore(const Settings& s) +{ + g_Cache = s.paths().recent(); +} + +QString FileDialogMemory::getOpenFileName(const QString& dirID, QWidget* parent, + const QString& caption, const QString& dir, + const QString& filter, + QString* selectedFilter, + QFileDialog::Options options) +{ + QString currentDir = dir; + + if (currentDir.isEmpty()) { + auto itor = g_Cache.find(dirID); + if (itor != g_Cache.end()) { + currentDir = itor->second; + } + } + + QString result = QFileDialog::getOpenFileName(parent, caption, currentDir, filter, + selectedFilter, options); + + if (!result.isNull()) { + g_Cache[dirID] = QFileInfo(result).path(); + } + + return result; +} + +QString FileDialogMemory::getExistingDirectory(const QString& dirID, QWidget* parent, + const QString& caption, + const QString& dir, + QFileDialog::Options options) +{ + QString currentDir = dir; + + if (currentDir.isEmpty()) { + auto itor = g_Cache.find(dirID); + if (itor != g_Cache.end()) { + currentDir = itor->second; + } + } + + QString result = + QFileDialog::getExistingDirectory(parent, caption, currentDir, options); + + if (!result.isNull()) { + g_Cache[dirID] = result; + } + + return result; +} diff --git a/src/src/filedialogmemory.h b/src/src/filedialogmemory.h new file mode 100644 index 0000000..c627ba9 --- /dev/null +++ b/src/src/filedialogmemory.h @@ -0,0 +1,51 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef FILEDIALOGMEMORY_H +#define FILEDIALOGMEMORY_H + +#include +#include +#include + +class Settings; + +class FileDialogMemory +{ +public: + FileDialogMemory() = delete; + + static void save(Settings& settings); + static void restore(const Settings& settings); + + static QString getOpenFileName(const QString& dirID, QWidget* parent = 0, + const QString& caption = QString(), + const QString& dir = QString(), + const QString& filter = QString(), + QString* selectedFilter = 0, + QFileDialog::Options options = QFileDialog::Option(0)); + + static QString + getExistingDirectory(const QString& dirID, QWidget* parent = 0, + const QString& caption = QString(), + const QString& dir = QString(), + QFileDialog::Options options = QFileDialog::ShowDirsOnly); +}; + +#endif // FILEDIALOGMEMORY_H diff --git a/src/src/filerenamer.cpp b/src/src/filerenamer.cpp new file mode 100644 index 0000000..7319a63 --- /dev/null +++ b/src/src/filerenamer.cpp @@ -0,0 +1,204 @@ +#include "filerenamer.h" +#include +#include +#include +#include + +using namespace MOBase; + +FileRenamer::FileRenamer(QWidget* parent, QFlags flags) + : m_parent(parent), m_flags(flags) +{ + // sanity check for flags + if ((m_flags & (HIDE | UNHIDE)) == 0) { + log::error("renameFile() missing hide flag"); + // doesn't really matter, it's just for text + m_flags = HIDE; + } +} + +FileRenamer::RenameResults FileRenamer::rename(const QString& oldName, + const QString& newName) +{ + log::debug("renaming {} to {}", oldName, newName); + + if (QFileInfo(newName).exists()) { + log::debug("{} already exists", newName); + + // target file already exists, confirm replacement + auto answer = confirmReplace(newName); + + switch (answer) { + case DECISION_SKIP: { + // user wants to skip this file + log::debug("skipping {}", oldName); + return RESULT_SKIP; + } + + case DECISION_REPLACE: { + log::debug("removing {}", newName); + + // user wants to replace the file, so remove it + const auto r = shell::Delete(QFileInfo(newName)); + + if (!r.success()) { + log::error("failed to remove '{}': {}", newName, r.toString()); + + // removal failed, warn the user and allow canceling + if (!removeFailed(newName, r)) { + log::debug("canceling {}", oldName); + // user wants to cancel + return RESULT_CANCEL; + } + + // ignore this file and continue on + log::debug("skipping {}", oldName); + return RESULT_SKIP; + } + + break; + } + + case DECISION_CANCEL: // fall-through + default: { + // user wants to stop + log::debug("canceling"); + return RESULT_CANCEL; + } + } + } + + // target either didn't exist or was removed correctly + const auto r = shell::Rename(QFileInfo(oldName), QFileInfo(newName)); + + if (!r.success()) { + log::error("failed to rename '{}' to '{}': {}", oldName, newName, r.toString()); + + // renaming failed, warn the user and allow canceling + if (!renameFailed(oldName, newName, r)) { + // user wants to cancel + log::debug("canceling"); + return RESULT_CANCEL; + } + + // ignore this file and continue on + log::debug("skipping {}", oldName); + return RESULT_SKIP; + } + + // everything worked + log::debug("successfully renamed {} to {}", oldName, newName); + return RESULT_OK; +} + +FileRenamer::RenameDecision FileRenamer::confirmReplace(const QString& newName) +{ + if (m_flags & REPLACE_ALL) { + // user wants to silently replace all + log::debug("user has selected replace all"); + return DECISION_REPLACE; + } else if (m_flags & REPLACE_NONE) { + // user wants to silently skip all + log::debug("user has selected replace none"); + return DECISION_SKIP; + } + + QString text; + + if (m_flags & HIDE) { + text = + QObject::tr("The hidden file \"%1\" already exists. Replace it?").arg(newName); + } else if (m_flags & UNHIDE) { + text = + QObject::tr("The visible file \"%1\" already exists. Replace it?").arg(newName); + } + + auto buttons = QMessageBox::Yes | QMessageBox::No; + if (m_flags & MULTIPLE) { + // only show these buttons when there are multiple files to replace + buttons |= QMessageBox::YesToAll | QMessageBox::NoToAll | QMessageBox::Cancel; + } + + const auto answer = + QMessageBox::question(m_parent, QObject::tr("Replace file?"), text, buttons); + + switch (answer) { + case QMessageBox::Yes: + log::debug("user wants to replace"); + return DECISION_REPLACE; + + case QMessageBox::No: + log::debug("user wants to skip"); + return DECISION_SKIP; + + case QMessageBox::YesToAll: + log::debug("user wants to replace all"); + // remember the answer + m_flags |= REPLACE_ALL; + return DECISION_REPLACE; + + case QMessageBox::NoToAll: + log::debug("user wants to replace none"); + // remember the answer + m_flags |= REPLACE_NONE; + return DECISION_SKIP; + + case QMessageBox::Cancel: // fall-through + default: + log::debug("user wants to cancel"); + return DECISION_CANCEL; + } +} + +bool FileRenamer::removeFailed(const QString& name, const shell::Result& r) +{ + QMessageBox::StandardButtons buttons = QMessageBox::Ok; + if (m_flags & MULTIPLE) { + // only show cancel for multiple files + buttons |= QMessageBox::Cancel; + } + + const auto answer = QMessageBox::critical( + m_parent, QObject::tr("File operation failed"), + QObject::tr("Failed to remove \"%1\": %2").arg(name).arg(r.toString()), buttons); + + if (answer == QMessageBox::Cancel) { + // user wants to stop + log::debug("user wants to cancel"); + return false; + } + + // skip this one and continue + log::debug("user wants to skip"); + return true; +} + +bool FileRenamer::renameFailed(const QString& oldName, const QString& newName, + const shell::Result& r) +{ + QMessageBox::StandardButtons buttons = QMessageBox::Ok; + if (m_flags & MULTIPLE) { + // only show cancel for multiple files + buttons |= QMessageBox::Cancel; + } + + const auto answer = + QMessageBox::critical(m_parent, QObject::tr("File operation failed"), + QObject::tr("Failed to rename file: %1.\r\n\r\n" + "Source:\r\n\"%2\"\r\n\r\n" + "Destination:\r\n\"%3\"") + .arg(r.toString()) + .arg(QDir::toNativeSeparators(oldName)) + .arg(QDir::toNativeSeparators(newName)), + buttons); + + if (answer == QMessageBox::Cancel) { + // user wants to stop + log::debug("user wants to cancel"); + return false; + } + + // skip this one and continue + log::debug("user wants to skip"); + return true; +} diff --git a/src/src/filerenamer.h b/src/src/filerenamer.h new file mode 100644 index 0000000..20b9a3d --- /dev/null +++ b/src/src/filerenamer.h @@ -0,0 +1,144 @@ +#ifndef FILERENAMER_H +#define FILERENAMER_H + +#include + +namespace MOBase::shell +{ +class Result; +} + +/** + * Renames individual files and handles dialog boxes to confirm replacements and + * failures with the user + **/ +class FileRenamer +{ +public: + /** + * controls appearance and replacement behaviour; if RENAME_REPLACE_ALL and + * RENAME_REPLACE_NONE are not provided, the user will have the option to + * choose on the first replacement + **/ + enum RenameFlags + { + /** + * this renamer will be used on multiple files, so display additional + * buttons to replace all and for canceling + **/ + MULTIPLE = 0x01, + + /** + * customizes some of the text shown on dialog to mention that files are + * being hidden + **/ + HIDE = 0x02, + + /** + * customizes some of the text shown on dialog to mention that files are + * being unhidden + **/ + UNHIDE = 0x04, + + /** + * silently replaces all existing files + **/ + REPLACE_ALL = 0x08, + + /** + * silently skips all existing files + **/ + REPLACE_NONE = 0x10, + }; + + /** result of a single rename + * + **/ + enum RenameResults + { + /** + * the user skipped this file + */ + RESULT_SKIP, + + /** + * the file was successfully renamed + */ + RESULT_OK, + + /** + * the user wants to cancel + */ + RESULT_CANCEL + }; + + /** + * @param parent Parent widget for dialog boxes + **/ + FileRenamer(QWidget* parent, QFlags flags); + + /** + * renames the given file + * @param oldName current filename + * @param newName new filename + * @return whether the file was renamed, skipped or the user wants to cancel + **/ + RenameResults rename(const QString& oldName, const QString& newName); + +private: + /** + *user's decision when replacing + **/ + enum RenameDecision + { + /** + * replace the file + **/ + DECISION_REPLACE, + + /** + * skip the file + **/ + DECISION_SKIP, + + /** + * cancel the whole thing + **/ + DECISION_CANCEL + }; + + /** + * parent widget for dialog boxes + **/ + QWidget* m_parent; + + /** + * flags + **/ + QFlags m_flags; + + /** + * asks the user to replace an existing file, may return early if the user + * has already selected to replace all/none + * @return whether to replace, skip or cancel + **/ + RenameDecision confirmReplace(const QString& newName); + + /** + * removal of a file failed, ask the user to continue or cancel + * @param name The name of the file that failed to be removed + * @return true to continue, false to stop + **/ + bool removeFailed(const QString& name, const MOBase::shell::Result& r); + + /** + * renaming a file failed, ask the user to continue or cancel + * @param oldName current filename + * @param newName new filename + * @return true to continue, false to stop + **/ + bool renameFailed(const QString& oldName, const QString& newName, + const MOBase::shell::Result& r); +}; + +#endif // FILERENAMER_H diff --git a/src/src/filetree.cpp b/src/src/filetree.cpp new file mode 100644 index 0000000..d89cf52 --- /dev/null +++ b/src/src/filetree.cpp @@ -0,0 +1,830 @@ +#include "filetree.h" +#include "envshell.h" +#include "filetreeitem.h" +#include "filetreemodel.h" +#include "organizercore.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include +#include + +using namespace MOShared; +using namespace MOBase; + +bool canPreviewFile(const PluginContainer& pc, const FileEntry& file) +{ + return canPreviewFile(pc, file.isFromArchive(), + QString::fromStdWString(file.getName())); +} + +bool canRunFile(const FileEntry& file) +{ + return canRunFile(file.isFromArchive(), QString::fromStdWString(file.getName())); +} + +bool canOpenFile(const FileEntry& file) +{ + return canOpenFile(file.isFromArchive(), QString::fromStdWString(file.getName())); +} + +bool isHidden(const FileEntry& file) +{ + return (QString::fromStdWString(file.getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)); +} + +bool canExploreFile(const FileEntry& file); +bool canHideFile(const FileEntry& file); +bool canUnhideFile(const FileEntry& file); + +class MenuItem +{ +public: + MenuItem(QString s = {}) : m_action(new QAction(std::move(s))) {} + + MenuItem& caption(const QString& s) + { + m_action->setText(s); + return *this; + } + + template + MenuItem& callback(F&& f) + { + QObject::connect(m_action, &QAction::triggered, std::forward(f)); + return *this; + } + + MenuItem& hint(const QString& s) + { + m_tooltip = s; + return *this; + } + + MenuItem& disabledHint(const QString& s) + { + m_disabledHint = s; + return *this; + } + + MenuItem& enabled(bool b) + { + m_action->setEnabled(b); + return *this; + } + + void addTo(QMenu& menu) + { + QString s; + + setTips(); + + m_action->setParent(&menu); + menu.addAction(m_action); + } + +private: + QAction* m_action; + QString m_tooltip; + QString m_disabledHint; + + void setTips() + { + if (m_action->isEnabled() || m_disabledHint.isEmpty()) { + m_action->setStatusTip(m_tooltip); + return; + } + + QString s = m_tooltip.trimmed(); + + if (!s.isEmpty()) { + if (!s.endsWith(".")) { + s += "."; + } + + s += "\n"; + } + + s += QObject::tr("Disabled because") + ": " + m_disabledHint.trimmed(); + + if (!s.endsWith(".")) { + s += "."; + } + + m_action->setStatusTip(s); + } +}; + +FileTree::FileTree(OrganizerCore& core, PluginContainer& pc, QTreeView* tree) + : m_core(core), m_plugins(pc), m_tree(tree), m_model(new FileTreeModel(core)) +{ + m_tree->sortByColumn(0, Qt::AscendingOrder); + m_tree->setModel(m_model); + m_tree->header()->resizeSection(0, 200); + + MOBase::setCustomizableColumns(m_tree); + + connect(m_tree, &QTreeView::customContextMenuRequested, [&](auto pos) { + onContextMenu(pos); + }); + + connect(m_tree, &QTreeView::expanded, [&](auto&& index) { + onExpandedChanged(index, true); + }); + + connect(m_tree, &QTreeView::collapsed, [&](auto&& index) { + onExpandedChanged(index, false); + }); + + connect(m_tree, &QTreeView::activated, [&](auto&& index) { + onItemActivated(index); + }); +} + +FileTreeModel* FileTree::model() +{ + return m_model; +} + +void FileTree::refresh() +{ + m_model->refresh(); +} + +void FileTree::clear() +{ + m_model->clear(); +} + +bool FileTree::fullyLoaded() const +{ + return m_model->fullyLoaded(); +} + +void FileTree::ensureFullyLoaded() +{ + m_model->ensureFullyLoaded(); +} + +FileTreeItem* FileTree::singleSelection() +{ + const auto sel = m_tree->selectionModel()->selectedRows(); + if (sel.size() == 1) { + return m_model->itemFromIndex(proxiedIndex(sel[0])); + } + + return nullptr; +} + +void FileTree::open(FileTreeItem* item) +{ + if (!item) { + item = singleSelection(); + + if (!item) { + return; + } + } + + if (item->isFromArchive() || item->isDirectory()) { + return; + } + + const QString path = item->realPath(); + const QFileInfo targetInfo(path); + + m_core.processRunner() + .setFromFile(m_tree->window(), targetInfo) + .setHooked(false) + .setWaitForCompletion(ProcessRunner::TriggerRefresh) + .run(); +} + +void FileTree::openHooked(FileTreeItem* item) +{ + if (!item) { + item = singleSelection(); + + if (!item) { + return; + } + } + + if (item->isFromArchive() || item->isDirectory()) { + return; + } + + const QString path = item->realPath(); + const QFileInfo targetInfo(path); + + m_core.processRunner() + .setFromFile(m_tree->window(), targetInfo) + .setHooked(true) + .setWaitForCompletion(ProcessRunner::TriggerRefresh) + .run(); +} + +void FileTree::preview(FileTreeItem* item) +{ + if (!item) { + item = singleSelection(); + + if (!item) { + return; + } + } + + const QString path = item->dataRelativeFilePath(); + m_core.previewFileWithAlternatives(m_tree->window(), path); +} + +void FileTree::activate(FileTreeItem* item) +{ + if (item->isDirectory()) { + // activating a directory should just toggle expansion + return; + } + + const auto tryPreview = m_core.settings().interface().doubleClicksOpenPreviews(); + + if (tryPreview) { + const QFileInfo fi(item->realPath()); + if (m_plugins.previewGenerator().previewSupported(fi.suffix().toLower(), + item->isFromArchive())) { + preview(item); + return; + } + } + + open(item); +} + +void FileTree::addAsExecutable(FileTreeItem* item) +{ + if (!item) { + item = singleSelection(); + + if (!item) { + return; + } + } + + const QString path = item->realPath(); + const QFileInfo target(path); + const auto fec = spawn::getFileExecutionContext(m_tree->window(), target); + + switch (fec.type) { + case spawn::FileExecutionTypes::Executable: { + const QString name = QInputDialog::getText( + m_tree->window(), tr("Enter Name"), tr("Enter a name for the executable"), + QLineEdit::Normal, target.completeBaseName()); + + if (!name.isEmpty()) { + // Note: If this already exists, you'll lose custom settings + m_core.executablesList()->setExecutable( + Executable() + .title(name) + .binaryInfo(fec.binary) + .arguments(fec.arguments) + .workingDirectory(target.absolutePath())); + + emit executablesChanged(); + } + + break; + } + + case spawn::FileExecutionTypes::Other: // fall-through + default: { + QMessageBox::information(m_tree->window(), tr("Not an executable"), + tr("This is not a recognized executable.")); + + break; + } + } +} + +void FileTree::exploreOrigin(FileTreeItem* item) +{ + if (!item) { + item = singleSelection(); + + if (!item) { + return; + } + } + + if (item->isFromArchive() || item->isDirectory()) { + return; + } + + const auto path = item->realPath(); + + log::debug("opening in explorer: {}", path); + shell::Explore(path); +} + +void FileTree::openModInfo(FileTreeItem* item) +{ + if (!item) { + item = singleSelection(); + + if (!item) { + return; + } + } + + const auto originID = item->originID(); + + if (originID == 0) { + // unmanaged + return; + } + + const auto& origin = m_core.directoryStructure()->getOriginByID(originID); + const auto& name = QString::fromStdWString(origin.getName()); + + unsigned int index = ModInfo::getIndex(name); + if (index == UINT_MAX) { + log::error("can't open mod info, mod '{}' not found", name); + return; + } + + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + if (modInfo) { + emit displayModInformation(modInfo, index, ModInfoTabIDs::None); + } +} + +void FileTree::toggleVisibility(bool visible, FileTreeItem* item) +{ + if (!item) { + item = singleSelection(); + + if (!item) { + return; + } + } + + const QString currentName = item->realPath(); + QString newName; + + if (visible) { + if (!currentName.endsWith(ModInfo::s_HiddenExt)) { + log::error("cannot unhide '{}', doesn't end with '{}'", currentName, + ModInfo::s_HiddenExt); + + return; + } + + newName = currentName.left(currentName.size() - ModInfo::s_HiddenExt.size()); + } else { + if (currentName.endsWith(ModInfo::s_HiddenExt)) { + log::error("cannot hide '{}', already ends with '{}'", currentName, + ModInfo::s_HiddenExt); + + return; + } + + newName = currentName + ModInfo::s_HiddenExt; + } + + log::debug("attempting to rename '{}' to '{}'", currentName, newName); + + FileRenamer renamer(m_tree->window(), + (visible ? FileRenamer::UNHIDE : FileRenamer::HIDE)); + + if (renamer.rename(currentName, newName) == FileRenamer::RESULT_OK) { + emit originModified(item->originID()); + refresh(); + } +} + +void FileTree::hide(FileTreeItem* item) +{ + toggleVisibility(false, item); +} + +void FileTree::unhide(FileTreeItem* item) +{ + toggleVisibility(true, item); +} + +class DumpFailed +{}; + +void FileTree::dumpToFile() const +{ + log::debug("dumping filetree to file"); + + QString file = QFileDialog::getSaveFileName(m_tree->window()); + if (file.isEmpty()) { + log::debug("user canceled"); + return; + } + + m_core.directoryStructure()->dump(file.toStdWString()); +} + +void FileTree::onExpandedChanged(const QModelIndex& index, bool expanded) +{ + if (auto* item = m_model->itemFromIndex(proxiedIndex(index))) { + item->setExpanded(expanded); + } +} + +void FileTree::onItemActivated(const QModelIndex& index) +{ + auto* item = m_model->itemFromIndex(proxiedIndex(index)); + if (!item) { + return; + } + + activate(item); +} + +void FileTree::onContextMenu(const QPoint& pos) +{ + const auto m = QApplication::keyboardModifiers(); + + if (m & Qt::ShiftModifier) { + // shift+right-click won't select individual items because it interferes + // with regular shift selection; this makes it behave like explorer: + // - if the right-clicked item is currently part of the selection, show + // the menu for the selection + // - if not, select this item only and show the menu for it + const auto index = m_tree->indexAt(pos); + + if (!m_tree->selectionModel()->isSelected(index)) { + m_tree->selectionModel()->select(index, QItemSelectionModel::ClearAndSelect | + QItemSelectionModel::Rows | + QItemSelectionModel::Current); + } + + // if no shell menu was available, continue on and show the regular + // context menu + if (showShellMenu(pos)) { + return; + } + } + + QMenu menu; + + if (auto* item = singleSelection()) { + if (item->isDirectory()) { + addDirectoryMenus(menu, *item); + } else { + const auto file = m_core.directoryStructure()->searchFile( + item->dataRelativeFilePath().toStdWString(), nullptr); + + if (file) { + addFileMenus(menu, *file, item->originID()); + } + } + } + + addCommonMenus(menu); + + menu.exec(m_tree->viewport()->mapToGlobal(pos)); +} + +QMainWindow* getMainWindow(QWidget* w) +{ + QWidget* p = w; + + while (p) { + if (auto* mw = dynamic_cast(p)) { + return mw; + } + + p = p->parentWidget(); + } + + return nullptr; +} + +bool FileTree::showShellMenu(QPoint pos) +{ + auto* mw = getMainWindow(m_tree); + + // menus by origin + std::map menus; + int totalFiles = 0; + bool warnOnEmpty = true; + + for (auto&& index : m_tree->selectionModel()->selectedRows()) { + auto* item = m_model->itemFromIndex(proxiedIndex(index)); + if (!item) { + continue; + } + + if (item->isDirectory()) { + warnOnEmpty = false; + + log::warn("directories do not have shell menus; '{}' selected", item->filename()); + + continue; + } + + if (item->isFromArchive()) { + warnOnEmpty = false; + + log::warn("files from archives do not have shell menus; '{}' selected", + item->filename()); + + continue; + } + + auto itor = menus.find(item->originID()); + if (itor == menus.end()) { + itor = menus.emplace(item->originID(), mw).first; + } + + if (!QFile::exists(item->realPath())) { + log::error("{}", tr("File '%1' does not exist, you may need to refresh.") + .arg(item->realPath())); + } + + itor->second.addFile(QFileInfo(item->realPath())); + ++totalFiles; + + if (item->isConflicted()) { + const auto file = m_core.directoryStructure()->searchFile( + item->dataRelativeFilePath().toStdWString(), nullptr); + + if (!file) { + log::error("file '{}' not found, data path={}, real path={}", item->filename(), + item->dataRelativeFilePath(), item->realPath()); + + continue; + } + + const auto alts = file->getAlternatives(); + if (alts.empty()) { + log::warn("file '{}' has no alternative origins but is marked as conflicted", + item->dataRelativeFilePath()); + } + + for (auto&& alt : alts) { + auto itor = menus.find(alt.originID()); + if (itor == menus.end()) { + itor = menus.emplace(alt.originID(), mw).first; + } + + const auto fullPath = file->getFullPath(alt.originID()); + if (fullPath.empty()) { + log::error("file {} not found in origin {}", item->dataRelativeFilePath(), + alt.originID()); + + continue; + } + + if (!QFile::exists(QString::fromStdWString(fullPath))) { + log::error("{}", tr("File '%1' does not exist, you may need to refresh.") + .arg(QString::fromStdWString(fullPath))); + } + + itor->second.addFile(QFileInfo(QString::fromStdWString(fullPath))); + } + } + } + + if (menus.empty()) { + // don't warn if something that doesn't have a shell menu was selected, a + // warning has already been logged above + if (warnOnEmpty) { + log::warn("no menus to show"); + } + + return false; + } else if (menus.size() == 1) { + auto& menu = menus.begin()->second; + menu.exec(m_tree->viewport()->mapToGlobal(pos)); + } else { + env::ShellMenuCollection mc(mw); + bool hasDiscrepancies = false; + + for (auto&& m : menus) { + const auto* origin = m_core.directoryStructure()->findOriginByID(m.first); + if (!origin) { + log::error("origin {} not found for merged menus", m.first); + continue; + } + + QString caption = QString::fromStdWString(origin->getName()); + if (m.second.fileCount() < totalFiles) { + const auto d = m.second.fileCount(); + caption += " " + tr("(only has %1 file(s))").arg(d); + hasDiscrepancies = true; + } + + mc.add(caption, std::move(m.second)); + } + + if (hasDiscrepancies) { + mc.addDetails(tr("%1 file(s) selected").arg(totalFiles)); + } + + mc.exec(m_tree->viewport()->mapToGlobal(pos)); + } + + return true; +} + +void FileTree::addDirectoryMenus(QMenu&, FileTreeItem&) +{ + // noop +} + +void FileTree::addFileMenus(QMenu& menu, const FileEntry& file, int originID) +{ + using namespace spawn; + + addOpenMenus(menu, file); + + menu.addSeparator(); + menu.setToolTipsVisible(true); + + const QFileInfo target(QString::fromStdWString(file.getFullPath())); + + MenuItem(tr("&Add as Executable")) + .callback([&] { + addAsExecutable(); + }) + .hint(tr("Add this file to the executables list")) + .disabledHint(tr("This file is not executable")) + .enabled(getFileExecutionType(target) == FileExecutionTypes::Executable) + .addTo(menu); + + MenuItem(tr("Reveal in E&xplorer")) + .callback([&] { + exploreOrigin(); + }) + .hint(tr("Opens the file in Explorer")) + .disabledHint(tr("This file is in an archive")) + .enabled(!file.isFromArchive()) + .addTo(menu); + + MenuItem(tr("Open &Mod Info")) + .callback([&] { + openModInfo(); + }) + .hint(tr("Opens the Mod Info Window")) + .disabledHint(tr("This file is not in a managed mod")) + .enabled(originID != 0) + .addTo(menu); + + if (isHidden(file)) { + MenuItem(tr("&Un-Hide")) + .callback([&] { + unhide(); + }) + .hint(tr("Un-hides the file")) + .disabledHint(tr("This file is in an archive")) + .enabled(!file.isFromArchive()) + .addTo(menu); + } else { + MenuItem(tr("&Hide")) + .callback([&] { + hide(); + }) + .hint(tr("Hides the file")) + .disabledHint(tr("This file is in an archive")) + .enabled(!file.isFromArchive()) + .addTo(menu); + } +} + +void FileTree::addOpenMenus(QMenu& menu, const MOShared::FileEntry& file) +{ + using namespace spawn; + + MenuItem openMenu, openHookedMenu; + + const QFileInfo target(QString::fromStdWString(file.getFullPath())); + + if (getFileExecutionType(target) == FileExecutionTypes::Executable) { + openMenu.caption(tr("&Execute")) + .callback([&] { + open(); + }) + .hint(tr("Launches this program")) + .disabledHint(tr("This file is in an archive")) + .enabled(!file.isFromArchive()); + + openHookedMenu.caption(tr("Execute with &VFS")) + .callback([&] { + openHooked(); + }) + .hint(tr("Launches this program hooked to the VFS")) + .disabledHint(tr("This file is in an archive")) + .enabled(!file.isFromArchive()); + } else { + openMenu.caption(tr("&Open")) + .callback([&] { + open(); + }) + .hint(tr("Opens this file with its default handler")) + .disabledHint(tr("This file is in an archive")) + .enabled(!file.isFromArchive()); + + openHookedMenu.caption(tr("Open with &VFS")) + .callback([&] { + openHooked(); + }) + .hint(tr("Opens this file with its default handler hooked to the VFS")) + .disabledHint(tr("This file is in an archive")) + .enabled(!file.isFromArchive()); + } + + MenuItem previewMenu(tr("&Preview")); + previewMenu + .callback([&] { + preview(); + }) + .hint(tr("Previews this file within Mod Organizer")) + .disabledHint(tr("This file is in an archive or has no preview handler " + "associated with it")) + .enabled(canPreviewFile(m_plugins, file)); + + if (m_core.settings().interface().doubleClicksOpenPreviews()) { + previewMenu.addTo(menu); + openMenu.addTo(menu); + openHookedMenu.addTo(menu); + } else { + openMenu.addTo(menu); + previewMenu.addTo(menu); + openHookedMenu.addTo(menu); + } + + // bold the first enabled option, only first three are considered + for (int i = 0; i < 3; ++i) { + if (i >= menu.actions().size()) { + break; + } + + auto* a = menu.actions()[i]; + + if (menu.actions()[i]->isEnabled()) { + auto f = a->font(); + f.setBold(true); + a->setFont(f); + break; + } + } +} + +void FileTree::addCommonMenus(QMenu& menu) +{ + menu.addSeparator(); + + MenuItem(tr("&Save Tree to Text File...")) + .callback([&] { + dumpToFile(); + }) + .hint(tr("Writes the list of files to a text file")) + .addTo(menu); + + MenuItem(tr("&Refresh")) + .callback([&] { + refresh(); + }) + .hint(tr("Refreshes the list")) + .addTo(menu); + + MenuItem(tr("Ex&pand All")) + .callback([&] { + expandAll(); + }) + .addTo(menu); + + MenuItem(tr("&Collapse All")) + .callback([&] { + collapseAll(); + }) + .addTo(menu); +} + +QModelIndex FileTree::proxiedIndex(const QModelIndex& index) +{ + auto* model = m_tree->model(); + + if (auto* proxy = dynamic_cast(model)) { + return proxy->mapToSource(index); + } else { + return index; + } +} + +void FileTree::collapseAll() +{ + m_tree->collapseAll(); +} + +void FileTree::expandAll() +{ + m_model->aboutToExpandAll(); + m_tree->expandAll(); + m_model->expandedAll(); +} diff --git a/src/src/filetree.h b/src/src/filetree.h new file mode 100644 index 0000000..16cecda --- /dev/null +++ b/src/src/filetree.h @@ -0,0 +1,76 @@ +#ifndef MODORGANIZER_FILETREE_INCLUDED +#define MODORGANIZER_FILETREE_INCLUDED + +#include "modinfo.h" +#include "modinfodialogfwd.h" + +namespace MOShared +{ +class FileEntry; +} + +class OrganizerCore; +class PluginContainer; +class FileTreeModel; +class FileTreeItem; + +class FileTree : public QObject +{ + Q_OBJECT; + +public: + FileTree(OrganizerCore& core, PluginContainer& pc, QTreeView* tree); + + FileTreeModel* model(); + void refresh(); + void clear(); + + bool fullyLoaded() const; + void ensureFullyLoaded(); + + void expandAll(); + void collapseAll(); + + void open(FileTreeItem* item = nullptr); + void openHooked(FileTreeItem* item = nullptr); + void preview(FileTreeItem* item = nullptr); + void activate(FileTreeItem* item = nullptr); + + void addAsExecutable(FileTreeItem* item = nullptr); + void exploreOrigin(FileTreeItem* item = nullptr); + void openModInfo(FileTreeItem* item = nullptr); + + void hide(FileTreeItem* item = nullptr); + void unhide(FileTreeItem* item = nullptr); + + void dumpToFile() const; + +signals: + void executablesChanged(); + void originModified(int originID); + void displayModInformation(ModInfo::Ptr m, unsigned int i, ModInfoTabIDs tab); + +private: + OrganizerCore& m_core; + PluginContainer& m_plugins; + QTreeView* m_tree; + FileTreeModel* m_model; + + FileTreeItem* singleSelection(); + + void onExpandedChanged(const QModelIndex& index, bool expanded); + void onItemActivated(const QModelIndex& index); + void onContextMenu(const QPoint& pos); + bool showShellMenu(QPoint pos); + + void addDirectoryMenus(QMenu& menu, FileTreeItem& item); + void addFileMenus(QMenu& menu, const MOShared::FileEntry& file, int originID); + void addOpenMenus(QMenu& menu, const MOShared::FileEntry& file); + void addCommonMenus(QMenu& menu); + + void toggleVisibility(bool b, FileTreeItem* item = nullptr); + + QModelIndex proxiedIndex(const QModelIndex& index); +}; + +#endif // MODORGANIZER_FILETREE_INCLUDED diff --git a/src/src/filetreeitem.cpp b/src/src/filetreeitem.cpp new file mode 100644 index 0000000..bca020c --- /dev/null +++ b/src/src/filetreeitem.cpp @@ -0,0 +1,456 @@ +#include "filetreeitem.h" +#include "filetreemodel.h" +#include "modinfo.h" +#include "modinfodialogfwd.h" +#include "shared/util.h" +#include +#include + +#ifndef _WIN32 +#include +#include +#endif + +using namespace MOBase; +using namespace MOShared; +namespace fs = std::filesystem; + +constexpr bool AlwaysSortDirectoriesFirst = true; + +const QString& directoryFileType() +{ + static const QString name = [] { +#ifdef _WIN32 + const DWORD flags = SHGFI_TYPENAME; + SHFILEINFOW sfi = {}; + + // "." for the current directory, which should always exist + const auto r = SHGetFileInfoW(L".", 0, &sfi, sizeof(sfi), flags); + + if (!r) { + const auto e = GetLastError(); + + log::error("SHGetFileInfoW failed for folder file type, {}", + formatSystemMessage(e)); + + return QString("File folder"); + } else { + return QString::fromWCharArray(sfi.szTypeName); + } +#else + return QString("File folder"); +#endif + }(); + + return name; +} + +const QString& cachedFileTypeNoExtension() +{ + static const QString name = [] { +#ifdef _WIN32 + const DWORD flags = SHGFI_TYPENAME | SHGFI_USEFILEATTRIBUTES; + SHFILEINFOW sfi = {}; + + // dummy filename with no extension + const auto r = SHGetFileInfoW(L"file", 0, &sfi, sizeof(sfi), flags); + + if (!r) { + const auto e = GetLastError(); + + log::error("SHGetFileInfoW failed for file without extension, {}", + formatSystemMessage(e)); + + return QString("File"); + } else { + return QString::fromWCharArray(sfi.szTypeName); + } +#else + return QString("File"); +#endif + }(); + + return name; +} + +const QString& cachedFileType(const std::wstring& file, bool isOnFilesystem) +{ + static std::map> map; + static std::mutex mutex; + + const auto dot = file.find_last_of(L'.'); + if (dot == std::wstring::npos) { + return cachedFileTypeNoExtension(); + } + + std::scoped_lock lock(mutex); + const auto sv = std::wstring_view(file.c_str() + dot, file.size() - dot); + + auto itor = map.find(sv); + if (itor != map.end()) { + return itor->second; + } + +#ifdef _WIN32 + DWORD flags = SHGFI_TYPENAME; + + if (!isOnFilesystem) { + // files from archives are not on the filesystem; this flag forces + // SHGetFileInfoW() to only work with the filename + flags |= SHGFI_USEFILEATTRIBUTES; + } + + SHFILEINFOW sfi = {}; + const auto r = SHGetFileInfoW(file.c_str(), 0, &sfi, sizeof(sfi), flags); + + QString s; + + if (!r) { + const auto e = GetLastError(); + + log::error("SHGetFileInfoW failed for '{}', {}", file, formatSystemMessage(e)); + + s = cachedFileTypeNoExtension(); + } else { + s = QString::fromWCharArray(sfi.szTypeName); + } +#else + // On Linux, use QMimeDatabase to get the file type description + static QMimeDatabase mimeDb; + QString filename = QString::fromStdWString(file); + QMimeType mimeType = mimeDb.mimeTypeForFile(filename, QMimeDatabase::MatchExtension); + QString s = mimeType.comment(); + if (s.isEmpty()) { + s = cachedFileTypeNoExtension(); + } +#endif + + return map.emplace(sv, s).first->second; +} + +FileTreeItem::FileTreeItem(FileTreeModel* model, FileTreeItem* parent, + std::wstring dataRelativeParentPath, bool isDirectory, + std::wstring file) + : m_model(model), m_parent(parent), m_indexGuess(NoIndexGuess), + m_virtualParentPath(QString::fromStdWString(dataRelativeParentPath)), + m_wsFile(file), m_wsLcFile(ToLowerCopy(file)), m_key(m_wsLcFile), + m_file(QString::fromStdWString(file)), m_isDirectory(isDirectory), m_originID(-1), + m_flags(NoFlags), m_loaded(false), m_expanded(false), m_sortingStale(true) +{} + +FileTreeItem::Ptr FileTreeItem::createFile(FileTreeModel* model, FileTreeItem* parent, + std::wstring dataRelativeParentPath, + std::wstring file) +{ + return std::unique_ptr(new FileTreeItem( + model, parent, std::move(dataRelativeParentPath), false, std::move(file))); +} + +FileTreeItem::Ptr FileTreeItem::createDirectory(FileTreeModel* model, + FileTreeItem* parent, + std::wstring dataRelativeParentPath, + std::wstring file) +{ + return std::unique_ptr(new FileTreeItem( + model, parent, std::move(dataRelativeParentPath), true, std::move(file))); +} + +void FileTreeItem::setOrigin(int originID, const std::wstring& realPath, Flags flags, + const std::wstring& mod) +{ + m_originID = originID; + m_wsRealPath = realPath; + m_realPath = QString::fromStdWString(realPath); + m_flags = flags; + m_mod = QString::fromStdWString(mod); + + m_fileSize.reset(); + m_lastModified.reset(); + m_fileType.reset(); + m_compressedFileSize.reset(); +} + +void FileTreeItem::insert(FileTreeItem::Ptr child, std::size_t at) +{ + if (at > m_children.size()) { + log::error("{}: can't insert child {} at {}, out of range", debugName(), + child->debugName(), at); + + return; + } + + child->m_indexGuess = at; + m_children.insert(m_children.begin() + at, std::move(child)); +} + +void FileTreeItem::remove(std::size_t i) +{ + if (i >= m_children.size()) { + log::error("{}: can't remove child at {}", debugName(), i); + return; + } + + m_children.erase(m_children.begin() + i); +} + +void FileTreeItem::remove(std::size_t from, std::size_t n) +{ + if ((from + n) > m_children.size()) { + log::error("{}: can't remove children from {} n={}", debugName(), from, n); + return; + } + + auto begin = m_children.begin() + from; + auto end = begin + n; + + m_children.erase(begin, end); +} + +template +int threeWayCompare(T&& a, T&& b) +{ + if (a < b) { + return -1; + } + + if (a > b) { + return 1; + } + + return 0; +} + +class FileTreeItem::Sorter +{ +public: + static int compare(int column, const FileTreeItem* a, const FileTreeItem* b) + { + switch (column) { + case FileTreeModel::FileName: + return naturalCompare(a->m_file, b->m_file); + + case FileTreeModel::ModName: + return naturalCompare(a->m_mod, b->m_mod); + + case FileTreeModel::FileType: + return naturalCompare(a->fileType().value_or(QString()), + b->fileType().value_or(QString())); + + case FileTreeModel::FileSize: + return threeWayCompare(a->fileSize().value_or(0), b->fileSize().value_or(0)); + + case FileTreeModel::LastModified: + return threeWayCompare(a->lastModified().value_or(QDateTime()), + b->lastModified().value_or(QDateTime())); + + default: + return 0; + } + } +}; + +void FileTreeItem::queueSort() +{ + if (!m_children.empty()) { + m_model->queueSortItem(this); + } +} + +void FileTreeItem::makeSortingStale() +{ + m_sortingStale = true; + + for (auto& c : m_children) { + c->makeSortingStale(); + } +} + +void FileTreeItem::sort(int column, Qt::SortOrder order, bool force) +{ + if (!m_expanded) { + m_sortingStale = true; + return; + } + + if (m_sortingStale || force) { + // log::debug("sorting is stale for {}, sorting now", debugName()); + m_sortingStale = false; + + std::sort(m_children.begin(), m_children.end(), [&](auto&& a, auto&& b) { + int r = 0; + + if (a->isDirectory() && !b->isDirectory()) { + if constexpr (AlwaysSortDirectoriesFirst) { + return true; + } else { + r = -1; + } + } else if (!a->isDirectory() && b->isDirectory()) { + if constexpr (AlwaysSortDirectoriesFirst) { + return false; + } else { + r = 1; + } + } else { + r = FileTreeItem::Sorter::compare(column, a.get(), b.get()); + } + + if (order == Qt::AscendingOrder) { + return (r < 0); + } else { + return (r > 0); + } + }); + } + + for (auto& child : m_children) { + child->sort(column, order, force); + } +} + +QString FileTreeItem::virtualPath() const +{ + QString s = "Data/"; + + if (!m_virtualParentPath.isEmpty()) { + s += m_virtualParentPath + "/"; + } + + s += m_file; + + return s; +} + +QString FileTreeItem::dataRelativeFilePath() const +{ + auto path = dataRelativeParentPath(); + if (!path.isEmpty()) { + path += "\\"; + } + + return path += m_file; +} + +QFont FileTreeItem::font() const +{ + QFont f; + + if (isFromArchive()) { + f.setItalic(true); + } else if (isHidden()) { + f.setStrikeOut(true); + } + + return f; +} + +std::optional FileTreeItem::fileSize() const +{ + if (m_fileSize.empty() && !m_isDirectory) { + std::error_code ec; + const auto size = fs::file_size(fs::path(m_wsRealPath), ec); + + if (ec) { + log::error("can't get file size for '{}', {}", m_realPath, ec.message()); + m_fileSize.fail(); + } else { + m_fileSize.set(size); + } + } + + return m_fileSize.value; +} + +std::optional FileTreeItem::lastModified() const +{ + if (m_lastModified.empty()) { + if (m_realPath.isEmpty()) { + // this is a virtual directory + m_lastModified.set({}); + } else if (isFromArchive()) { + // can't get last modified date for files in archives + m_lastModified.set({}); + } else { + // looks like a regular file on the filesystem + const QFileInfo fi(m_realPath); + const auto d = fi.lastModified(); + + if (!d.isValid()) { + log::error("can't get last modified date for '{}'", m_realPath); + m_lastModified.fail(); + } else { + m_lastModified.set(d); + } + } + } + + return m_lastModified.value; +} + +std::optional FileTreeItem::fileType() const +{ + if (m_fileType.empty()) { + getFileType(); + } + + return m_fileType.value; +} + +void FileTreeItem::getFileType() const +{ + if (isDirectory()) { + m_fileType.set(directoryFileType()); + return; + } + + const auto& t = cachedFileType(m_wsRealPath, !isFromArchive()); + if (t.isEmpty()) { + m_fileType.fail(); + } else { + m_fileType.set(t); + } +} + +QFileIconProvider::IconType FileTreeItem::icon() const +{ + if (m_isDirectory) { + return QFileIconProvider::Folder; + } else { + return QFileIconProvider::File; + } +} + +bool FileTreeItem::isHidden() const +{ + return m_file.endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive); +} + +void FileTreeItem::unload() +{ + if (!m_loaded) { + return; + } + + clear(); +} + +bool FileTreeItem::areChildrenVisible() const +{ + if (m_expanded) { + if (m_parent) { + return m_parent->areChildrenVisible(); + } else { + return true; + } + } + + return false; +} + +QString FileTreeItem::debugName() const +{ + return QString("%1(ld=%2,cs=%3)") + .arg(virtualPath()) + .arg(m_loaded) + .arg(m_children.size()); +} diff --git a/src/src/filetreeitem.h b/src/src/filetreeitem.h new file mode 100644 index 0000000..2d6d8ab --- /dev/null +++ b/src/src/filetreeitem.h @@ -0,0 +1,257 @@ +#ifndef MODORGANIZER_FILETREEITEM_INCLUDED +#define MODORGANIZER_FILETREEITEM_INCLUDED + +#include "shared/fileregisterfwd.h" +#include + +class FileTreeModel; + +class FileTreeItem +{ + class Sorter; + +public: + using Ptr = std::unique_ptr; + using Children = std::vector; + + enum Flag + { + NoFlags = 0x00, + FromArchive = 0x01, + Conflicted = 0x02 + }; + + Q_DECLARE_FLAGS(Flags, Flag); + + static Ptr createFile(FileTreeModel* model, FileTreeItem* parent, + std::wstring dataRelativeParentPath, std::wstring file); + + static Ptr createDirectory(FileTreeModel* model, FileTreeItem* parent, + std::wstring dataRelativeParentPath, std::wstring file); + + FileTreeItem(const FileTreeItem&) = delete; + FileTreeItem& operator=(const FileTreeItem&) = delete; + FileTreeItem(FileTreeItem&&) = default; + FileTreeItem& operator=(FileTreeItem&&) = default; + + void setOrigin(int originID, const std::wstring& realPath, Flags flags, + const std::wstring& mod); + + void add(Ptr child) + { + child->m_indexGuess = m_children.size(); + m_children.push_back(std::move(child)); + } + + void insert(Ptr child, std::size_t at); + + template + void insert(Itor begin, Itor end, std::size_t at) + { + std::size_t nextRowGuess = m_children.size(); + for (auto itor = begin; itor != end; ++itor) { + (*itor)->m_indexGuess = nextRowGuess++; + } + + m_children.insert(m_children.begin() + at, begin, end); + } + + void remove(std::size_t i); + void remove(std::size_t from, std::size_t n); + + void clear() + { + m_children.clear(); + m_loaded = false; + } + + const Children& children() const { return m_children; } + + int childIndex(const FileTreeItem& item) const + { + if (item.m_indexGuess < m_children.size()) { + if (m_children[item.m_indexGuess].get() == &item) { + return static_cast(item.m_indexGuess); + } + } + + for (std::size_t i = 0; i < m_children.size(); ++i) { + if (m_children[i].get() == &item) { + item.m_indexGuess = i; + return static_cast(i); + } + } + + return -1; + } + + void sort(int column, Qt::SortOrder order, bool force); + void makeSortingStale(); + + FileTreeItem* parent() { return m_parent; } + + int originID() const { return m_originID; } + + const QString& virtualParentPath() const { return m_virtualParentPath; } + + QString virtualPath() const; + + const QString& filename() const { return m_file; } + + const std::wstring& filenameWs() const { return m_wsFile; } + + const std::wstring& filenameWsLowerCase() const { return m_wsLcFile; } + + const MOShared::DirectoryEntryFileKey& key() const { return m_key; } + + const QString& mod() const { return m_mod; } + + QFont font() const; + + std::optional fileSize() const; + std::optional lastModified() const; + std::optional fileType() const; + + std::optional compressedFileSize() const + { + return m_compressedFileSize.value; + } + + void setFileSize(uint64_t size) { m_fileSize.override(size); } + + void setCompressedFileSize(uint64_t compressedSize) + { + m_compressedFileSize.override(compressedSize); + } + + const QString& realPath() const { return m_realPath; } + + const QString& dataRelativeParentPath() const { return m_virtualParentPath; } + + QString dataRelativeFilePath() const; + + QFileIconProvider::IconType icon() const; + + bool isDirectory() const { return m_isDirectory; } + + bool isFromArchive() const { return (m_flags & FromArchive); } + + bool isConflicted() const { return (m_flags & Conflicted); } + + bool isHidden() const; + + bool hasChildren() const + { + if (!isDirectory()) { + return false; + } + + if (isLoaded() && m_children.empty()) { + return false; + } + + return true; + } + + void setLoaded(bool b) { m_loaded = b; } + + bool isLoaded() const { return m_loaded; } + + void unload(); + + void setExpanded(bool b) + { + if (m_expanded == b) { + return; + } + + m_expanded = b; + + if (m_expanded && m_sortingStale) { + queueSort(); + } + } + + bool isStrictlyExpanded() const { return m_expanded; } + + bool areChildrenVisible() const; + + QString debugName() const; + +private: + template + struct Cached + { + std::optional value; + bool failed = false; + bool overridden = false; + + bool empty() const { return !failed && !value; } + + void set(T t) + { + value = std::move(t); + failed = false; + overridden = false; + } + + void override(T t) + { + value = std::move(t); + failed = false; + overridden = true; + } + + void fail() + { + value = {}; + failed = true; + overridden = false; + } + + void reset() + { + if (!overridden) { + value = {}; + failed = false; + } + } + }; + + static constexpr std::size_t NoIndexGuess = std::numeric_limits::max(); + + FileTreeModel* m_model; + FileTreeItem* m_parent; + mutable std::size_t m_indexGuess; + + const QString m_virtualParentPath; + const std::wstring m_wsFile, m_wsLcFile; + const MOShared::DirectoryEntryFileKey m_key; + const QString m_file; + const bool m_isDirectory; + + int m_originID; + QString m_realPath; + std::wstring m_wsRealPath; + Flags m_flags; + QString m_mod; + + mutable Cached m_fileSize; + mutable Cached m_lastModified; + mutable Cached m_fileType; + mutable Cached m_compressedFileSize; + + bool m_loaded; + bool m_expanded; + bool m_sortingStale; + Children m_children; + + FileTreeItem(FileTreeModel* model, FileTreeItem* parent, + std::wstring dataRelativeParentPath, bool isDirectory, + std::wstring file); + + void getFileType() const; + void queueSort(); +}; + +#endif // MODORGANIZER_FILETREEITEM_INCLUDED diff --git a/src/src/filetreemodel.cpp b/src/src/filetreemodel.cpp new file mode 100644 index 0000000..e7d5283 --- /dev/null +++ b/src/src/filetreemodel.cpp @@ -0,0 +1,1239 @@ +#include "filetreemodel.h" +#include "organizercore.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "shared/util.h" +#include +#include + +using namespace MOBase; +using namespace MOShared; +namespace fs = std::filesystem; + +// in mainwindow.cpp +QString UnmanagedModName(); + +#define trace(f) + +// about queueRemoveItem(), queueSortItems() and the `forFetching` parameter +// +// update() can be called when refreshing the tree or expanding a node; +// there are certain operations that cannot be done during node expansion, +// namely removing or moving items +// +// 1) removing items +// beginRemoveRows()/endRemoveRows() clears the internal list of visible +// items in a QTreeView, which is repopulated during the next layout +// +// doing this _during_ layout (which happens during node expansion) crashes +// in Qt because it assumes the list of visible items doesn't change; that +// is, fetchMore() cannot _remove_ items from the tree, it can only _add_ +// items +// +// 2) moving items +// when a QSortFilterProxyModel is used between a tree and the FileTreeModel, +// it maintains a mapping of indices between the source and proxy models; +// calling layoutAboutToBeChanged()/layoutChanged() clears that mapping +// +// the only time this is called in FileTreeModel is when sorting items, +// which can happen during layout because since it calls fetchMore() +// +// doing this _during_ layout (which happens during node expansion) crashes +// in Qt because the QSortFilterProxyModel assumes the mapping doesn't +// change; that is, fetchMore() cannot _move_ items in the tree, it can +// only _add_ items +// +// +// therefore, these two operations are queued in a 1ms timer which is processed +// immediately after update() +// +// note that not all instances of removing items are currently getting queued +// (such as when removeDisappearingFiles()) because they cannot happen while +// fetching + +// tracks a contiguous range in the model to avoid calling begin*Rows(), etc. +// for every single item that's added/removed +// +class FileTreeModel::Range +{ +public: + // note that file ranges can start from an index higher than 0 if there are + // directories + // + Range(FileTreeModel* model, FileTreeItem& parentItem, int start = 0) + : m_model(model), m_parentItem(parentItem), m_first(-1), m_current(start) + {} + + // includes the current index in the range + // + void includeCurrent() + { + // just remember the start of the range, m_current will be used in add() + // or remove() to figure out the actual range + if (m_first == -1) { + m_first = m_current; + } + } + + // moves to the next row + // + void next() { ++m_current; } + + // returns the current row + // + int current() const { return m_current; } + + // manually set this range + // + void set(int first, int last) + { + m_first = first; + m_current = last; + } + + // adds the given items to this range + // + void add(FileTreeItem::Children toAdd) + { + if (m_first == -1) { + // nothing to add + MO_ASSERT(toAdd.empty()); + return; + } + + const auto last = m_current - 1; + const auto parentIndex = m_model->indexFromItem(m_parentItem); + + // make sure the number of items is the same as the size of this range + MO_ASSERT(static_cast(toAdd.size()) == (last - m_first + 1)); + + trace(log::debug("Range::add() {} to {}", m_first, last)); + + m_model->beginInsertRows(parentIndex, m_first, last); + + m_parentItem.insert(std::make_move_iterator(toAdd.begin()), + std::make_move_iterator(toAdd.end()), + static_cast(m_first)); + + m_model->endInsertRows(); + + // reset + m_first = -1; + } + + // removes the item in this range, returns an iterator to first item passed + // this range once removed, which can be end() + // + FileTreeItem::Children::const_iterator remove() + { + if (m_first >= 0) { + const auto last = m_current - 1; + const auto parentIndex = m_model->indexFromItem(m_parentItem); + + trace(log::debug("Range::remove() {} to {}", m_first, last)); + + m_model->beginRemoveRows(parentIndex, m_first, last); + + m_parentItem.remove(static_cast(m_first), + static_cast(last - m_first + 1)); + + m_model->endRemoveRows(); + + m_model->removePendingIcons(parentIndex, m_first, last); + + // adjust current row to account for those that were just removed + m_current -= (m_current - m_first); + + // reset + m_first = -1; + } + + if (m_current >= m_parentItem.children().size()) { + return m_parentItem.children().end(); + } + + return m_parentItem.children().begin() + m_current + 1; + } + + static void removeChildren(FileTreeModel* model, FileTreeItem& parentItem) + { + Range r(model, parentItem); + r.set(0, static_cast(parentItem.children().size())); + r.remove(); + parentItem.clear(); + } + +private: + FileTreeModel* m_model; + FileTreeItem& m_parentItem; + int m_first; + int m_current; +}; + +FileTreeItem* getItem(const QModelIndex& index) +{ + return static_cast(index.internalPointer()); +} + +void* makeInternalPointer(FileTreeItem* item) +{ + return item; +} + +FileTreeModel::FileTreeModel(OrganizerCore& core, QObject* parent) + : QAbstractItemModel(parent), m_core(core), m_enabled(true), + m_root(FileTreeItem::createDirectory(this, nullptr, L"", L"")), + m_flags(HiddenFiles), m_fullyLoaded(false), m_sortingEnabled(true) +{ + m_root->setExpanded(true); + m_sortTimer.setSingleShot(true); + + connect(&m_removeTimer, &QTimer::timeout, [&] { + removeItems(); + }); + connect(&m_sortTimer, &QTimer::timeout, [&] { + sortItems(); + }); + connect(&m_iconPendingTimer, &QTimer::timeout, [&] { + updatePendingIcons(); + }); +} + +void FileTreeModel::refresh() +{ + TimeThis tt("FileTreeModel::refresh()"); + + m_fullyLoaded = false; + update(*m_root, *m_core.directoryStructure(), L"", false); + sortItem(*m_root, false); +} + +void FileTreeModel::clear() +{ + m_fullyLoaded = false; + + beginResetModel(); + m_root->clear(); + endResetModel(); +} + +void FileTreeModel::recursiveFetchMore(const QModelIndex& m) +{ + if (canFetchMore(m)) { + doFetchMore(m, false, false); + } + + for (int i = 0; i < rowCount(m); ++i) { + recursiveFetchMore(index(i, 0, m)); + } +} + +void FileTreeModel::ensureFullyLoaded() +{ + if (!m_fullyLoaded) { + TimeThis tt("FileTreeModel:: fully loading for search"); + recursiveFetchMore(QModelIndex()); + sortItem(*m_root, false); + m_fullyLoaded = true; + } +} + +bool FileTreeModel::enabled() const +{ + return m_enabled; +} + +void FileTreeModel::setEnabled(bool b) +{ + m_enabled = b; +} + +void FileTreeModel::aboutToExpandAll() +{ + m_sortingEnabled = false; +} + +void FileTreeModel::expandedAll() +{ + m_sortingEnabled = true; + sortItem(*m_root, false); +} + +const FileTreeModel::SortInfo& FileTreeModel::sortInfo() const +{ + return m_sort; +} + +bool FileTreeModel::showArchives() const +{ + return (m_flags.testFlag(Archives) && m_core.settings().archiveParsing()); +} + +bool FileTreeModel::showHiddenFiles() const +{ + return m_flags.testAnyFlag(HiddenFiles); +} + +QModelIndex FileTreeModel::index(int row, int col, const QModelIndex& parentIndex) const +{ + if (auto* parentItem = itemFromIndex(parentIndex)) { + if (row < 0 || row >= parentItem->children().size()) { + log::error("row {} out of range for {}", row, parentItem->debugName()); + return {}; + } + + return createIndex(row, col, makeInternalPointer(parentItem)); + } + + log::error("FileTreeModel::index(): parentIndex has no internal pointer"); + return {}; +} + +QModelIndex FileTreeModel::parent(const QModelIndex& index) const +{ + if (!index.isValid()) { + return {}; + } + + auto* parentItem = getItem(index); + if (!parentItem) { + log::error("FileTreeModel::parent(): no internal pointer"); + return {}; + } + + return indexFromItem(*parentItem); +} + +int FileTreeModel::rowCount(const QModelIndex& parent) const +{ + if (auto* item = itemFromIndex(parent)) { + return static_cast(item->children().size()); + } + + return 0; +} + +int FileTreeModel::columnCount(const QModelIndex&) const +{ + return ColumnCount; +} + +bool FileTreeModel::hasChildren(const QModelIndex& parent) const +{ + if (!m_enabled) { + return false; + } + + if (auto* item = itemFromIndex(parent)) { + if (parent.column() <= 0) { + return item->hasChildren(); + } + } + + return false; +} + +bool FileTreeModel::canFetchMore(const QModelIndex& parent) const +{ + if (!m_enabled) { + return false; + } + + if (auto* item = itemFromIndex(parent)) { + return !item->isLoaded(); + } + + return false; +} + +void FileTreeModel::fetchMore(const QModelIndex& parent) +{ + doFetchMore(parent, true, true); +} + +void FileTreeModel::doFetchMore(const QModelIndex& parent, bool forFetch, bool doSort) +{ + FileTreeItem* item = itemFromIndex(parent); + if (!item) { + return; + } + + const auto path = item->dataRelativeFilePath(); + + auto* parentEntry = + m_core.directoryStructure()->findSubDirectoryRecursive(path.toStdWString()); + + if (!parentEntry) { + log::error("FileTreeModel::fetchMore(): directory '{}' not found", path); + return; + } + + const auto parentPath = item->dataRelativeParentPath(); + update(*item, *parentEntry, parentPath.toStdWString(), forFetch); + + if (!forFetch && doSort) { + sortItem(*item, false); + } +} + +QVariant FileTreeModel::data(const QModelIndex& index, int role) const +{ + switch (role) { + case Qt::DisplayRole: { + if (auto* item = itemFromIndex(index)) { + return displayData(item, index.column()); + } + + break; + } + + case Qt::FontRole: { + if (auto* item = itemFromIndex(index)) { + return item->font(); + } + + break; + } + + case Qt::ToolTipRole: { + if (auto* item = itemFromIndex(index)) { + return makeTooltip(*item); + } + + return {}; + } + + case Qt::ForegroundRole: { + if (index.column() == 1) { + if (auto* item = itemFromIndex(index)) { + if (item->isConflicted()) { + return QBrush(Qt::red); + } + } + } + + break; + } + + case Qt::DecorationRole: { + if (index.column() == 0) { + if (auto* item = itemFromIndex(index)) { + return makeIcon(*item, index); + } + } + + break; + } + } + + return {}; +} + +QVariant FileTreeModel::headerData(int i, Qt::Orientation ori, int role) const +{ + static const std::array names = { + tr("Name"), tr("Mod"), tr("Type"), tr("Size"), tr("Date modified")}; + + if (role == Qt::DisplayRole) { + if (i >= 0 && i < static_cast(names.size())) { + return names[static_cast(i)]; + } + } + + return {}; +} + +Qt::ItemFlags FileTreeModel::flags(const QModelIndex& index) const +{ + auto f = QAbstractItemModel::flags(index); + + if (auto* item = itemFromIndex(index)) { + if (!item->hasChildren()) { + f |= Qt::ItemNeverHasChildren; + } + } + + return f; +} + +void FileTreeModel::sortItem(FileTreeItem& item, bool force) +{ + emit layoutAboutToBeChanged({}, QAbstractItemModel::VerticalSortHint); + + const auto oldList = persistentIndexList(); + std::vector> oldItems; + + const auto itemCount = oldList.size(); + oldItems.reserve(static_cast(itemCount)); + + for (int i = 0; i < itemCount; ++i) { + const QModelIndex& index = oldList[i]; + oldItems.push_back({itemFromIndex(index), index.column()}); + } + + item.sort(m_sort.column, m_sort.order, force); + + QModelIndexList newList; + newList.reserve(itemCount); + + for (int i = 0; i < itemCount; ++i) { + const auto& pair = oldItems[static_cast(i)]; + newList.append(indexFromItem(*pair.first, pair.second)); + } + + changePersistentIndexList(oldList, newList); + + emit layoutChanged({}, QAbstractItemModel::VerticalSortHint); +} + +void FileTreeModel::sort(int column, Qt::SortOrder order) +{ + m_sort.column = column; + m_sort.order = order; + + sortItem(*m_root, true); +} + +FileTreeItem* FileTreeModel::itemFromIndex(const QModelIndex& index) const +{ + if (!index.isValid()) { + return m_root.get(); + } + + auto* parentItem = getItem(index); + if (!parentItem) { + log::error("FileTreeModel::itemFromIndex(): no internal pointer"); + return nullptr; + } + + if (index.row() < 0 || index.row() >= parentItem->children().size()) { + log::error("FileTreeModel::itemFromIndex(): row {} is out of range for {}", + index.row(), parentItem->debugName()); + + return nullptr; + } + + return parentItem->children()[index.row()].get(); +} + +QModelIndex FileTreeModel::indexFromItem(FileTreeItem& item, int col) const +{ + auto* parent = item.parent(); + if (!parent) { + return {}; + } + + const int index = parent->childIndex(item); + if (index == -1) { + log::error("FileTreeMode::indexFromItem(): item {} not found in parent", + item.debugName()); + + return {}; + } + + return createIndex(index, col, makeInternalPointer(parent)); +} + +void FileTreeModel::update(FileTreeItem& parentItem, + const MOShared::DirectoryEntry& parentEntry, + const std::wstring& parentPath, bool forFetching) +{ + trace(log::debug("updating {}", parentItem.debugName())); + + auto path = parentPath; + if (!parentEntry.isTopLevel()) { + if (!path.empty()) { + path += MOShared::NativeWPathSep; + } + + path += parentEntry.getName(); + } + + parentItem.setLoaded(true); + + bool added = false; + + if (updateDirectories(parentItem, path, parentEntry, forFetching)) { + added = true; + } + + if (updateFiles(parentItem, path, parentEntry)) { + added = true; + } + + if (added) { + parentItem.makeSortingStale(); + + // see comment at the top of this file + if (forFetching) { + // don't pass a specific item, this will start a timer and re-sort the + // whole tree, which is faster than potentially queuing every single + // node if the whole tree is expanded + queueSortItem(nullptr); + } + } +} + +bool FileTreeModel::updateDirectories(FileTreeItem& parentItem, + const std::wstring& parentPath, + const MOShared::DirectoryEntry& parentEntry, + bool forFetching) +{ + // removeDisappearingDirectories() will add directories that are in the + // tree and still on the filesystem to this set; addNewDirectories() will + // use this to figure out if a directory is new or not + std::unordered_set seen; + + removeDisappearingDirectories(parentItem, parentEntry, parentPath, seen, forFetching); + return addNewDirectories(parentItem, parentEntry, parentPath, seen); +} + +void FileTreeModel::removeDisappearingDirectories( + FileTreeItem& parentItem, const MOShared::DirectoryEntry& parentEntry, + const std::wstring& parentPath, std::unordered_set& seen, + bool forFetching) +{ + auto& children = parentItem.children(); + auto itor = children.begin(); + + // keeps track of the contiguous directories that need to be removed to + // avoid calling beginRemoveRows(), etc. for each item + Range range(this, parentItem); + + // for each item in this tree item + while (itor != children.end()) { + const auto& item = *itor; + + if (!item->isDirectory()) { + // directories are always first, no point continuing once a file has + // been seen + break; + } + + auto d = parentEntry.findSubDirectory(item->filenameWsLowerCase(), true); + + if (d) { + trace(log::debug("dir {} still there", item->filename())); + + // directory is still there + seen.emplace(d->getName()); + + bool currentRemoved = false; + + if (item->areChildrenVisible()) { + // the item is currently expanded, update it + update(*item, *d, parentPath, forFetching); + } + + if (shouldShowFolder(*d, item.get())) { + // folder should be left in the list + if (!item->areChildrenVisible() && item->isLoaded()) { + if (!d->isEmpty()) { + // the item is loaded (previously expanded but now collapsed) and + // has children, mark it as unloaded so it updates when next + // expanded + item->setLoaded(false); + + if (!item->children().empty()) { + // if the item had children, remove them from the tree + + // see comment at the top of this file + if (forFetching) { + queueRemoveItem(item.get()); + } else { + Range::removeChildren(this, *item); + } + } + } + } + } else { + // item wouldn't have any children, prune it + trace(log::debug("dir {} is empty and pruned", item->filename())); + + range.includeCurrent(); + currentRemoved = true; + ++itor; + } + + if (!currentRemoved) { + // if there were directories before this row that need to be removed, + // do it now + itor = range.remove(); + } + } else { + // directory is gone from the parent entry + trace(log::debug("dir {} is gone", item->filename())); + + range.includeCurrent(); + ++itor; + } + + range.next(); + } + + // remove the last directory range, if any + range.remove(); +} + +bool FileTreeModel::addNewDirectories(FileTreeItem& parentItem, + const MOShared::DirectoryEntry& parentEntry, + const std::wstring& parentPath, + const std::unordered_set& seen) +{ + // keeps track of the contiguous directories that need to be added to + // avoid calling beginAddRows(), etc. for each item + Range range(this, parentItem); + std::vector toAdd; + bool added = false; + + // for each directory on the filesystem + for (auto&& d : parentEntry.getSubDirectories()) { + if (seen.contains(d->getName())) { + // already seen in the parent item + + // if there were directories before this row that need to be added, + // do it now + // + // todo: if the directory was actually removed in + // removeDisappearingDirectories(), the range doesn't need to be added + // now and could be extended further + range.add(std::move(toAdd)); + toAdd.clear(); + } else { + if (!shouldShowFolder(*d, nullptr)) { + // this is a new directory, but it doesn't contain anything interesting + trace(log::debug("new dir {}, empty and pruned", + QString::fromStdWString(d->getName()))); + + // act as if this directory doesn't exist at all + continue; + } + + // this is a new directory + trace(log::debug("new dir {}", QString::fromStdWString(d->getName()))); + + toAdd.push_back(createDirectoryItem(parentItem, parentPath, *d)); + added = true; + + range.includeCurrent(); + } + + range.next(); + } + + // add the last directory range, if any + range.add(std::move(toAdd)); + + return added; +} + +bool FileTreeModel::updateFiles(FileTreeItem& parentItem, + const std::wstring& parentPath, + const MOShared::DirectoryEntry& parentEntry) +{ + // removeDisappearingFiles() will add files that are in the tree and still on + // the filesystem to this set; addNewFiless() will use this to figure out if + // a file is new or not + std::unordered_set seen; + + int firstFileRow = 0; + + removeDisappearingFiles(parentItem, parentEntry, firstFileRow, seen); + return addNewFiles(parentItem, parentEntry, parentPath, firstFileRow, seen); +} + +void FileTreeModel::removeDisappearingFiles(FileTreeItem& parentItem, + const MOShared::DirectoryEntry& parentEntry, + int& firstFileRow, + std::unordered_set& seen) +{ + auto& children = parentItem.children(); + auto itor = children.begin(); + + firstFileRow = -1; + + // keeps track of the contiguous directories that need to be removed to + // avoid calling beginRemoveRows(), etc. for each item + Range range(this, parentItem); + + // for each item in this tree item + while (itor != children.end()) { + const auto& item = *itor; + + if (!item->isDirectory()) { + if (firstFileRow == -1) { + firstFileRow = range.current(); + } + + auto f = parentEntry.findFile(item->key()); + + if (f && shouldShowFile(*f)) { + trace(log::debug("file {} still there", item->filename())); + + // file is still there + seen.emplace(f->getIndex()); + + if (f->getOrigin() != item->originID()) { + // origin has changed + updateFileItem(*item, *f); + } + + // if there were files before this row that need to be removed, + // do it now + itor = range.remove(); + } else { + // file is gone from the parent entry + trace(log::debug("file {} is gone", item->filename())); + + range.includeCurrent(); + ++itor; + } + } else { + ++itor; + } + + range.next(); + } + + // remove the last file range, if any + range.remove(); + + if (firstFileRow == -1) { + firstFileRow = static_cast(children.size()); + } +} + +bool FileTreeModel::addNewFiles(FileTreeItem& parentItem, + const MOShared::DirectoryEntry& parentEntry, + const std::wstring& parentPath, const int firstFileRow, + const std::unordered_set& seen) +{ + // keeps track of the contiguous files that need to be added to + // avoid calling beginAddRows(), etc. for each item + std::vector toAdd; + Range range(this, parentItem, firstFileRow); + bool added = false; + + // for each directory on the filesystem + parentEntry.forEachFileIndex([&](auto&& fileIndex) { + if (seen.contains(fileIndex)) { + // already seen in the parent item + + // if there were directories before this row that need to be added, + // do it now + range.add(std::move(toAdd)); + toAdd.clear(); + } else { + const auto file = parentEntry.getFileByIndex(fileIndex); + + if (!file) { + log::error("FileTreeModel::addNewFiles(): file index {} in path {} not found", + fileIndex, parentPath); + + return true; + } + + if (shouldShowFile(*file)) { + // this is a new file + trace(log::debug("new file {}", QString::fromStdWString(file->getName()))); + + toAdd.push_back(createFileItem(parentItem, parentPath, *file)); + added = true; + + range.includeCurrent(); + } else { + // this is a new file, but it shouldn't be shown + trace(log::debug("new file {}, not shown", + QString::fromStdWString(file->getName()))); + return true; + } + } + + range.next(); + + return true; + }); + + // add the last file range, if any + range.add(std::move(toAdd)); + + return added; +} + +void FileTreeModel::queueRemoveItem(FileTreeItem* item) +{ + trace(log::debug("queuing {} for removal", item->debugName())); + + m_removeItems.push_back(item); + m_removeTimer.start(1); +} + +void FileTreeModel::removeItems() +{ + // see comment at the top of this file + trace(log::debug("remove item timer: removing {} items", m_removeItems.size())); + + auto copy = std::move(m_removeItems); + m_removeItems.clear(); + m_removeTimer.stop(); + + for (auto&& f : copy) { + Range::removeChildren(this, *f); + } +} + +void FileTreeModel::queueSortItem(FileTreeItem* item) +{ + if (!m_sortingEnabled) { + return; + } + + if (item) { + m_sortItems.push_back(item); + } + + m_sortTimer.start(1); +} + +void FileTreeModel::sortItems() +{ + // see comment at the top of this file + + if (m_sortItems.empty()) { + sortItem(*m_root, false); + } else { + log::debug("sort item timer: sorting {} items", m_sortItems.size()); + + auto items = std::move(m_sortItems); + m_sortItems.clear(); + + for (auto* item : items) { + sortItem(*item, false); + } + } +} + +FileTreeItem::Ptr FileTreeModel::createDirectoryItem(FileTreeItem& parentItem, + const std::wstring& parentPath, + const DirectoryEntry& d) +{ + auto item = FileTreeItem::createDirectory(this, &parentItem, parentPath, d.getName()); + + if (d.isEmpty()) { + // if this directory is empty, mark the item as loaded so the expand + // arrow doesn't show + item->setLoaded(true); + } + + return item; +} + +FileTreeItem::Ptr FileTreeModel::createFileItem(FileTreeItem& parentItem, + const std::wstring& parentPath, + const FileEntry& file) +{ + auto item = FileTreeItem::createFile(this, &parentItem, parentPath, file.getName()); + + updateFileItem(*item, file); + + item->setLoaded(true); + + return item; +} + +void FileTreeModel::updateFileItem(FileTreeItem& item, const MOShared::FileEntry& file) +{ + bool isArchive = false; + int originID = file.getOrigin(isArchive); + + FileTreeItem::Flags flags = FileTreeItem::NoFlags; + + if (isArchive) { + flags |= FileTreeItem::FromArchive; + } + + if (!file.getAlternatives().empty()) { + flags |= FileTreeItem::Conflicted; + } + + item.setOrigin(originID, file.getFullPath(), flags, makeModName(file, originID)); + + if (file.getFileSize() != FileEntry::NoFileSize) { + item.setFileSize(file.getFileSize()); + } + + if (file.getCompressedFileSize() != FileEntry::NoFileSize) { + item.setCompressedFileSize(file.getCompressedFileSize()); + } +} + +bool FileTreeModel::shouldShowFile(const FileEntry& file) const +{ + if (showConflictsOnly() && + ((file.getAlternatives().size() == 0) || + QString::fromStdWString(file.getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive))) { + // only conflicts should be shown, but this file is hidden or not conflicted + return false; + } + + if (!showArchives() && file.isFromArchive()) { + // files from archives shouldn't be shown, but this file is from an archive + return false; + } + + if (!showHiddenFiles() && QString::fromStdWString(file.getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + // hidden files shouldn't be shown, but this file is hidden + return false; + } + + return true; +} + +bool FileTreeModel::shouldShowFolder(const DirectoryEntry& dir, + const FileTreeItem* item) const +{ + if ((!showHiddenFiles() || showConflictsOnly()) && + QString::fromStdWString(dir.getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + return false; + } + + bool shouldPrune = m_flags.testFlag(PruneDirectories); + + if (m_core.settings().archiveParsing()) { + if (!m_flags.testFlag(Archives)) { + // archive parsing is enabled but the tree shouldn't show archives; this + // is a bit of a special case for folders because they have to be hidden + // regardless of the PruneDirectories flag if they only exist in archives + // + // note that this test is inaccurate: if a loose folder exists but is + // empty, and the same folder exists in an archive but is _not_ empty, + // then it's considered to exist _only_ in an archive and will be pruned + // + // if directories are ever made first-class so they can retain their + // origins, this test can be made more accurate + shouldPrune = true; + } + } + + if (!shouldPrune) { + // always show folders regardless of their content + return true; + } + + if (item) { + if (item->isLoaded() && item->children().empty()) { + // item is loaded and has no children; prune it + return false; + } + } + + bool foundFile = false; + + // check all files in this directory, return early if a file should be shown + dir.forEachFile([&](auto&& f) { + if (shouldShowFile(f)) { + foundFile = true; + + // stop + return false; + } + + // continue + return true; + }); + + if (foundFile) { + return true; + } + + // recurse into subdirectories + for (auto subdir : dir.getSubDirectories()) { + if (shouldShowFolder(*subdir, nullptr)) { + return true; + } + } + + return false; +} + +QVariant FileTreeModel::displayData(const FileTreeItem* item, int column) const +{ + switch (column) { + case FileName: { + return item->filename(); + } + + case ModName: { + return item->mod(); + } + + case FileType: { + return item->fileType().value_or(QString()); + } + + case FileSize: { + if (item->isDirectory()) { + return {}; + } else { + QString fs; + + if (auto n = item->fileSize()) { + fs = localizedByteSize(*n); + } + + if (auto n = item->compressedFileSize()) { + return QString("%1 (%2)").arg(fs).arg(localizedByteSize(*n)); + } else { + return fs; + } + } + } + + case LastModified: { + if (auto d = item->lastModified()) { + if (d.has_value() && d.value().isValid()) { + return QLocale::system().toString(d.value(), QLocale::ShortFormat); + } + } + + return {}; + } + + default: { + return {}; + } + } +} + +std::wstring FileTreeModel::makeModName(const MOShared::FileEntry& file, + int originID) const +{ + static const std::wstring Unmanaged = UnmanagedModName().toStdWString(); + + const auto& origin = m_core.directoryStructure()->getOriginByID(originID); + + if (origin.getID() == 0) { + return Unmanaged; + } + + std::wstring name = origin.getName(); + + const auto& archive = file.getArchive(); + if (!archive.name().empty()) { + name += L" (" + archive.name() + L")"; + } + + return name; +} + +QString FileTreeModel::makeTooltip(const FileTreeItem& item) const +{ + auto nowrap = [&](auto&& s) { + return "

    " + s + "

    "; + }; + + auto line = [&](auto&& caption, auto&& value) { + if (value.isEmpty()) { + return nowrap("" + caption + ":\n"); + } else { + return nowrap("" + caption + ": " + value.toHtmlEscaped()) + "\n"; + } + }; + + if (item.isDirectory()) { + return line(tr("Directory"), item.filename()) + + line(tr("Virtual path"), item.virtualPath()); + } + + static const QString ListStart = "
      "; + + static const QString ListEnd = "
    "; + + QString s = line(tr("Virtual path"), item.virtualPath()) + + line(tr("Real path"), item.realPath()) + line(tr("From"), item.mod()); + + const auto file = m_core.directoryStructure()->searchFile( + item.dataRelativeFilePath().toStdWString(), nullptr); + + if (file) { + const auto alternatives = file->getAlternatives(); + QStringList list; + + for (auto&& alt : alternatives) { + const auto& origin = m_core.directoryStructure()->getOriginByID(alt.originID()); + list.push_back(QString::fromStdWString(origin.getName())); + } + + if (list.size() == 1) { + s += line(tr("Also in"), list[0]); + } else if (list.size() >= 2) { + s += line(tr("Also in"), QString()) + ListStart; + + for (auto&& alt : list) { + s += "
  • " + alt + "
  • "; + } + + s += ListEnd; + } + } + + return s; +} + +QVariant FileTreeModel::makeIcon(const FileTreeItem& item, + const QModelIndex& index) const +{ + if (item.isDirectory()) { + return m_iconFetcher.genericDirectoryIcon(); + } + + auto v = m_iconFetcher.icon(item.realPath()); + if (!v.isNull()) { + return v; + } + + m_iconPending.push_back(index); + m_iconPendingTimer.start(std::chrono::milliseconds(1)); + + return m_iconFetcher.genericFileIcon(); +} + +void FileTreeModel::updatePendingIcons() +{ + std::vector v(std::move(m_iconPending)); + m_iconPending.clear(); + + for (auto&& index : v) { + emit dataChanged(index, index, {Qt::DecorationRole}); + } + + if (m_iconPending.empty()) { + m_iconPendingTimer.stop(); + } +} + +void FileTreeModel::removePendingIcons(const QModelIndex& parent, int first, int last) +{ + auto itor = m_iconPending.begin(); + + while (itor != m_iconPending.end()) { + if (itor->parent() == parent) { + if (itor->row() >= first && itor->row() <= last) { + itor = m_iconPending.erase(itor); + continue; + } + } + + ++itor; + } +} diff --git a/src/src/filetreemodel.h b/src/src/filetreemodel.h new file mode 100644 index 0000000..35b63f9 --- /dev/null +++ b/src/src/filetreemodel.h @@ -0,0 +1,178 @@ +#ifndef MODORGANIZER_FILETREEMODEL_INCLUDED +#define MODORGANIZER_FILETREEMODEL_INCLUDED + +#include "filetreeitem.h" +#include "iconfetcher.h" +#include "shared/fileregisterfwd.h" +#include + +class OrganizerCore; + +class FileTreeModel : public QAbstractItemModel +{ + Q_OBJECT; + +public: + enum Flag + { + NoFlags = 0x00, + ConflictsOnly = 0x01, + Archives = 0x02, + PruneDirectories = 0x04, + HiddenFiles = 0x08 + }; + + enum Columns + { + FileName = 0, + ModName, + FileType, + FileSize, + LastModified, + + ColumnCount + }; + + Q_DECLARE_FLAGS(Flags, Flag); + + struct SortInfo + { + int column = 0; + Qt::SortOrder order = Qt::AscendingOrder; + }; + + FileTreeModel(OrganizerCore& core, QObject* parent = nullptr); + + void setFlags(Flags f) { m_flags = f; } + + void refresh(); + void clear(); + + bool fullyLoaded() const { return m_fullyLoaded; } + + void ensureFullyLoaded(); + + bool enabled() const; + void setEnabled(bool b); + + void aboutToExpandAll(); + void expandedAll(); + + const SortInfo& sortInfo() const; + + QModelIndex index(int row, int col, const QModelIndex& parent = {}) const override; + QModelIndex parent(const QModelIndex& index) const override; + int rowCount(const QModelIndex& parent = {}) const override; + int columnCount(const QModelIndex& parent = {}) const override; + bool hasChildren(const QModelIndex& parent = {}) const override; + bool canFetchMore(const QModelIndex& parent) const override; + void fetchMore(const QModelIndex& parent) override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QVariant headerData(int i, Qt::Orientation ori, + int role = Qt::DisplayRole) const override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + void sort(int column, Qt::SortOrder order = Qt::AscendingOrder) override; + + FileTreeItem* itemFromIndex(const QModelIndex& index) const; + void sortItem(FileTreeItem& item, bool force); + void queueSortItem(FileTreeItem* item); + +private: + class Range; + + using DirectoryIterator = std::vector::const_iterator; + + OrganizerCore& m_core; + bool m_enabled; + mutable FileTreeItem::Ptr m_root; + Flags m_flags; + mutable IconFetcher m_iconFetcher; + mutable std::vector m_iconPending; + mutable QTimer m_iconPendingTimer; + SortInfo m_sort; + bool m_fullyLoaded; + bool m_sortingEnabled; + + // see top of filetreemodel.cpp + std::vector m_removeItems; + std::vector m_sortItems; + QTimer m_removeTimer; + QTimer m_sortTimer; + + bool showConflictsOnly() const { return (m_flags & ConflictsOnly); } + + bool showArchives() const; + + bool showHiddenFiles() const; + + // for `forFetching`, see top of filetreemodel.cpp + void update(FileTreeItem& parentItem, const MOShared::DirectoryEntry& parentEntry, + const std::wstring& parentPath, bool forFetching); + + void doFetchMore(const QModelIndex& parent, bool forFetch, bool doSort); + + void queueRemoveItem(FileTreeItem* item); + void removeItems(); + + void sortItems(); + + // for `forFetching`, see top of filetreemodel.cpp + bool updateDirectories(FileTreeItem& parentItem, const std::wstring& path, + const MOShared::DirectoryEntry& parentEntry, bool forFetching); + + // for `forFetching`, see top of filetreemodel.cpp + void removeDisappearingDirectories(FileTreeItem& parentItem, + const MOShared::DirectoryEntry& parentEntry, + const std::wstring& parentPath, + std::unordered_set& seen, + bool forFetching); + + bool addNewDirectories(FileTreeItem& parentItem, + const MOShared::DirectoryEntry& parentEntry, + const std::wstring& parentPath, + const std::unordered_set& seen); + + bool updateFiles(FileTreeItem& parentItem, const std::wstring& path, + const MOShared::DirectoryEntry& parentEntry); + + void removeDisappearingFiles(FileTreeItem& parentItem, + const MOShared::DirectoryEntry& parentEntry, + int& firstFileRow, + std::unordered_set& seen); + + bool addNewFiles(FileTreeItem& parentItem, + const MOShared::DirectoryEntry& parentEntry, + const std::wstring& parentPath, int firstFileRow, + const std::unordered_set& seen); + + FileTreeItem::Ptr createDirectoryItem(FileTreeItem& parentItem, + const std::wstring& parentPath, + const MOShared::DirectoryEntry& d); + + FileTreeItem::Ptr createFileItem(FileTreeItem& parentItem, + const std::wstring& parentPath, + const MOShared::FileEntry& file); + + void updateFileItem(FileTreeItem& item, const MOShared::FileEntry& file); + + QVariant displayData(const FileTreeItem* item, int column) const; + std::wstring makeModName(const MOShared::FileEntry& file, int originID) const; + + void ensureLoaded(FileTreeItem* item) const; + void updatePendingIcons(); + void removePendingIcons(const QModelIndex& parent, int first, int last); + + bool shouldShowFile(const MOShared::FileEntry& file) const; + bool shouldShowFolder(const MOShared::DirectoryEntry& dir, + const FileTreeItem* item) const; + QString makeTooltip(const FileTreeItem& item) const; + QVariant makeIcon(const FileTreeItem& item, const QModelIndex& index) const; + + QModelIndex indexFromItem(FileTreeItem& item, int col = 0) const; + void recursiveFetchMore(const QModelIndex& m); +}; + +Q_DECLARE_OPERATORS_FOR_FLAGS(FileTreeModel::Flags); +Q_DECLARE_OPERATORS_FOR_FLAGS(FileTreeItem::Flags); + +#endif // MODORGANIZER_FILETREEMODEL_INCLUDED diff --git a/src/src/filterlist.cpp b/src/src/filterlist.cpp new file mode 100644 index 0000000..1e17e14 --- /dev/null +++ b/src/src/filterlist.cpp @@ -0,0 +1,445 @@ +#include "filterlist.h" +#include "categories.h" +#include "categoriesdialog.h" +#include "organizercore.h" +#include "plugincontainer.h" +#include "settings.h" +#include "ui_mainwindow.h" +#include + +using namespace MOBase; +using CriteriaType = ModListSortProxy::CriteriaType; +using Criteria = ModListSortProxy::Criteria; + +class FilterList::CriteriaItem : public QTreeWidgetItem +{ + + static constexpr int IDRole = Qt::UserRole; + static constexpr int TypeRole = Qt::UserRole + 1; + +public: + static constexpr int StateRole = Qt::UserRole + 2; + + enum States + { + FirstState = 0, + + Inactive = FirstState, + Active, + Inverted, + + LastState = Inverted + }; + + CriteriaItem(FilterList* list, QString name, CriteriaType type, int id) + : QTreeWidgetItem({"", name}), m_list(list), m_state(Inactive) + { + setData(0, Qt::ToolTipRole, name); + setData(0, TypeRole, type); + setData(0, IDRole, id); + setData(0, Qt::DecorationRole, QIcon(":/MO/gui/unchecked-checkbox")); + } + + CriteriaType type() const + { + return static_cast(data(0, TypeRole).toInt()); + } + + int id() const { return data(0, IDRole).toInt(); } + + States state() const { return m_state; } + + void setState(States s) + { + if (m_state != s) { + m_state = s; + updateState(); + } + } + + void nextState() + { + auto s = static_cast(m_state + 1); + if (s > LastState) { + s = FirstState; + } + setState(s); + } + + void previousState() + { + auto s = static_cast(m_state - 1); + if (s < FirstState) { + s = LastState; + } + setState(s); + } + + QVariant data(int column, int role) const + { + if (role == StateRole) { + return m_state; + } + return QTreeWidgetItem::data(column, role); + } + + void setData(int column, int role, const QVariant& value) + { + if (role == StateRole) { + setState(static_cast(value.toInt())); + } else { + QTreeWidgetItem::setData(column, role, value); + } + } + +private: + FilterList* m_list; + States m_state; + + void updateState() + { + QIcon i; + + switch (m_state) { + case Inactive: { + i = QIcon(":/MO/gui/unchecked-checkbox"); + break; + } + + case Active: { + i = QIcon(":/MO/gui/checked-checkbox"); + break; + } + + case Inverted: { + i = QIcon(":/MO/gui/indeterminate-checkbox"); + break; + } + } + setData(0, Qt::DecorationRole, i); + } +}; + +class CriteriaItemFilter : public QObject +{ +public: + using Callback = std::function; + + CriteriaItemFilter(QTreeWidget* tree, Callback f) + : QObject(tree), m_tree(tree), m_f(std::move(f)) + {} + + bool eventFilter(QObject* o, QEvent* e) override + { + // careful: this filter is installed on both the tree and the viewport + // + // no check is currently necessary because mouse events originate from the + // viewport only and keyboard events from the tree only + + if (m_f) { + if (e->type() == QEvent::MouseButtonPress || + e->type() == QEvent::MouseButtonDblClick) { + if (handleMouse(static_cast(e))) { + return true; + } + } else if (e->type() == QEvent::KeyPress) { + if (handleKeyboard(static_cast(e))) { + return true; + } + } + } + + return QObject::eventFilter(o, e); + } + +private: + QTreeWidget* m_tree; + Callback m_f; + + bool handleMouse(QMouseEvent* e) + { + auto* item = m_tree->itemAt(e->pos()); + if (!item) { + return false; + } + + m_tree->setCurrentItem(item); + + const auto dir = (e->button() == Qt::LeftButton ? 1 : -1); + + return m_f(item, dir); + } + + bool handleKeyboard(QKeyEvent* e) + { + if (e->key() == Qt::Key_Space) { + auto* item = m_tree->currentItem(); + if (!item) { + return false; + } + + const auto shiftPressed = (e->modifiers() & Qt::ShiftModifier); + const auto dir = (shiftPressed ? -1 : 1); + + return m_f(item, dir); + } + + return false; + } +}; + +FilterList::FilterList(Ui::MainWindow* ui, OrganizerCore& core, + CategoryFactory& factory) + : ui(ui), m_core(core), m_factory(factory) +{ + auto* eventFilter = new CriteriaItemFilter(ui->filters, [&](auto* item, int dir) { + return cycleItem(item, dir); + }); + + ui->filters->installEventFilter(eventFilter); + ui->filters->viewport()->installEventFilter(eventFilter); + + connect(ui->filtersClear, &QPushButton::clicked, [&] { + clearSelection(); + }); + connect(ui->filtersEdit, &QPushButton::clicked, [&] { + editCategories(); + }); + connect(ui->filtersAnd, &QCheckBox::toggled, [&] { + onOptionsChanged(); + }); + connect(ui->filtersOr, &QCheckBox::toggled, [&] { + onOptionsChanged(); + }); + + connect(ui->filtersSeparators, qOverload(&QComboBox::currentIndexChanged), [&] { + onOptionsChanged(); + }); + + ui->filters->header()->setMinimumSectionSize(0); + ui->filters->header()->resizeSection(0, 23); + ui->categoriesSplitter->setCollapsible(0, false); + ui->categoriesSplitter->setCollapsible(1, false); + + ui->filtersSeparators->addItem(tr("Filter separators"), + ModListSortProxy::SeparatorFilter); + ui->filtersSeparators->addItem(tr("Show separators"), + ModListSortProxy::SeparatorShow); + ui->filtersSeparators->addItem(tr("Hide separators"), + ModListSortProxy::SeparatorHide); +} + +void FilterList::restoreState(const Settings& s) +{ + s.widgets().restoreIndex(ui->filtersSeparators); + s.widgets().restoreChecked(ui->filtersAnd); + s.widgets().restoreChecked(ui->filtersOr); + + if (m_core.settings().interface().saveFilters()) { + s.widgets().restoreTreeCheckState(ui->filters, CriteriaItem::StateRole); + } + checkCriteria(); +} + +void FilterList::saveState(Settings& s) const +{ + s.widgets().saveTreeCheckState(ui->filters, CriteriaItem::StateRole); + s.widgets().saveChecked(ui->filtersAnd); + s.widgets().saveChecked(ui->filtersOr); + s.widgets().saveIndex(ui->filtersSeparators); +} + +QTreeWidgetItem* FilterList::addCriteriaItem(QTreeWidgetItem* root, const QString& name, + int categoryID, CriteriaType type) +{ + auto* item = new CriteriaItem(this, name, type, categoryID); + + // For now list all categories flatly without nestling them as there is + // no way to espand nodes in the filter view since clicking changes state. + ui->filters->addTopLevelItem(item); + + return item; +} + +void FilterList::addContentCriteria() +{ + m_core.modDataContents().forEachContent( + [this](auto const& content) { + addCriteriaItem(nullptr, + QString("<%1>").arg(tr("Contains %1").arg(content.name())), + content.id(), ModListSortProxy::TypeContent); + }, + true); +} + +void FilterList::addCategoryCriteria(QTreeWidgetItem* root, + const std::set& categoriesUsed, int targetID) +{ + const auto count = static_cast(m_factory.numCategories()); + for (unsigned int i = 1; i < count; ++i) { + if (m_factory.getParentID(i) == targetID) { + int categoryID = m_factory.getCategoryID(i); + if (categoriesUsed.find(categoryID) != categoriesUsed.end()) { + QTreeWidgetItem* item = + addCriteriaItem(root, m_factory.getCategoryName(i), categoryID, + ModListSortProxy::TypeCategory); + if (m_factory.hasChildren(i)) { + addCategoryCriteria(item, categoriesUsed, categoryID); + } + } + } + } +} + +void FilterList::addSpecialCriteria(int type) +{ + const auto sc = static_cast(type); + + addCriteriaItem(nullptr, m_factory.getSpecialCategoryName(sc), type, + ModListSortProxy::TypeSpecial); +} + +void FilterList::refresh() +{ + const auto oldSelection = selectedCriteria(); + + ui->filters->clear(); + + using F = CategoryFactory; + addSpecialCriteria(F::Checked); + addSpecialCriteria(F::UpdateAvailable); + addSpecialCriteria(F::Backup); + addSpecialCriteria(F::Managed); + addSpecialCriteria(F::HasCategory); + addSpecialCriteria(F::Conflict); + addSpecialCriteria(F::HasHiddenFiles); + addSpecialCriteria(F::Endorsed); + addSpecialCriteria(F::Tracked); + addSpecialCriteria(F::HasNexusID); + addSpecialCriteria(F::HasGameData); + + addContentCriteria(); + + std::set categoriesUsed; + for (unsigned int modIdx = 0; modIdx < ModInfo::getNumMods(); ++modIdx) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIdx); + for (int categoryID : modInfo->getCategories()) { + int currentID = categoryID; + std::set cycleTest; + // also add parents so they show up in the tree + while (currentID != 0) { + categoriesUsed.insert(currentID); + if (!cycleTest.insert(currentID).second) { + log::warn("cycle in categories: {}", SetJoin(cycleTest, ", ")); + break; + } + currentID = m_factory.getParentID(m_factory.getCategoryIndex(currentID)); + } + } + } + + addCategoryCriteria(nullptr, categoriesUsed, 0); + setSelection(oldSelection); +} + +void FilterList::setSelection(const std::vector& criteria) +{ + for (int i = 0; i < ui->filters->topLevelItemCount(); ++i) { + auto* item = dynamic_cast(ui->filters->topLevelItem(i)); + if (!item) { + continue; + } + + bool found = false; + + for (auto&& c : criteria) { + if (item->type() == c.type && item->id() == c.id) { + item->setState(c.inverse ? CriteriaItem::Inverted : CriteriaItem::Active); + found = true; + break; + } + } + + if (!found) { + item->setState(CriteriaItem::Inactive); + } + } +} + +void FilterList::clearSelection() +{ + for (int i = 0; i < ui->filters->topLevelItemCount(); ++i) { + auto* ci = dynamic_cast(ui->filters->topLevelItem(i)); + if (!ci) { + continue; + } + + ci->setState(CriteriaItem::Inactive); + } + + checkCriteria(); +} + +bool FilterList::cycleItem(QTreeWidgetItem* item, int direction) +{ + auto* ci = dynamic_cast(item); + if (!ci) { + return false; + } + + if (direction > 0) { + ci->nextState(); + } else if (direction < 0) { + ci->previousState(); + } else { + return false; + } + + checkCriteria(); + return true; +} + +std::vector FilterList::selectedCriteria() const +{ + std::vector criteria; + + for (int i = 0; i < ui->filters->topLevelItemCount(); ++i) { + const auto* ci = dynamic_cast(ui->filters->topLevelItem(i)); + if (!ci) { + continue; + } + + if (ci->state() != CriteriaItem::Inactive) { + criteria.push_back( + {ci->type(), ci->id(), (ci->state() == CriteriaItem::Inverted)}); + } + } + + return criteria; +} + +void FilterList::checkCriteria() +{ + emit criteriaChanged(selectedCriteria()); +} + +void FilterList::editCategories() +{ + CategoriesDialog dialog(qApp->activeWindow()); + + if (dialog.exec() == QDialog::Accepted) { + dialog.commitChanges(); + refresh(); + } +} + +void FilterList::onOptionsChanged() +{ + const auto mode = ui->filtersAnd->isChecked() ? ModListSortProxy::FilterAnd + : ModListSortProxy::FilterOr; + + const auto separators = static_cast( + ui->filtersSeparators->currentData().toInt()); + + emit optionsChanged(mode, separators); +} diff --git a/src/src/filterlist.h b/src/src/filterlist.h new file mode 100644 index 0000000..5b3d686 --- /dev/null +++ b/src/src/filterlist.h @@ -0,0 +1,59 @@ +#ifndef MODORGANIZER_CATEGORIESLIST_INCLUDED +#define MODORGANIZER_CATEGORIESLIST_INCLUDED + +#include "modlistsortproxy.h" +#include + +namespace Ui +{ +class MainWindow; +}; +class CategoryFactory; +class Settings; +class OrganizerCore; + +class FilterList : public QObject +{ + Q_OBJECT; + +public: + FilterList(Ui::MainWindow* ui, OrganizerCore& organizer, CategoryFactory& factory); + + void restoreState(const Settings& s); + void saveState(Settings& s) const; + + void setSelection(const std::vector& criteria); + void clearSelection(); + void refresh(); + +signals: + void criteriaChanged(std::vector criteria); + void optionsChanged(ModListSortProxy::FilterMode mode, + ModListSortProxy::SeparatorsMode sep); + +private: + class CriteriaItem; + + Ui::MainWindow* ui; + OrganizerCore& m_core; + CategoryFactory& m_factory; + + bool onClick(QMouseEvent* e); + void onItemActivated(QTreeWidgetItem* item); + void onOptionsChanged(); + + void editCategories(); + void checkCriteria(); + std::vector selectedCriteria() const; + bool cycleItem(QTreeWidgetItem* item, int direction); + + QTreeWidgetItem* addCriteriaItem(QTreeWidgetItem* root, const QString& name, + int categoryID, ModListSortProxy::CriteriaType type); + + void addContentCriteria(); + void addCategoryCriteria(QTreeWidgetItem* root, const std::set& categoriesUsed, + int targetID); + void addSpecialCriteria(int type); +}; + +#endif // MODORGANIZER_CATEGORIESLIST_INCLUDED diff --git a/src/src/fluorineconfig.cpp b/src/src/fluorineconfig.cpp new file mode 100644 index 0000000..34cc5cd --- /dev/null +++ b/src/src/fluorineconfig.cpp @@ -0,0 +1,146 @@ +#include "fluorineconfig.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +QString fluorineConfigPath() +{ + QString configRoot = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation); + if (configRoot.isEmpty()) { + configRoot = QDir::homePath() + "/.config"; + } + + return QDir(configRoot).filePath("fluorine/config.json"); +} +} // namespace + +QString FluorineConfig::configFilePath() +{ + return fluorineConfigPath(); +} + +std::optional FluorineConfig::load() +{ + const QString path = configFilePath(); + QFile f(path); + if (!f.exists()) { + return std::nullopt; + } + + if (!f.open(QIODevice::ReadOnly)) { + return std::nullopt; + } + + const auto json = QJsonDocument::fromJson(f.readAll()); + f.close(); + + if (!json.isObject()) { + return std::nullopt; + } + + const QJsonObject obj = json.object(); + + FluorineConfig cfg; + cfg.app_id = static_cast(obj.value("app_id").toInteger()); + cfg.prefix_path = obj.value("prefix_path").toString(); + cfg.proton_name = obj.value("proton_name").toString(); + cfg.proton_path = obj.value("proton_path").toString(); + cfg.created = obj.value("created").toString(); + + return cfg; +} + +bool FluorineConfig::save() const +{ + const QString path = configFilePath(); + const QFileInfo fi(path); + + if (!QDir().mkpath(fi.dir().absolutePath())) { + return false; + } + + QJsonObject obj; + obj.insert("app_id", static_cast(app_id)); + obj.insert("prefix_path", prefix_path); + obj.insert("proton_name", proton_name); + obj.insert("proton_path", proton_path); + obj.insert("created", created); + + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + + const qint64 written = f.write(QJsonDocument(obj).toJson(QJsonDocument::Indented)); + f.close(); + + return written >= 0; +} + +void FluorineConfig::deleteConfig() const +{ + const QString path = configFilePath(); + if (QFile::exists(path)) { + QFile::remove(path); + } +} + +bool FluorineConfig::prefixExists() const +{ + if (prefix_path.isEmpty()) { + return false; + } + + return QDir(QDir(prefix_path).filePath("drive_c")).exists(); +} + +QString FluorineConfig::compatDataPath() const +{ + if (prefix_path.isEmpty()) { + return QString(); + } + + QDir prefixDir(prefix_path); + if (prefixDir.dirName() == "pfx") { + prefixDir.cdUp(); + return QDir::cleanPath(prefixDir.absolutePath()); + } + + return QDir::cleanPath(QFileInfo(prefix_path).dir().absolutePath()); +} + +void FluorineConfig::destroyPrefix() const +{ + const QString compatData = compatDataPath(); + if (!compatData.isEmpty()) { + QDir dir(compatData); + if (dir.exists()) { + dir.removeRecursively(); + } + } + + deleteConfig(); +} + +bool FluorineConfig::isSetup() +{ + auto cfg = load(); + return cfg.has_value() && cfg->prefixExists(); +} + +std::optional FluorineConfig::prefixPath() +{ + auto cfg = load(); + if (cfg.has_value() && cfg->prefixExists()) { + return cfg->prefix_path; + } + + return std::nullopt; +} diff --git a/src/src/fluorineconfig.h b/src/src/fluorineconfig.h new file mode 100644 index 0000000..92f8561 --- /dev/null +++ b/src/src/fluorineconfig.h @@ -0,0 +1,32 @@ +#ifndef FLUORINECONFIG_H +#define FLUORINECONFIG_H + +#include +#include +#include + +class FluorineConfig +{ +public: + uint32_t app_id = 0; + QString prefix_path; + QString proton_name; + QString proton_path; + QString created; + + static std::optional load(); + + bool save() const; + void deleteConfig() const; + bool prefixExists() const; + QString compatDataPath() const; + void destroyPrefix() const; + + static bool isSetup(); + static std::optional prefixPath(); + +private: + static QString configFilePath(); +}; + +#endif // FLUORINECONFIG_H diff --git a/src/src/forcedloaddialog.cpp b/src/src/forcedloaddialog.cpp new file mode 100644 index 0000000..2131e59 --- /dev/null +++ b/src/src/forcedloaddialog.cpp @@ -0,0 +1,71 @@ +#include "forcedloaddialog.h" +#include "ui_forcedloaddialog.h" + +#include "forcedloaddialogwidget.h" + +#include +#include + +using namespace MOBase; + +ForcedLoadDialog::ForcedLoadDialog(const IPluginGame* game, QWidget* parent) + : QDialog(parent), ui(new Ui::ForcedLoadDialog), m_GamePlugin(game) +{ + ui->setupUi(this); +} + +ForcedLoadDialog::~ForcedLoadDialog() +{ + delete ui; +} + +void ForcedLoadDialog::setValues(QList& values) +{ + ui->tableWidget->clearContents(); + + for (int i = 0; i < values.count(); i++) { + ForcedLoadDialogWidget* item = new ForcedLoadDialogWidget(m_GamePlugin, this); + item->setEnabled(values[i].enabled()); + item->setProcess(values[i].process()); + item->setLibraryPath(values[i].library()); + item->setForced(values[i].forced()); + + ui->tableWidget->insertRow(i); + ui->tableWidget->setCellWidget(i, 0, item); + } + + ui->tableWidget->resizeRowsToContents(); +} + +QList ForcedLoadDialog::values() +{ + QList results; + for (int row = 0; row < ui->tableWidget->rowCount(); row++) { + auto widget = (ForcedLoadDialogWidget*)ui->tableWidget->cellWidget(row, 0); + results.append( + ExecutableForcedLoadSetting(widget->getProcess(), widget->getLibraryPath()) + .withEnabled(widget->getEnabled()) + .withForced(widget->getForced())); + } + return results; +} + +void ForcedLoadDialog::on_addRowButton_clicked() +{ + int row = ui->tableWidget->rowCount(); + ui->tableWidget->insertRow(row); + ForcedLoadDialogWidget* item = new ForcedLoadDialogWidget(m_GamePlugin, this); + ui->tableWidget->setCellWidget(row, 0, item); + ui->tableWidget->resizeRowsToContents(); +} + +void ForcedLoadDialog::on_deleteRowButton_clicked() +{ + for (auto rowIndex : ui->tableWidget->selectionModel()->selectedRows()) { + int row = rowIndex.row(); + auto widget = (ForcedLoadDialogWidget*)ui->tableWidget->cellWidget(row, 0); + if (!widget->getForced()) { + ui->tableWidget->removeRow(row); + } + } +} diff --git a/src/src/forcedloaddialog.h b/src/src/forcedloaddialog.h new file mode 100644 index 0000000..5d3806e --- /dev/null +++ b/src/src/forcedloaddialog.h @@ -0,0 +1,35 @@ +#ifndef FORCEDLOADDIALOG_H +#define FORCEDLOADDIALOG_H + +#include +#include + +#include "executableinfo.h" +#include "iplugingame.h" + +namespace Ui +{ +class ForcedLoadDialog; +} + +class ForcedLoadDialog : public QDialog +{ + Q_OBJECT + +public: + explicit ForcedLoadDialog(const MOBase::IPluginGame* game, QWidget* parent = nullptr); + ~ForcedLoadDialog(); + + void setValues(QList& values); + QList values(); + +private slots: + void on_addRowButton_clicked(); + void on_deleteRowButton_clicked(); + +private: + Ui::ForcedLoadDialog* ui; + const MOBase::IPluginGame* m_GamePlugin; +}; + +#endif // FORCEDLOADDIALOG_H diff --git a/src/src/forcedloaddialog.ui b/src/src/forcedloaddialog.ui new file mode 100644 index 0000000..9c5b2d1 --- /dev/null +++ b/src/src/forcedloaddialog.ui @@ -0,0 +1,117 @@ + + + ForcedLoadDialog + + + + 0 + 0 + 700 + 400 + + + + Forced Load Settings + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + 1 + + + false + + + true + + + + + + + + + + Adds a row to the table. + + + Adds a row to the table. + + + Add Row + + + + + + + Deletes the selected row from the table. + + + Deletes the selected row from the table. + + + Delete Row + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + buttonBox + accepted() + ForcedLoadDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + ForcedLoadDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/src/forcedloaddialogwidget.cpp b/src/src/forcedloaddialogwidget.cpp new file mode 100644 index 0000000..1567bdf --- /dev/null +++ b/src/src/forcedloaddialogwidget.cpp @@ -0,0 +1,109 @@ +#include "forcedloaddialogwidget.h" +#include "executableinfo.h" +#include "ui_forcedloaddialogwidget.h" +#include +#include + +using namespace MOBase; + +ForcedLoadDialogWidget::ForcedLoadDialogWidget(const IPluginGame* game, QWidget* parent) + : QWidget(parent), ui(new Ui::ForcedLoadDialogWidget), m_GamePlugin(game) +{ + ui->setupUi(this); +} + +ForcedLoadDialogWidget::~ForcedLoadDialogWidget() +{ + delete ui; +} + +bool ForcedLoadDialogWidget::getEnabled() +{ + return ui->enabledBox->isChecked(); +} + +bool ForcedLoadDialogWidget::getForced() +{ + return m_Forced; +} + +QString ForcedLoadDialogWidget::getLibraryPath() +{ + return ui->libraryPathEdit->text(); +} + +QString ForcedLoadDialogWidget::getProcess() +{ + return ui->processEdit->text(); +} + +void ForcedLoadDialogWidget::setEnabled(bool enabled) +{ + ui->enabledBox->setChecked(enabled); +} + +void ForcedLoadDialogWidget::setForced(bool forced) +{ + m_Forced = forced; + ui->libraryPathBrowseButton->setEnabled(!forced); + ui->libraryPathEdit->setEnabled(!forced); + ui->processBrowseButton->setEnabled(!forced); + ui->processEdit->setEnabled(!forced); +} + +void ForcedLoadDialogWidget::setLibraryPath(const QString& path) +{ + ui->libraryPathEdit->setText(path); +} + +void ForcedLoadDialogWidget::setProcess(const QString& name) +{ + ui->processEdit->setText(name); +} + +void ForcedLoadDialogWidget::on_enabledBox_toggled() +{ + // anything to do? +} + +void ForcedLoadDialogWidget::on_libraryPathBrowseButton_clicked() +{ + QDir gameDir(m_GamePlugin->gameDirectory()); + QString startPath = gameDir.absolutePath(); + QString result = QFileDialog::getOpenFileName( + nullptr, "Select a library...", startPath, "Dynamic link library (*.dll)", + nullptr, QFileDialog::ReadOnly); + if (!result.isEmpty()) { + QFileInfo fileInfo(result); + QString relativePath = gameDir.relativeFilePath(fileInfo.filePath()); + QString filePath = fileInfo.filePath(); + if (!relativePath.startsWith("..")) { + filePath = relativePath; + } + + if (fileInfo.exists()) { + ui->libraryPathEdit->setText(filePath); + } else { + log::error("{} does not exist", filePath); + } + } +} + +void ForcedLoadDialogWidget::on_processBrowseButton_clicked() +{ + QDir gameDir(m_GamePlugin->gameDirectory()); + QString startPath = gameDir.absolutePath(); + QString result = QFileDialog::getOpenFileName(nullptr, "Select a process...", + startPath, "Executable (*.exe)", + nullptr, QFileDialog::ReadOnly); + if (!result.isEmpty()) { + QFileInfo fileInfo(result); + QString fileName = fileInfo.fileName(); + + if (fileInfo.exists()) { + ui->processEdit->setText(fileName); + } else { + log::error("{} does not exist", fileInfo.filePath()); + } + } +} diff --git a/src/src/forcedloaddialogwidget.h b/src/src/forcedloaddialogwidget.h new file mode 100644 index 0000000..2624d58 --- /dev/null +++ b/src/src/forcedloaddialogwidget.h @@ -0,0 +1,42 @@ +#ifndef FORCEDLOADDIALOGWIDGET_H +#define FORCEDLOADDIALOGWIDGET_H + +#include "iplugingame.h" +#include + +namespace Ui +{ +class ForcedLoadDialogWidget; +} + +class ForcedLoadDialogWidget : public QWidget +{ + Q_OBJECT + +public: + explicit ForcedLoadDialogWidget(const MOBase::IPluginGame* game, + QWidget* parent = nullptr); + ~ForcedLoadDialogWidget(); + + bool getEnabled(); + bool getForced(); + QString getLibraryPath(); + QString getProcess(); + + void setEnabled(bool enabled); + void setForced(bool forced); + void setLibraryPath(const QString& path); + void setProcess(const QString& name); + +private slots: + void on_enabledBox_toggled(); + void on_libraryPathBrowseButton_clicked(); + void on_processBrowseButton_clicked(); + +private: + Ui::ForcedLoadDialogWidget* ui; + bool m_Forced; + const MOBase::IPluginGame* m_GamePlugin; +}; + +#endif // FORCEDLOADDIALOGWIDGET_H diff --git a/src/src/forcedloaddialogwidget.ui b/src/src/forcedloaddialogwidget.ui new file mode 100644 index 0000000..4be215e --- /dev/null +++ b/src/src/forcedloaddialogwidget.ui @@ -0,0 +1,113 @@ + + + ForcedLoadDialogWidget + + + + 0 + 0 + 400 + 41 + + + + + 0 + 0 + + + + + + + If checked, the specified library will be force loaded for the specified process. + + + If checked, the specified library will be force loaded for the specified process. + + + + + + + + + + The name of the process that should be forced to load a library. + + + The name of the process that should be forced to load a library. + + + Process name + + + + + + + + 0 + 0 + + + + + 32 + 0 + + + + + 32 + 16777215 + + + + Browse for a process. + + + Browse for a process. + + + ... + + + + + + + The path to the library to be loaded. This may be a path relative to the managed game's directory or may be an absolute path to somewhere else. + + + The path to the library to be loaded. This may be a path relative to the managed game's directory or may be an absolute path to somewhere else. + + + Library to load + + + + + + + + 32 + 16777215 + + + + Browse for a library. + + + Browse for a library. + + + ... + + + + + + + + diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp new file mode 100644 index 0000000..9862664 --- /dev/null +++ b/src/src/fuseconnector.cpp @@ -0,0 +1,676 @@ +#include "fuseconnector.h" + +#include "settings.h" +#include "vfs/vfstree.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; + +// Global mount point for signal-handler cleanup (async-signal-safe access). +static char g_fuseMountPoint[4096] = {0}; + +void setFuseMountPointForCrashCleanup(const char* path) +{ + if (path != nullptr) { + std::strncpy(g_fuseMountPoint, path, sizeof(g_fuseMountPoint) - 1); + g_fuseMountPoint[sizeof(g_fuseMountPoint) - 1] = '\0'; + } else { + g_fuseMountPoint[0] = '\0'; + } +} + +const char* getFuseMountPointForCrashCleanup() +{ + return g_fuseMountPoint[0] != '\0' ? g_fuseMountPoint : nullptr; +} + +namespace +{ +namespace fs = std::filesystem; + +bool isFlatpak() +{ + static const bool result = QFile::exists(QStringLiteral("/.flatpak-info")); + return result; +} + +bool waitForHelperLine(QProcess* proc, const char* expected, int timeoutMs) +{ + const QByteArray target(expected); + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + + while (proc->state() == QProcess::Running) { + if (proc->canReadLine()) { + const QByteArray line = proc->readLine().trimmed(); + if (line == target) { + return true; + } + if (line.startsWith("error:")) { + log::error("VFS helper: {}", QString::fromUtf8(line)); + return false; + } + continue; + } + + const auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()) + .count(); + if (remaining <= 0) { + break; + } + proc->waitForReadyRead(static_cast(remaining)); + } + + return false; +} + +bool sendHelperCommand(QProcess* proc, const char* command, int timeoutMs) +{ + proc->write(command); + proc->write("\n"); + if (!proc->waitForBytesWritten(1000)) { + return false; + } + return waitForHelperLine(proc, "ok", timeoutMs); +} + +std::string decodeProcMountField(const std::string& in) +{ + std::string out; + out.reserve(in.size()); + + for (size_t i = 0; i < in.size();) { + if (in[i] == '\\' && i + 3 < in.size() && std::isdigit(in[i + 1]) && + std::isdigit(in[i + 2]) && std::isdigit(in[i + 3])) { + const std::string oct = in.substr(i + 1, 3); + const int value = std::stoi(oct, nullptr, 8); + out.push_back(static_cast(value)); + i += 4; + continue; + } + + out.push_back(in[i]); + ++i; + } + + return out; +} + +bool isMountPoint(const QString& path) +{ + QFile mounts(QStringLiteral("/proc/mounts")); + if (!mounts.open(QIODevice::ReadOnly)) { + return false; + } + + const auto mountPoint = QDir::cleanPath(path); + while (!mounts.atEnd()) { + const auto line = QString::fromUtf8(mounts.readLine()).trimmed(); + const auto parts = line.split(' ', Qt::SkipEmptyParts); + if (parts.size() < 2) { + continue; + } + + const QString current = QString::fromStdString( + decodeProcMountField(parts[1].toStdString())); + if (QDir::cleanPath(current) == mountPoint) { + return true; + } + } + + return false; +} + +bool runUnmountCommand(const QString& program, const QStringList& args) +{ + QProcess p; + + if (isFlatpak()) { + QStringList spawnArgs = {QStringLiteral("--host"), program}; + spawnArgs.append(args); + p.start(QStringLiteral("flatpak-spawn"), spawnArgs); + } else { + p.start(program, args); + } + + if (!p.waitForFinished(3000)) { + p.kill(); + return false; + } + + return p.exitStatus() == QProcess::NormalExit && p.exitCode() == 0; +} + +std::vector> +buildModsFromMapping(const MappingType& mapping, const QString& dataDir, + const QString& overwriteDir) +{ + std::vector> mods; + std::set seen; + + const QString dataPrefix = QDir::cleanPath(dataDir) + "/"; + const QString overPrefix = QDir::cleanPath(overwriteDir) + "/"; + + for (const auto& map : mapping) { + if (!map.isDirectory) { + continue; + } + + const QString src = QDir::cleanPath(QDir::fromNativeSeparators(map.source)); + const QString dst = QDir::cleanPath(QDir::fromNativeSeparators(map.destination)); + + if (!(dst == QDir::cleanPath(dataDir) || dst.startsWith(dataPrefix))) { + continue; + } + + if (src == QDir::cleanPath(overwriteDir) || src.startsWith(overPrefix)) { + continue; + } + + const std::string srcStd = src.toStdString(); + if (!seen.insert(srcStd).second) { + continue; + } + + const QString name = QFileInfo(src).fileName(); + mods.emplace_back(name.toStdString(), srcStd); + } + + return mods; +} + +void setupFuseOps(struct fuse_lowlevel_ops* ops) +{ + std::memset(ops, 0, sizeof(struct fuse_lowlevel_ops)); + ops->lookup = mo2_lookup; + ops->getattr = mo2_getattr; + ops->readdir = mo2_readdir; + ops->open = mo2_open; + ops->read = mo2_read; + ops->write = mo2_write; + ops->create = mo2_create; + ops->rename = mo2_rename; + ops->setattr = mo2_setattr; + ops->unlink = mo2_unlink; + ops->mkdir = mo2_mkdir; + ops->release = mo2_release; +} + +} // namespace + +FuseConnector::FuseConnector(QObject* parent) : QObject(parent) +{ + log::debug("FUSE connector initialized"); +} + +FuseConnector::~FuseConnector() +{ + unmount(); +} + +bool FuseConnector::mount( + const QString& mount_point, const QString& overwrite_dir, const QString& game_dir, + const QString& data_dir_name, + const std::vector>& mods) +{ + if (m_mounted) { + unmount(); + } + + m_overwriteDir = overwrite_dir.toStdString(); + m_gameDir = game_dir.toStdString(); + m_dataDirName = data_dir_name.toStdString(); + m_lastMods = mods; + + // Compute the actual data directory path and mount directly on it + m_dataDirPath = (fs::path(m_gameDir) / m_dataDirName).string(); + m_mountPoint = m_dataDirPath; + + if (!fs::exists(m_dataDirPath)) { + throw FuseConnectorException( + QObject::tr("Game data directory does not exist: %1") + .arg(QString::fromStdString(m_dataDirPath))); + } + + tryCleanupStaleMount(QString::fromStdString(m_mountPoint)); + + if (isFlatpak()) { + return mountViaHelper(overwrite_dir, game_dir, data_dir_name, mods); + } + + const fs::path overwritePath(m_overwriteDir); + m_stagingDir = (overwritePath.parent_path() / "VFS_staging").string(); + + std::error_code ec; + fs::create_directories(m_stagingDir, ec); + fs::create_directories(m_overwriteDir, ec); + + // Scan + cache base game files BEFORE mounting (after mount they're hidden) + m_baseFileCache = scanDataDir(m_dataDirPath); + log::debug("Cached {} base game entries from {}", m_baseFileCache.size(), + QString::fromStdString(m_dataDirPath)); + + // Open fd to data dir BEFORE mounting so we can access original files + m_backingFd = open(m_dataDirPath.c_str(), O_RDONLY | O_DIRECTORY); + if (m_backingFd < 0) { + throw FuseConnectorException( + QObject::tr("Failed to open backing fd for %1") + .arg(QString::fromStdString(m_dataDirPath))); + } + + // Build tree using cached base files + mods + overwrite + auto tree = std::make_shared( + buildDataDirVfs(m_baseFileCache, m_dataDirPath, mods, m_overwriteDir)); + + m_context = std::make_shared(); + m_context->tree = tree; + m_context->inodes = std::make_unique(); + m_context->overwrite = std::make_unique(m_stagingDir, m_overwriteDir); + m_context->backing_dir_fd = m_backingFd; + m_context->uid = ::getuid(); + m_context->gid = ::getgid(); + + // NOTE: Do NOT include mount_point here — low-level API passes it + // separately to fuse_session_mount(). Including it here causes + // "fuse: unknown option(s)" error. + std::vector argvStorage = { + "mo2fuse", "-o", "fsname=mo2linux", "-o", "default_permissions", + "-o", "noatime"}; + + std::vector argv; + argv.reserve(argvStorage.size()); + for (auto& s : argvStorage) { + argv.push_back(s.data()); + } + + struct fuse_args args = FUSE_ARGS_INIT(static_cast(argv.size()), argv.data()); + + struct fuse_lowlevel_ops ops; + setupFuseOps(&ops); + + m_session = fuse_session_new(&args, &ops, sizeof(ops), m_context.get()); + if (m_session == nullptr) { + close(m_backingFd); + m_backingFd = -1; + throw FuseConnectorException(QObject::tr("Failed to create FUSE session")); + } + + if (fuse_session_mount(m_session, m_mountPoint.c_str()) != 0) { + fuse_session_destroy(m_session); + m_session = nullptr; + close(m_backingFd); + m_backingFd = -1; + throw FuseConnectorException( + QObject::tr("Failed to mount FUSE at %1") + .arg(QString::fromStdString(m_mountPoint))); + } + + m_fuseThread = std::thread([this]() { + fuse_session_loop_mt(m_session, nullptr); + }); + + m_mounted = true; + setFuseMountPointForCrashCleanup(m_mountPoint.c_str()); + log::debug("FUSE mounted on data dir {}", QString::fromStdString(m_mountPoint)); + return true; +} + +void FuseConnector::unmount() +{ + if (!m_mounted) { + return; + } + + if (m_helperProcess) { + sendHelperCommand(m_helperProcess, "quit", 10000); + m_helperProcess->waitForFinished(5000); + if (m_helperProcess->state() != QProcess::NotRunning) { + m_helperProcess->kill(); + m_helperProcess->waitForFinished(2000); + } + delete m_helperProcess; + m_helperProcess = nullptr; + m_mounted = false; + setFuseMountPointForCrashCleanup(nullptr); + log::debug("VFS helper stopped, FUSE unmounted from {}", + QString::fromStdString(m_mountPoint)); + return; + } + + if (m_session != nullptr) { + fuse_session_exit(m_session); + fuse_session_unmount(m_session); + } + + if (m_fuseThread.joinable()) { + m_fuseThread.join(); + } + + if (m_session != nullptr) { + fuse_session_destroy(m_session); + m_session = nullptr; + } + + flushStaging(); + + if (m_backingFd >= 0) { + close(m_backingFd); + m_backingFd = -1; + } + + m_context.reset(); + m_mounted = false; + setFuseMountPointForCrashCleanup(nullptr); + + log::debug("FUSE unmounted from {}", QString::fromStdString(m_mountPoint)); +} + +bool FuseConnector::isMounted() const +{ + return m_mounted; +} + +void FuseConnector::rebuild( + const std::vector>& mods, + const QString& overwrite_dir, const QString& data_dir_name) +{ + if (!m_mounted) { + return; + } + + m_overwriteDir = overwrite_dir.toStdString(); + m_dataDirName = data_dir_name.toStdString(); + m_lastMods = mods; + + if (m_helperProcess) { + const QString dataDir = + QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); + const QString configPath = QDir(dataDir).filePath("fluorine/vfs.cfg"); + writeVfsConfig(configPath, QString::fromStdString(m_mountPoint), + overwrite_dir, QString::fromStdString(m_gameDir), + data_dir_name, mods); + sendHelperCommand(m_helperProcess, "rebuild", 10000); + return; + } + + if (m_context == nullptr) { + return; + } + + // Use cached base files - can't re-scan the data dir since it's behind our mount + auto newTree = std::make_shared( + buildDataDirVfs(m_baseFileCache, m_dataDirPath, mods, m_overwriteDir)); + + std::unique_lock lock(m_context->tree_mutex); + m_context->tree.swap(newTree); +} + +void FuseConnector::updateMapping(const MappingType& mapping) +{ + auto* game = qApp->property("managed_game").value(); + if (game == nullptr) { + throw FuseConnectorException(QObject::tr("Managed game not available")); + } + + const QString gameDir = game->gameDirectory().absolutePath(); + const QString dataDirPath = game->dataDirectory().absolutePath(); + const QString dataDirName = game->dataDirectory().dirName(); + const QString overwriteDir = Settings::instance().paths().overwrite(); + + auto mods = buildModsFromMapping(mapping, dataDirPath, overwriteDir); + + if (!m_mounted) { + // mount_point param is ignored — mount() computes it from gameDir + dataDirName + mount(dataDirPath, overwriteDir, gameDir, dataDirName, mods); + } else { + rebuild(mods, overwriteDir, dataDirName); + } +} + +void FuseConnector::updateParams(MOBase::log::Levels /*logLevel*/, + env::CoreDumpTypes /*coreDumpType*/, + const QString& /*crashDumpsPath*/, + std::chrono::seconds /*spawnDelay*/, + QString /*executableBlacklist*/, + const QStringList& /*skipFileSuffixes*/, + const QStringList& /*skipDirectories*/) +{} + +void FuseConnector::updateForcedLibraries( + const QList& /*forced*/) +{} + +void FuseConnector::flushStaging() +{ + if (m_stagingDir.empty() || m_overwriteDir.empty()) { + return; + } + + const fs::path staging(m_stagingDir); + const fs::path overwrite(m_overwriteDir); + if (!fs::exists(staging)) { + return; + } + + std::error_code ec; + for (auto it = fs::recursive_directory_iterator( + staging, fs::directory_options::skip_permission_denied); + it != fs::recursive_directory_iterator(); ++it) { + const auto& entry = *it; + const fs::path rel = fs::relative(entry.path(), staging, ec); + if (ec || rel.empty()) { + continue; + } + + const fs::path dest = overwrite / rel; + if (entry.is_directory(ec)) { + fs::create_directories(dest, ec); + continue; + } + + if (!entry.is_regular_file(ec)) { + continue; + } + + fs::create_directories(dest.parent_path(), ec); + fs::rename(entry.path(), dest, ec); + if (ec) { + ec.clear(); + fs::copy_file(entry.path(), dest, fs::copy_options::overwrite_existing, ec); + if (!ec) { + fs::remove(entry.path(), ec); + } + } + } + + fs::remove_all(staging, ec); +} + +void FuseConnector::flushStagingLive() +{ + if (!m_mounted) { + return; + } + + if (m_helperProcess) { + sendHelperCommand(m_helperProcess, "flush", 30000); + return; + } + + if (m_context == nullptr) { + return; + } + + // Move staged files to overwrite + flushStaging(); + + // Re-create the staging dir (flushStaging removes it) + std::error_code ec; + fs::create_directories(m_stagingDir, ec); + + // Rebuild the VFS tree to pick up new overwrite files + auto newTree = std::make_shared( + buildDataDirVfs(m_baseFileCache, m_dataDirPath, m_lastMods, m_overwriteDir)); + + { + std::unique_lock lock(m_context->tree_mutex); + m_context->tree.swap(newTree); + } + + // Re-create OverwriteManager with fresh staging dir + m_context->overwrite = std::make_unique(m_stagingDir, m_overwriteDir); + + log::debug("Live staging flush complete"); +} + +// Detect a stale FUSE mount by probing with stat(). Returns true if +// the path exists in the mount table OR if accessing it gives ENOTCONN +// (which happens when the FUSE daemon died but the mount is listed +// under a different path due to symlinks). +static bool isStaleOrMounted(const QString& path) +{ + if (isMountPoint(path)) { + return true; + } + + // Probe the path directly — ENOTCONN means dead FUSE mount even if + // /proc/mounts lists it under a different (canonical) path. + struct stat st; + if (::stat(path.toLocal8Bit().constData(), &st) != 0 && errno == ENOTCONN) { + return true; + } + + return false; +} + +static void doUnmount(const QString& path) +{ + const QString clean = QDir::cleanPath(path); + + if (runUnmountCommand("fusermount3", {"-u", clean}) || + runUnmountCommand("fusermount", {"-u", clean})) { + log::info("stale mount at '{}' cleaned up successfully", path); + return; + } + + // Graceful unmount failed — try force/lazy variants. + runUnmountCommand("umount", {clean}); + runUnmountCommand("umount", {"-l", clean}); + runUnmountCommand("fusermount3", {"-uz", clean}); + runUnmountCommand("fusermount", {"-uz", clean}); + + if (!isStaleOrMounted(path)) { + log::info("stale mount at '{}' cleaned up (lazy unmount)", path); + } else { + log::error("failed to clean up stale mount at '{}'", path); + } +} + +void FuseConnector::tryCleanupStaleMount(const QString& path) +{ + if (!isStaleOrMounted(path)) { + return; + } + + log::warn("stale FUSE mount detected at '{}', attempting cleanup", path); + doUnmount(path); +} + +bool FuseConnector::mountViaHelper( + const QString& overwrite_dir, const QString& game_dir, + const QString& data_dir_name, + const std::vector>& mods) +{ + const QString dataDir = + QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); + const QString configPath = QDir(dataDir).filePath("fluorine/vfs.cfg"); + const QString helperBin = + QDir(dataDir).filePath("fluorine/bin/mo2-vfs-helper"); + + if (!QFile::exists(helperBin)) { + throw FuseConnectorException( + QObject::tr("VFS helper not found: %1").arg(helperBin)); + } + + writeVfsConfig(configPath, QString::fromStdString(m_mountPoint), overwrite_dir, + game_dir, data_dir_name, mods); + + m_helperProcess = new QProcess(this); + m_helperProcess->setProcessChannelMode(QProcess::SeparateChannels); + m_helperProcess->start(QStringLiteral("flatpak-spawn"), + {QStringLiteral("--host"), helperBin, configPath}); + + if (!m_helperProcess->waitForStarted(5000)) { + const QString err = QString::fromUtf8(m_helperProcess->readAllStandardError()); + delete m_helperProcess; + m_helperProcess = nullptr; + throw FuseConnectorException( + QObject::tr("Failed to start VFS helper process. %1").arg(err)); + } + + if (!waitForHelperLine(m_helperProcess, "mounted", 10000)) { + const QString err = QString::fromUtf8(m_helperProcess->readAllStandardError()); + const QString out = QString::fromUtf8(m_helperProcess->readAllStandardOutput()); + log::error("VFS helper stderr: {}", err); + log::error("VFS helper stdout: {}", out); + m_helperProcess->kill(); + m_helperProcess->waitForFinished(2000); + delete m_helperProcess; + m_helperProcess = nullptr; + throw FuseConnectorException( + QObject::tr("VFS helper failed to mount FUSE. %1").arg(err)); + } + + m_mounted = true; + setFuseMountPointForCrashCleanup(m_mountPoint.c_str()); + log::debug("FUSE mounted via helper on {}", + QString::fromStdString(m_mountPoint)); + return true; +} + +void FuseConnector::writeVfsConfig( + const QString& configPath, const QString& mount_point, + const QString& overwrite_dir, const QString& game_dir, + const QString& data_dir_name, + const std::vector>& mods) +{ + QDir().mkpath(QFileInfo(configPath).absolutePath()); + + QFile file(configPath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + throw FuseConnectorException( + QObject::tr("Failed to write VFS config: %1").arg(configPath)); + } + + QTextStream out(&file); + out << "mount_point=" << mount_point << "\n"; + out << "game_dir=" << game_dir << "\n"; + out << "data_dir_name=" << data_dir_name << "\n"; + out << "overwrite_dir=" << overwrite_dir << "\n"; + + for (const auto& [name, path] : mods) { + out << "mod=" << QString::fromStdString(name) << "|" + << QString::fromStdString(path) << "\n"; + } +} diff --git a/src/src/fuseconnector.h b/src/src/fuseconnector.h new file mode 100644 index 0000000..b33c52c --- /dev/null +++ b/src/src/fuseconnector.h @@ -0,0 +1,92 @@ +#ifndef FUSECONNECTOR_H +#define FUSECONNECTOR_H + +#include "envdump.h" +#include "vfs/mo2filesystem.h" + +#include +#include + +class QProcess; + +#include +#include +#include +#include +#include +#include + +class FuseConnectorException : public std::exception +{ +public: + explicit FuseConnectorException(const QString& text) + : std::exception(), m_Message(text.toLocal8Bit()) + {} + + const char* what() const throw() override { return m_Message.constData(); } + +private: + QByteArray m_Message; +}; + +class FuseConnector : public QObject +{ + Q_OBJECT + +public: + explicit FuseConnector(QObject* parent = nullptr); + ~FuseConnector() override; + + bool mount(const QString& mount_point, const QString& overwrite_dir, + const QString& game_dir, const QString& data_dir_name, + const std::vector>& mods); + + void unmount(); + bool isMounted() const; + + void rebuild(const std::vector>& mods, + const QString& overwrite_dir, const QString& data_dir_name); + + void flushStagingLive(); + + void updateMapping(const MappingType& mapping); + void updateParams(MOBase::log::Levels logLevel, env::CoreDumpTypes coreDumpType, + const QString& crashDumpsPath, std::chrono::seconds spawnDelay, + QString executableBlacklist, const QStringList& skipFileSuffixes, + const QStringList& skipDirectories); + void updateForcedLibraries( + const QList& forced); + + static void tryCleanupStaleMount(const QString& path); + +private: + void flushStaging(); + + std::string m_mountPoint; + std::string m_stagingDir; + std::string m_overwriteDir; + std::string m_gameDir; + std::string m_dataDirName; + std::string m_dataDirPath; + int m_backingFd = -1; + std::vector m_baseFileCache; + + std::vector> m_lastMods; + + std::shared_ptr m_context; + + struct fuse_session* m_session = nullptr; + std::thread m_fuseThread; + bool m_mounted = false; + + QProcess* m_helperProcess = nullptr; + bool mountViaHelper(const QString& overwrite_dir, const QString& game_dir, + const QString& data_dir_name, + const std::vector>& mods); + void writeVfsConfig(const QString& configPath, const QString& mount_point, + const QString& overwrite_dir, const QString& game_dir, + const QString& data_dir_name, + const std::vector>& mods); +}; + +#endif diff --git a/src/src/game_features.cpp b/src/src/game_features.cpp new file mode 100644 index 0000000..1865b18 --- /dev/null +++ b/src/src/game_features.cpp @@ -0,0 +1,350 @@ +#include "game_features.h" + +#include + +#include + +#include "iplugingame.h" +#include "log.h" + +#include "bsainvalidation.h" +#include "dataarchives.h" +#include "gameplugins.h" +#include "localsavegames.h" +#include "organizercore.h" +#include "plugincontainer.h" +#include "savegameinfo.h" +#include "scriptextender.h" +#include "unmanagedmods.h" + +using namespace MOBase; + +// avoid updating features more than once every 100ms +constexpr std::chrono::milliseconds UPDATE_QUEUE_TIMER{100}; + +// specific type index for checker and content that behaves differently +const std::type_index ModDataCheckerIndex{typeid(ModDataChecker)}; +const std::type_index ModDataContentIndex{typeid(ModDataContent)}; + +class GameFeatures::CombinedModDataChecker : public ModDataChecker +{ + std::vector> m_modDataCheckers; + mutable std::shared_ptr m_fixer = nullptr; + +public: + void setCheckers(std::vector> checkers) + { + m_modDataCheckers = std::move(checkers); + m_fixer = nullptr; + } + + bool isValid() const { return !m_modDataCheckers.empty(); } + + CheckReturn + dataLooksValid(std::shared_ptr fileTree) const override + { + m_fixer = nullptr; + + // go through the available mod-data checker, if any returns valid, we also + // return valid, otherwise, return the first one that is fixable + for (const auto& modDataChecker : m_modDataCheckers) { + auto check = modDataChecker->dataLooksValid(fileTree); + + switch (check) { + case CheckReturn::FIXABLE: + // only update fixer if there is not one with higher priority + if (!m_fixer) { + m_fixer = modDataChecker; + } + break; + case CheckReturn::VALID: + // clear fixer if one were found before and return VALID, not mandatory + // but cleaner + m_fixer = nullptr; + return CheckReturn::VALID; + case CheckReturn::INVALID: + break; + } + } + + return m_fixer ? CheckReturn::FIXABLE : CheckReturn::INVALID; + } + + std::shared_ptr + fix(std::shared_ptr fileTree) const override + { + if (m_fixer) { + return m_fixer->fix(fileTree); + } + + return nullptr; + } +}; +class GameFeatures::CombinedModDataContent : public ModDataContent +{ + // store the ModDataContent and the offset to add to the content + std::vector, std::unordered_map>> + m_modDataContents; + std::vector m_allContents; + +public: + bool isValid() const { return !m_modDataContents.empty(); } + + void setContents(std::vector> modDataContents) + { + m_modDataContents.clear(); + m_modDataContents.reserve(modDataContents.size()); + + m_allContents.clear(); + + // update all contents + for (auto& modDataContent : modDataContents) { + + std::unordered_map idMap; + + // extract contents for all ModDataContent, replacing ID with index in the list + // and keeping track of the ID/index with the mapping (required since + // getContentsFor returns ID, not index) + for (const auto& content : modDataContent->getAllContents()) { + const auto index = static_cast(m_allContents.size()); + idMap[content.id()] = index; + m_allContents.emplace_back(index, content.name(), content.icon(), + content.isOnlyForFilter()); + } + + m_modDataContents.emplace_back(modDataContent, std::move(idMap)); + } + } + + std::vector getAllContents() const override { return m_allContents; } + + std::vector + getContentsFor(std::shared_ptr fileTree) const + { + std::vector contentsFor; + for (const auto& modDataContent : m_modDataContents) { + auto contentsForFrom = modDataContent.first->getContentsFor(fileTree); + std::transform(contentsForFrom.begin(), contentsForFrom.end(), + std::back_inserter(contentsFor), [&modDataContent](auto content) { + return modDataContent.second.at(content); + }); + } + + return contentsFor; + } +}; + +GameFeatures::GameFeatures(OrganizerCore* core, PluginContainer* plugins) + : m_pluginContainer(*plugins), + m_modDataChecker(std::make_unique()), + m_modDataContent(std::make_unique()) +{ + // can be nullptr since the plugin container can be initialized with a Core (e.g., + // on first MO2 start) + if (core) { + connect(core, &OrganizerCore::managedGameChanged, [this] { + updateCurrentFeatures(); + }); + } + + auto updateFeatures = [this] { + QTimer::singleShot(UPDATE_QUEUE_TIMER, [this] { + updateCurrentFeatures(); + }); + }; + + connect(plugins, &PluginContainer::pluginEnabled, updateFeatures); + connect(plugins, &PluginContainer::pluginDisabled, updateFeatures); + connect(plugins, &PluginContainer::pluginRegistered, updateFeatures); + connect(plugins, &PluginContainer::pluginUnregistered, + [this, updateFeatures](MOBase::IPlugin* plugin) { + // remove features from the current plugin + for (auto& [_, features] : m_allFeatures) { + features.erase(std::remove_if(features.begin(), features.end(), + [plugin](const auto& feature) { + return feature.plugin() == plugin; + }), + features.end()); + } + + // update current features + updateFeatures(); + }); +} + +GameFeatures::~GameFeatures() {} + +GameFeatures::CombinedModDataChecker& GameFeatures::modDataChecker() const +{ + return dynamic_cast(*m_modDataChecker); +} +GameFeatures::CombinedModDataContent& GameFeatures::modDataContent() const +{ + return dynamic_cast(*m_modDataContent); +} + +void GameFeatures::updateCurrentFeatures(std::type_index const& index) +{ + auto& features = m_allFeatures[index]; + + m_currentFeatures[index].clear(); + + // this can occur when starting MO2, just wait for the next update + if (!m_pluginContainer.managedGame()) { + return; + } + + for (const auto& dataFeature : features) { + + // registering plugin is disabled + if (!m_pluginContainer.isEnabled(dataFeature.plugin())) { + continue; + } + + // games does not match + if (!dataFeature.games().isEmpty() && + !dataFeature.games().contains(m_pluginContainer.managedGame()->gameName())) { + continue; + } + + m_currentFeatures[index].push_back(dataFeature.feature()); + } + + // update mod data checker + if (index == ModDataCheckerIndex) { + auto& currentCheckers = m_currentFeatures[ModDataCheckerIndex]; + std::vector> checkers; + checkers.reserve(currentCheckers.size()); + std::transform(currentCheckers.begin(), currentCheckers.end(), + std::back_inserter(checkers), [](auto const& checker) { + return std::dynamic_pointer_cast(checker); + }); + modDataChecker().setCheckers(std::move(checkers)); + emit modDataCheckerUpdated(gameFeature().get()); + } + + // update mod data content + if (index == ModDataContentIndex) { + auto& currentContents = m_currentFeatures[ModDataContentIndex]; + std::vector> contents; + contents.reserve(currentContents.size()); + std::transform(currentContents.begin(), currentContents.end(), + std::back_inserter(contents), [](auto const& checker) { + return std::dynamic_pointer_cast(checker); + }); + modDataContent().setContents(std::move(contents)); + emit modDataContentUpdated(gameFeature().get()); + } +} + +void GameFeatures::updateCurrentFeatures() +{ + // TODO: this completely refilters everything currently, which should be ok since + // this should only be triggered by function that are uncommon (enabling/disabling + // plugin or changing the game), it should be possible to filter more finely by + // maintaining more information (e.g., only removing features from current when a + // plugin is disabled or unregistered) + // + + for (auto& [info, _] : m_allFeatures) { + updateCurrentFeatures(info); + } +} + +bool GameFeatures::registerGameFeature(MOBase::IPlugin* plugin, + QStringList const& games, + std::shared_ptr feature, + int priority) +{ + auto& features = m_allFeatures[feature->typeInfo()]; + + if (std::find_if(features.begin(), features.end(), [&feature](const auto& data) { + return data.feature() == feature; + }) != features.end()) { + log::error("cannot register feature multiple time"); + return false; + } + + std::decay_t::iterator it; + if (dynamic_cast(plugin)) { + it = features.end(); + } else { + it = std::lower_bound(features.begin(), features.end(), priority, + [](const auto& feature, int priority) { + return feature.priority() > priority; + }); + } + + features.emplace(it, feature, plugin, games, std::numeric_limits::min()); + + // TODO: only update if relevant + updateCurrentFeatures(feature->typeInfo()); + + return true; +} + +// unregister game features +// +bool GameFeatures::unregisterGameFeature(std::shared_ptr feature) +{ + bool removed = false; + for (auto& [_, features] : m_allFeatures) { + auto it = + std::find_if(features.begin(), features.end(), [&feature](const auto& data) { + return data.feature() == feature; + }); + + // the feature can only exist for one kind of features and cannot be duplicated + if (it != features.end()) { + features.erase(it); + removed = true; + break; + } + } + + if (removed) { + updateCurrentFeatures(); + } + + return removed; +} + +int GameFeatures::unregisterGameFeatures(MOBase::IPlugin* plugin, + std::type_info const& info) +{ + auto& features = m_allFeatures[info]; + + const auto initialSize = features.size(); + + features.erase(std::remove_if(features.begin(), features.end(), + [plugin](const auto& feature) { + return feature.plugin() == plugin; + }), + features.end()); + + const int removed = features.size() - initialSize; + + if (removed) { + updateCurrentFeatures(); + } + + return removed; +} + +std::shared_ptr GameFeatures::gameFeature(std::type_info const& info) const +{ + if (info == ModDataCheckerIndex) { + return modDataChecker().isValid() ? m_modDataChecker : nullptr; + } + + if (info == ModDataContentIndex) { + return modDataContent().isValid() ? m_modDataContent : nullptr; + } + + auto it = m_currentFeatures.find(info); + if (it == m_currentFeatures.end() || it->second.empty()) { + return nullptr; + } + + return it->second.front(); +} diff --git a/src/src/game_features.h b/src/src/game_features.h new file mode 100644 index 0000000..52390ae --- /dev/null +++ b/src/src/game_features.h @@ -0,0 +1,117 @@ +#ifndef MO2_GAME_FEATURES_H +#define MO2_GAME_FEATURES_H + +#include +#include +#include +#include +#include + +#include + +#include "game_feature.h" +#include "moddatachecker.h" +#include "moddatacontent.h" + +namespace MOBase +{ +class IPlugin; +class IPluginGame; +} // namespace MOBase + +class OrganizerCore; +class PluginContainer; + +/** + * Class managing game features, either registered or from the game plugin. + */ +class GameFeatures : public QObject +{ + Q_OBJECT + +public: + /** + * + */ + GameFeatures(OrganizerCore* core, PluginContainer* plugins); + + ~GameFeatures(); + + // register game features + // + bool registerGameFeature(MOBase::IPlugin* plugin, QStringList const& games, + std::shared_ptr feature, int priority); + + // unregister game features + // + bool unregisterGameFeature(std::shared_ptr feature); + int unregisterGameFeatures(MOBase::IPlugin* plugin, std::type_info const& info); + + // retrieve a game feature + // + template + std::shared_ptr gameFeature() const + { + return std::dynamic_pointer_cast(gameFeature(typeid(T))); + } + +signals: + void modDataCheckerUpdated(const MOBase::ModDataChecker* check); + void modDataContentUpdated(const MOBase::ModDataContent* content); + +private: + friend class GameFeaturesProxy; + + class GameFeatureWithData + { + // feature + std::shared_ptr m_feature; + + // plugin that registered the feature + MOBase::IPlugin* m_plugin; + + // games this plugin applies to - empty list indicates all games + QStringList m_games; + + // priority of the plugin + int m_priority; + + public: + GameFeatureWithData(std::shared_ptr feature, + MOBase::IPlugin* plugin, QStringList games, int priority) + : m_feature(feature), m_plugin(plugin), m_games(games), m_priority(priority) + {} + + const auto& feature() const { return m_feature; } + auto* const plugin() const { return m_plugin; } + const auto& games() const { return m_games; } + auto priority() const { return m_priority; } + }; + + // retrieve a game feature from info + // + std::shared_ptr gameFeature(std::type_info const& index) const; + + // update current features by filtering + // + void updateCurrentFeatures(std::type_index const& index); + void updateCurrentFeatures(); + + // implementation details + class CombinedModDataChecker; + class CombinedModDataContent; + + CombinedModDataChecker& modDataChecker() const; + CombinedModDataContent& modDataContent() const; + + PluginContainer& m_pluginContainer; + + std::unordered_map> m_allFeatures; + std::unordered_map>> + m_currentFeatures; + + std::shared_ptr m_modDataChecker; + std::shared_ptr m_modDataContent; +}; + +#endif diff --git a/src/src/gamefeaturesproxy.cpp b/src/src/gamefeaturesproxy.cpp new file mode 100644 index 0000000..607f74d --- /dev/null +++ b/src/src/gamefeaturesproxy.cpp @@ -0,0 +1,48 @@ +#include "gamefeaturesproxy.h" + +#include "game_features.h" +#include "organizerproxy.h" + +GameFeaturesProxy::GameFeaturesProxy(OrganizerProxy* coreProxy, + GameFeatures& gameFeatures) + : m_CoreProxy(*coreProxy), m_Features(gameFeatures) +{} + +bool GameFeaturesProxy::registerFeature(QStringList const& games, + std::shared_ptr feature, + int priority, bool replace) +{ + if (replace) { + m_Features.unregisterGameFeatures(m_CoreProxy.plugin(), feature->typeInfo()); + } + return m_Features.registerGameFeature(m_CoreProxy.plugin(), games, feature, priority); +} + +bool GameFeaturesProxy::registerFeature(MOBase::IPluginGame* game, + std::shared_ptr feature, + int priority, bool replace) +{ + return registerFeature({game->gameName()}, feature, priority, replace); +} + +bool GameFeaturesProxy::registerFeature(std::shared_ptr feature, + int priority, bool replace) +{ + return registerFeature(QStringList(), feature, priority, replace); +} + +bool GameFeaturesProxy::unregisterFeature(std::shared_ptr feature) +{ + return m_Features.unregisterGameFeature(feature); +} + +std::shared_ptr +GameFeaturesProxy::gameFeatureImpl(std::type_info const& info) const +{ + return m_Features.gameFeature(info); +} + +int GameFeaturesProxy::unregisterFeaturesImpl(std::type_info const& info) +{ + return m_Features.unregisterGameFeatures(m_CoreProxy.plugin(), info); +} diff --git a/src/src/gamefeaturesproxy.h b/src/src/gamefeaturesproxy.h new file mode 100644 index 0000000..db83b86 --- /dev/null +++ b/src/src/gamefeaturesproxy.h @@ -0,0 +1,34 @@ +#ifndef GAMEFEATURESPROXY_H +#define GAMEFEATURESPROXY_H + +#include "igamefeatures.h" + +class GameFeatures; +class OrganizerProxy; + +class GameFeaturesProxy : public MOBase::IGameFeatures +{ +public: + GameFeaturesProxy(OrganizerProxy* coreProxy, GameFeatures& gameFeatures); + + bool registerFeature(QStringList const& games, + std::shared_ptr feature, int priority, + bool replace) override; + bool registerFeature(MOBase::IPluginGame* game, + std::shared_ptr feature, int priority, + bool replace) override; + bool registerFeature(std::shared_ptr feature, int priority, + bool replace) override; + bool unregisterFeature(std::shared_ptr feature) override; + +protected: + std::shared_ptr + gameFeatureImpl(std::type_info const& info) const override; + int unregisterFeaturesImpl(std::type_info const& info) override; + +private: + GameFeatures& m_Features; + OrganizerProxy& m_CoreProxy; +}; + +#endif diff --git a/src/src/genericicondelegate.cpp b/src/src/genericicondelegate.cpp new file mode 100644 index 0000000..52154cd --- /dev/null +++ b/src/src/genericicondelegate.cpp @@ -0,0 +1,27 @@ +#include "genericicondelegate.h" +#include "pluginlist.h" +#include +#include + +GenericIconDelegate::GenericIconDelegate(QTreeView* parent, int role, int logicalIndex, + int compactSize) + : IconDelegate(parent, logicalIndex, compactSize), m_Role(role) +{} + +QList GenericIconDelegate::getIcons(const QModelIndex& index) const +{ + QList result; + if (index.isValid()) { + for (const QVariant& var : index.data(m_Role).toList()) { + if (!compact() || !var.toString().isEmpty()) { + result.append(var.toString()); + } + } + } + return result; +} + +size_t GenericIconDelegate::getNumIcons(const QModelIndex& index) const +{ + return index.data(m_Role).toList().count(); +} diff --git a/src/src/genericicondelegate.h b/src/src/genericicondelegate.h new file mode 100644 index 0000000..9aabfd4 --- /dev/null +++ b/src/src/genericicondelegate.h @@ -0,0 +1,40 @@ +#ifndef GENERICICONDELEGATE_H +#define GENERICICONDELEGATE_H + +#include "icondelegate.h" + +/** + * @brief an icon delegate that takes the list of icons from a user-defines data role + */ +class GenericIconDelegate : public IconDelegate +{ + Q_OBJECT +public: + /** + * @brief constructor + * @param parent parent object + * @param role role of the itemmodel from which the icon list can be queried (as a + * QVariantList) + * @param logicalIndex logical index within the model. This is part of a "hack". + * Normally "empty" icons will be allocated the same space as a regular icon. This way + * the model can use empty icons as spacers and thus align same icons horizontally. + * Now, if you set the logical Index to a valid column and connect + * the columnResized slot to the sectionResized signal of the view, the delegate will + * turn off this behaviour if the column is smaller than "compactSize" + * @param compactSize see explanation of logicalIndex + */ + GenericIconDelegate(QTreeView* parent, int role = Qt::UserRole + 1, + int logicalIndex = -1, int compactSize = 150); + +private: + virtual QList getIcons(const QModelIndex& index) const; + virtual size_t getNumIcons(const QModelIndex& index) const; + +private: + int m_Role; + int m_LogicalIndex; + int m_CompactSize; + bool m_Compact; +}; + +#endif // GENERICICONDELEGATE_H diff --git a/src/src/github.cpp b/src/src/github.cpp new file mode 100644 index 0000000..dcbfaf1 --- /dev/null +++ b/src/src/github.cpp @@ -0,0 +1,204 @@ +#include "github.h" +#include +#include +#include + +#include +#include + +static const QString GITHUB_URL("https://api.github.com"); +static const QString USER_AGENT("GitHubPP"); + +GitHub::GitHub(const char* clientId) : m_AccessManager(new QNetworkAccessManager(this)) +{} + +GitHub::~GitHub() +{ + // delete all the replies since they depend on the access manager, which is + // about to be deleted + for (auto* reply : m_replies) { + reply->disconnect(); + delete reply; + } +} + +QJsonArray GitHub::releases(const Repository& repo) +{ + QJsonDocument result = request( + Method::GET, QString("repos/%1/%2/releases").arg(repo.owner, repo.project), + QByteArray(), true); + return result.array(); +} + +void GitHub::releases(const Repository& repo, + const std::function& callback) +{ + request( + Method::GET, QString("repos/%1/%2/releases").arg(repo.owner, repo.project), + QByteArray(), + [callback](const QJsonDocument& result) { + callback(result.array()); + }, + true); +} + +QJsonDocument GitHub::handleReply(QNetworkReply* reply) +{ + int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (statusCode != 200) { + return QJsonDocument(QJsonObject( + {{"http_status", statusCode}, + {"redirection", + reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toString()}, + {"reason", + reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString()}})); + } + + QByteArray data = reply->readAll(); + if (data.isNull() || data.isEmpty() || (strcmp(data.constData(), "null") == 0)) { + return QJsonDocument(); + } + + QJsonParseError parseError; + QJsonDocument result = QJsonDocument::fromJson(data, &parseError); + + if (parseError.error != QJsonParseError::NoError) { + return QJsonDocument(QJsonObject({{"parse_error", parseError.errorString()}})); + } + + return result; +} + +QNetworkReply* GitHub::genReply(Method method, const QString& path, + const QByteArray& data, bool relative) +{ + QNetworkRequest request(relative ? GITHUB_URL + "/" + path : path); + + request.setHeader(QNetworkRequest::UserAgentHeader, USER_AGENT); + request.setRawHeader("Accept", "application/vnd.github.v3+json"); + + switch (method) { + case Method::GET: + return m_AccessManager->get(request); + case Method::POST: + return m_AccessManager->post(request, data); + default: + // this shouldn't be possible as all enum options are handled + throw std::runtime_error("invalid method"); + } +} + +QJsonDocument GitHub::request(Method method, const QString& path, + const QByteArray& data, bool relative) +{ + QEventLoop wait; + QNetworkReply* reply = genReply(method, path, data, relative); + + connect(reply, SIGNAL(finished), &wait, SLOT(quit())); + wait.exec(); + QJsonDocument result = handleReply(reply); + reply->deleteLater(); + + QJsonObject object = result.object(); + if (object.value("http_status").toDouble() == 301.0) { + return request(method, object.value("redirection").toString(), data, false); + } else { + return result; + } +} + +void GitHub::request(Method method, const QString& path, const QByteArray& data, + const std::function& callback, + bool relative) +{ + // make sure the timer is owned by this so it's deleted correctly and + // doesn't fire after the GitHub object is destroyed; this happens when + // restarting MO by switching instances, for example + QTimer* timer = new QTimer(this); + timer->setSingleShot(true); + timer->setInterval(10000); + + QNetworkReply* reply = genReply(method, path, data, relative); + + // remember this reply so it can be deleted in the destructor if necessary + m_replies.push_back(reply); + + Request req = {method, data, callback, timer, reply}; + + // finished + connect(reply, &QNetworkReply::finished, [this, req] { + onFinished(req); + }); + + // error + connect(reply, qOverload(&QNetworkReply::errorOccurred), + [this, req](auto&& error) { + onError(req, error); + }); + + // timeout + connect(timer, &QTimer::timeout, [this, req] { + onTimeout(req); + }); + + timer->start(); +} + +void GitHub::onFinished(const Request& req) +{ + QJsonDocument result = handleReply(req.reply); + QJsonObject object = result.object(); + + req.timer->stop(); + + if (object.value("http_status").toInt() == 301) { + request(req.method, object.value("redirection").toString(), req.data, req.callback, + false); + } else { + req.callback(result); + } + + deleteReply(req.reply); +} + +void GitHub::onError(const Request& req, QNetworkReply::NetworkError error) +{ + // the only way the request can be aborted is when there's a timeout, which + // already logs a message + if (error != QNetworkReply::OperationCanceledError) { + qCritical().noquote().nospace() + << "Github: request for " << req.reply->url().toString() << " failed, " + << req.reply->errorString() << " (" << error << ")"; + } + + req.timer->stop(); + req.reply->disconnect(); + + QJsonObject root({{"network_error", req.reply->errorString()}}); + QJsonDocument doc(root); + + req.callback(doc); + + deleteReply(req.reply); +} + +void GitHub::onTimeout(const Request& req) +{ + qCritical().noquote().nospace() + << "Github: request for " << req.reply->url().toString() << " timed out"; + + // don't delete the reply, abort will fire the error() handler above + req.reply->abort(); +} + +void GitHub::deleteReply(QNetworkReply* reply) +{ + // remove from the list + auto itor = std::find(m_replies.begin(), m_replies.end(), reply); + if (itor != m_replies.end()) { + m_replies.erase(itor); + } + + // delete + reply->deleteLater(); +} diff --git a/src/src/github.h b/src/src/github.h new file mode 100644 index 0000000..b82fe94 --- /dev/null +++ b/src/src/github.h @@ -0,0 +1,108 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +class GitHubException : public std::exception +{ +public: + GitHubException(const QJsonObject& errorObj) : std::exception() + { + initMessage(errorObj); + } + + virtual ~GitHubException() throw() override {} + + virtual const char* what() const throw() { return m_Message.constData(); } + +private: + void initMessage(const QJsonObject& obj) + { + if (obj.contains("http_status")) { + m_Message = QString("HTTP Status %1: %2") + .arg(obj.value("http_status").toInt()) + .arg(obj.value("reason").toString()) + .toUtf8(); + } else if (obj.contains("parse_error")) { + m_Message = QString("Parsing failed: %1") + .arg(obj.value("parse_error").toString()) + .toUtf8(); + } else if (obj.contains("network_error")) { + m_Message = QString("Network failed: %1") + .arg(obj.value("network_error").toString()) + .toUtf8(); + } else { + m_Message = "Unknown error"; + } + } + + QByteArray m_Message; +}; + +class GitHub : public QObject +{ + + Q_OBJECT + +public: + enum class Method + { + GET, + POST + }; + + struct Repository + { + Repository(const QString& owner, const QString& project) + : owner(owner), project(project) + {} + QString owner; + QString project; + }; + +public: + GitHub(const char* clientId = nullptr); + ~GitHub(); + + QJsonArray releases(const Repository& repo); + void releases(const Repository& repo, + const std::function& callback); + +private: + QJsonDocument request(Method method, const QString& path, const QByteArray& data, + bool relative); + void request(Method method, const QString& path, const QByteArray& data, + const std::function& callback, + bool relative); + + QJsonDocument handleReply(QNetworkReply* reply); + QNetworkReply* genReply(Method method, const QString& path, const QByteArray& data, + bool relative); + +private: + struct Request + { + Method method = Method::GET; + QByteArray data; + std::function callback; + QTimer* timer = nullptr; + QNetworkReply* reply = nullptr; + }; + + QNetworkAccessManager* m_AccessManager; + + // remember the replies that are in flight and delete them in the destructor + std::vector m_replies; + + void onFinished(const Request& req); + void onError(const Request& req, QNetworkReply::NetworkError error); + void onTimeout(const Request& req); + + void deleteReply(QNetworkReply* reply); +}; diff --git a/src/src/glob_matching.h b/src/src/glob_matching.h new file mode 100644 index 0000000..bb7f64f --- /dev/null +++ b/src/src/glob_matching.h @@ -0,0 +1,185 @@ +/* Copyright (C) 2020 G'k + * Imported by Holt59 + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +#ifndef GLOB_MATCHING_H +#define GLOB_MATCHING_H + +#include +#include +#include + +namespace MOShared +{ + +/** + * Contraints string_traits to allow usage of both standard strings and QString in + * GlobPattern. + */ +namespace details +{ + + template , + class Allocator = std::allocator> + struct string_traits + { + using string_type = std::basic_string; + using string_view = std::basic_string_view; + + static auto tolower(CharT c) { return std::tolower(c); } + + static auto empty(string_view const& view) { return view.empty(); } + }; + + template <> + struct string_traits + { + using string_type = QString; + using string_view = QString; + + static auto tolower(QChar const& c) { return c.toLower(); } + static auto empty(string_view const& view) { return view.isEmpty(); } + }; + +} // namespace details + +/** + * @brief Class that provides basic wildcard pattern matching. + * + * From https://gitlab.com/G_ka/playground/-/commits/master/include/wildcards.hpp + * + * Currently, this supports the following globbing character: + * - '*' matches zero or more characters. + * - '?' matches exactly one character. + * - '[abc]' matches one of 'a', 'b' or 'c'. + * + * Standard globbing feature not supported: + * - You cannot escape globbing characters with \. + * - You cannot use `[a-z]` to match any character from 'a' to 'z'. + * + * Custom class because the following alternatives have some issues: + * - QRegExp is a tad slow, and we need to convert everything to QString. + * - QDir::match is VERY slow. I think it converts the glob pattern to a + * QRegularExpression and then use it. + * - PatchMatchSpecW (Windows API) is fast but does not support some useful glob + * pattern (e.g., [ab]). + * + * Advantage of this over the above methods: + * - It is fast. Quick testing show that this is faster than PatchMatchSpecW. + * - It can be used on most string types (QString, std::string, std::wstring, etc.). + */ +template , + class Allocator = std::allocator> +class GlobPattern +{ +public: + using traits = details::string_traits; + + using string_type = typename traits::string_type; + using string_view_type = typename traits::string_view; + + struct card + { + // Relying on automatic conversion: + static constexpr CharT any = '?'; + static constexpr CharT any_repeat = '*'; + static constexpr CharT set_begin = '['; + static constexpr CharT set_end = ']'; + }; + +public: + GlobPattern(string_view_type const& s) : v{s} {} + + const string_type& native() const { return v; } + + constexpr bool match(string_view_type const& str, bool case_sensitive = false) + { + // Empty pattern can only match with empty sting + if (traits::empty(v)) + return traits::empty(str); + + auto pat_it = v.begin(); + auto pat_end = v.end(); + + auto str_it = str.begin(); + auto str_end = str.end(); + + auto anyrep_pos_pat = pat_end; + auto anyrep_pos_str = str_end; + + auto set_pos_pat = pat_end; + + while (str_it != str_end) { + CharT current_pat = QChar(0); + CharT current_str = QChar(-1); + if (pat_it != pat_end) { + current_pat = case_sensitive ? *pat_it : traits::tolower(*pat_it); + current_str = case_sensitive ? *str_it : traits::tolower(*str_it); + } + if (pat_it != pat_end && current_pat == card::set_begin) { + set_pos_pat = pat_it; + pat_it++; + } else if (pat_it != pat_end && current_pat == card::set_end) { + if (anyrep_pos_pat != pat_end) { + set_pos_pat = pat_end; + pat_it++; + } else { + return false; + } + + } else if (set_pos_pat != pat_end) { + if (current_pat == current_str) { + set_pos_pat = pat_end; + pat_it = std::find(pat_it, pat_end, card::set_end) + 1; + str_it++; + } else { + if (pat_it == pat_end) { + return false; + } + pat_it++; + } + } else if (pat_it != pat_end && current_pat == current_str) { + pat_it++; + str_it++; + } else if (pat_it != pat_end && current_pat == card::any) { + pat_it++; + str_it++; + } else if (pat_it != pat_end && current_pat == card::any_repeat) { + anyrep_pos_pat = pat_it; + anyrep_pos_str = str_it; + pat_it++; + } else if (anyrep_pos_pat != pat_end) { + pat_it = anyrep_pos_pat + 1; + str_it = anyrep_pos_str + 1; + anyrep_pos_str++; + } else { + return false; + } + } + while (pat_it != pat_end) { + CharT cur = case_sensitive ? *pat_it : traits::tolower(*pat_it); + if (cur == card::any_repeat) + pat_it++; + else + break; + } + return pat_it == pat_end; + } + +private: + string_type v; +}; + +template +GlobPattern(std::basic_string const&) + -> GlobPattern; + +template +GlobPattern(CharT const*) -> GlobPattern; + +GlobPattern(QString const&) -> GlobPattern; + +} // namespace MOShared + +#endif diff --git a/src/src/icondelegate.cpp b/src/src/icondelegate.cpp new file mode 100644 index 0000000..b3c23b0 --- /dev/null +++ b/src/src/icondelegate.cpp @@ -0,0 +1,87 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "icondelegate.h" +#include +#include +#include +#include +#include +#include + +using namespace MOBase; + +IconDelegate::IconDelegate(QTreeView* view, int column, int compactSize) + : QStyledItemDelegate(view), m_column(column), m_compactSize(compactSize), + m_compact(false) +{ + if (view) { + connect(view->header(), &QHeaderView::sectionResized, + [=](int column, int, int size) { + if (column == m_column) { + m_compact = size < m_compactSize; + } + }); + } +} + +void IconDelegate::paintIcons(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index, const QList& icons) +{ + int x = 4; + painter->save(); + + int iconWidth = icons.size() > 0 ? ((option.rect.width() / icons.size()) - 4) : 16; + iconWidth = std::min(16, iconWidth); + + const int margin = (option.rect.height() - iconWidth) / 2; + + painter->translate(option.rect.topLeft()); + for (const QString& iconId : icons) { + if (iconId.isEmpty()) { + x += iconWidth + 4; + continue; + } + QPixmap icon; + QString fullIconId = QString("%1_%2").arg(iconId).arg(iconWidth); + if (!QPixmapCache::find(fullIconId, &icon)) { + icon = QIcon(iconId).pixmap(iconWidth, iconWidth); + if (icon.isNull()) { + log::warn("failed to load icon {}", iconId); + } + QPixmapCache::insert(fullIconId, icon); + } + painter->drawPixmap(x, margin, iconWidth, iconWidth, icon); + x += iconWidth + 4; + } + + painter->restore(); +} + +void IconDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const +{ + if (auto* w = qobject_cast(parent())) { + w->itemDelegate()->paint(painter, option, index); + } else { + QStyledItemDelegate::paint(painter, option, index); + } + + paintIcons(painter, option, index, getIcons(index)); +} diff --git a/src/src/icondelegate.h b/src/src/icondelegate.h new file mode 100644 index 0000000..75c4746 --- /dev/null +++ b/src/src/icondelegate.h @@ -0,0 +1,54 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef ICONDELEGATE_H +#define ICONDELEGATE_H + +#include +#include +#include + +class IconDelegate : public QStyledItemDelegate +{ + Q_OBJECT; + +public: + explicit IconDelegate(QTreeView* view, int column = -1, int compactSize = 100); + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + +protected: + // check if icons should be compacted or not + // + bool compact() const { return m_compact; } + + static void paintIcons(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index, const QList& icons); + + virtual QList getIcons(const QModelIndex& index) const = 0; + virtual size_t getNumIcons(const QModelIndex& index) const = 0; + +private: + int m_column; + int m_compactSize; + bool m_compact; +}; + +#endif // ICONDELEGATE_H diff --git a/src/src/iconfetcher.cpp b/src/src/iconfetcher.cpp new file mode 100644 index 0000000..5dce4ae --- /dev/null +++ b/src/src/iconfetcher.cpp @@ -0,0 +1,165 @@ +#include "iconfetcher.h" +#include "shared/util.h" +#include "thread_utils.h" + +void IconFetcher::Waiter::wait() +{ + std::unique_lock lock(m_wakeUpMutex); + m_wakeUp.wait(lock, [&] { + return m_queueAvailable; + }); + m_queueAvailable = false; +} + +void IconFetcher::Waiter::wakeUp() +{ + { + std::scoped_lock lock(m_wakeUpMutex); + m_queueAvailable = true; + } + + m_wakeUp.notify_one(); +} + +#ifdef _WIN32 +IconFetcher::IconFetcher() : m_iconSize(GetSystemMetrics(SM_CXSMICON)), m_stop(false) +#else +IconFetcher::IconFetcher() : m_iconSize(16), m_stop(false) +#endif +{ + m_quickCache.file = getPixmapIcon(QFileIconProvider::File); + m_quickCache.directory = getPixmapIcon(QFileIconProvider::Folder); + + m_thread = MOShared::startSafeThread([&] { + threadFun(); + }); +} + +IconFetcher::~IconFetcher() +{ + stop(); + m_thread.join(); +} + +void IconFetcher::stop() +{ + m_stop = true; + m_waiter.wakeUp(); +} + +QVariant IconFetcher::icon(const QString& path) const +{ + if (hasOwnIcon(path)) { + return fileIcon(path); + } else { + const auto dot = path.lastIndexOf("."); + + if (dot == -1) { + // no extension + return m_quickCache.file; + } + + return extensionIcon(QStringView{path}.mid(dot)); + } +} + +QPixmap IconFetcher::genericFileIcon() const +{ + return m_quickCache.file; +} + +QPixmap IconFetcher::genericDirectoryIcon() const +{ + return m_quickCache.directory; +} + +bool IconFetcher::hasOwnIcon(const QString& path) const +{ + static const QString exe = ".exe"; + static const QString lnk = ".lnk"; + static const QString ico = ".ico"; + + return path.endsWith(exe, Qt::CaseInsensitive) || + path.endsWith(lnk, Qt::CaseInsensitive) || + path.endsWith(ico, Qt::CaseInsensitive); +} + +void IconFetcher::threadFun() +{ + MOShared::SetThisThreadName("IconFetcher"); + + while (!m_stop) { + m_waiter.wait(); + if (m_stop) { + break; + } + + checkCache(m_extensionCache); + checkCache(m_fileCache); + } +} + +void IconFetcher::checkCache(Cache& cache) +{ + std::set queue; + + { + std::scoped_lock lock(cache.queueMutex); + queue = std::move(cache.queue); + cache.queue.clear(); + } + + if (queue.empty()) { + return; + } + + std::map map; + for (auto&& ext : queue) { + map.emplace(std::move(ext), getPixmapIcon(QFileInfo(ext))); + } + + { + std::scoped_lock lock(cache.mapMutex); + for (auto&& p : map) { + cache.map.insert(std::move(p)); + } + } +} + +void IconFetcher::queue(Cache& cache, QString path) const +{ + { + std::scoped_lock lock(cache.queueMutex); + cache.queue.insert(std::move(path)); + } + + m_waiter.wakeUp(); +} + +QVariant IconFetcher::fileIcon(const QString& path) const +{ + { + std::scoped_lock lock(m_fileCache.mapMutex); + auto itor = m_fileCache.map.find(path); + if (itor != m_fileCache.map.end()) { + return itor->second; + } + } + + queue(m_fileCache, path); + return {}; +} + +QVariant IconFetcher::extensionIcon(const QStringView& ext) const +{ + { + std::scoped_lock lock(m_extensionCache.mapMutex); + auto itor = m_extensionCache.map.find(ext); + if (itor != m_extensionCache.map.end()) { + return itor->second; + } + } + + queue(m_extensionCache, ext.toString()); + return {}; +} diff --git a/src/src/iconfetcher.h b/src/src/iconfetcher.h new file mode 100644 index 0000000..e0f3baf --- /dev/null +++ b/src/src/iconfetcher.h @@ -0,0 +1,75 @@ +#ifndef MODORGANIZER_ICONFETCHER_INCLUDED +#define MODORGANIZER_ICONFETCHER_INCLUDED +#include +#include +#include +#include + +class IconFetcher +{ +public: + IconFetcher(); + ~IconFetcher(); + + void stop(); + + QVariant icon(const QString& path) const; + QPixmap genericFileIcon() const; + QPixmap genericDirectoryIcon() const; + +private: + struct QuickCache + { + QPixmap file; + QPixmap directory; + }; + + struct Cache + { + std::map> map; + std::mutex mapMutex; + + std::set queue; + std::mutex queueMutex; + }; + + class Waiter + { + public: + void wait(); + void wakeUp(); + + private: + mutable std::mutex m_wakeUpMutex; + std::condition_variable m_wakeUp; + bool m_queueAvailable = false; + }; + + const int m_iconSize; + QFileIconProvider m_provider; + std::thread m_thread; + std::atomic m_stop; + + mutable QuickCache m_quickCache; + mutable Cache m_extensionCache; + mutable Cache m_fileCache; + mutable Waiter m_waiter; + + bool hasOwnIcon(const QString& path) const; + + template + QPixmap getPixmapIcon(T&& t) const + { + return m_provider.icon(t).pixmap({m_iconSize, m_iconSize}); + } + + void threadFun(); + + void checkCache(Cache& cache); + void queue(Cache& cache, QString path) const; + + QVariant fileIcon(const QString& path) const; + QVariant extensionIcon(const QStringView& ext) const; +}; + +#endif // MODORGANIZER_ICONFETCHER_INCLUDED diff --git a/src/src/installationmanager.cpp b/src/src/installationmanager.cpp new file mode 100644 index 0000000..9bd0cd8 --- /dev/null +++ b/src/src/installationmanager.cpp @@ -0,0 +1,967 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include + +#include "installationmanager.h" + +#include "categories.h" +#include "filesystemutilities.h" +#include "iplugininstallercustom.h" +#include "iplugininstallersimple.h" +#include "messagedialog.h" +#include "modinfo.h" +#include "nexusinterface.h" +#include "queryoverwritedialog.h" +#include "questionboxmemory.h" +#include "report.h" +#include "selectiondialog.h" +#include "settings.h" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#endif + +#include +#include + +#include "archivefiletree.h" + +using namespace MOBase; +using namespace MOShared; + +namespace +{ +QString storeMetaPath(const QString& value) +{ + if (value.isEmpty()) { + return value; + } + + if (MOBase::isWindowsDrivePath(value) || QDir::isAbsolutePath(value)) { + return MOBase::normalizePathForWine(value); + } + + return QDir::fromNativeSeparators(value); +} +} // namespace + +InstallationResult::InstallationResult(IPluginInstaller::EInstallResult result) + : m_result(result), m_name(), m_iniTweaks(false), m_backup(false), m_merged(false), + m_replaced(false) +{} + +template +static T resolveFunction(QLibrary& lib, const char* name) +{ + T temp = reinterpret_cast(lib.resolve(name)); + if (temp == nullptr) { + throw std::runtime_error(QObject::tr("invalid 7-zip32.dll: %1") + .arg(lib.errorString()) + .toLatin1() + .constData()); + } + return temp; +} + +InstallationManager::InstallationManager() : m_ParentWidget(nullptr), m_IsRunning(false) +{ + m_ArchiveHandler = CreateArchive(); + if (!m_ArchiveHandler->isValid()) { + throw MyException(getErrorString(m_ArchiveHandler->getLastError())); + } + m_ArchiveHandler->setLogCallback([](auto level, auto const& message) { + using LogLevel = Archive::LogLevel; + switch (level) { + case LogLevel::Debug: + log::debug("{}", message); + break; + case LogLevel::Info: + log::info("{}", message); + break; + case LogLevel::Warning: + log::warn("{}", message); + break; + case LogLevel::Error: + log::error("{}", message); + break; + } + }); + + // Connect the query password slot - This is the only way I found to be able to query + // user from a separate thread. We use a BlockingQueuedConnection so that calling + // passwordRequested() will block until the end of the slot. + connect(this, &InstallationManager::passwordRequested, this, + &InstallationManager::queryPassword, Qt::BlockingQueuedConnection); +} + +InstallationManager::~InstallationManager() {} + +void InstallationManager::setParentWidget(QWidget* widget) +{ + m_ParentWidget = widget; +} + +void InstallationManager::setPluginContainer(const PluginContainer* pluginContainer) +{ + m_PluginContainer = pluginContainer; +} + +void InstallationManager::queryPassword() +{ + m_Password = QInputDialog::getText(m_ParentWidget, tr("Password required"), + tr("Password"), QLineEdit::Password); +} + +bool InstallationManager::extractFiles(QString extractPath, QString title, + bool showFilenames, bool silent) +{ + TimeThis tt("InstallationManager::extractFiles"); + + // Callback for errors: + QString errorMessage; + auto errorCallback = [&errorMessage, this](std::wstring const& message) { + m_ArchiveHandler->cancel(); + errorMessage = QString::fromStdWString(message); + }; + + // The future that will hold the result: + QFuture future; + + if (silent) { + future = QtConcurrent::run([&]() -> bool { + return m_ArchiveHandler->extract(extractPath.toStdWString(), nullptr, nullptr, + errorCallback); + }); + future.waitForFinished(); + } else { + QProgressDialog* installationProgress = new QProgressDialog(m_ParentWidget); + ON_BLOCK_EXIT([=]() { + installationProgress->cancel(); + installationProgress->hide(); + installationProgress->deleteLater(); + }); + installationProgress->setWindowFlags(installationProgress->windowFlags() & + (~Qt::WindowContextHelpButtonHint)); + if (!title.isEmpty()) { + installationProgress->setWindowTitle(title); + } + installationProgress->setWindowModality(Qt::WindowModal); + installationProgress->setFixedSize(600, 100); + + // Turn off auto-reset otherwize the progress dialog is reset before the end. This + // is kind of annoying because updateProgress consider percentage of progression + // through the archive (pack), while we are waiting for extracting archive entries, + // so the percentage of in updateProgress is not really related to the percentage of + // files extracted... + installationProgress->setAutoReset(false); + + // Note: Using a loop with a progressUpdate() that only wake-up the loop. The + // event-loop will be used in a loop and not via exec() because connecting to + // QProgressDialog::setValue and using .exec() creates huge recursion that leads to + // stack-overflow. See https://bugreports.qt.io/browse/QTBUG-10561 + QEventLoop loop; + connect(this, &InstallationManager::progressUpdate, &loop, &QEventLoop::wakeUp, + Qt::QueuedConnection); + + // Cancelling progress only cancel the extraction, we do not force exiting the + // event-loop: + connect(installationProgress, &QProgressDialog::canceled, [this]() { + m_ArchiveHandler->cancel(); + }); + + std::mutex mutex; + int currentProgress = 0; + QString currentFileName; + + // The callbacks: + auto progressCallback = [this, ¤tProgress, &mutex]( + auto progressType, uint64_t current, uint64_t total) { + if (progressType == Archive::ProgressType::EXTRACTION) { + { + std::scoped_lock guard(mutex); + currentProgress = static_cast(100 * current / total); + } + emit progressUpdate(); + } + }; + Archive::FileChangeCallback fileChangeCallback = + [this, ¤tFileName, &mutex](auto changeType, std::wstring const& file) { + if (changeType == Archive::FileChangeType::EXTRACTION_START) { + { + std::scoped_lock guard(mutex); + currentFileName = QString::fromStdWString(file); + } + emit progressUpdate(); + } + }; + + // unpack only the files we need for the installer + QFutureWatcher futureWatcher; + connect(&futureWatcher, &QFutureWatcher::finished, &loop, &QEventLoop::wakeUp, + Qt::QueuedConnection); + futureWatcher.setFuture(QtConcurrent::run([&]() -> bool { + return m_ArchiveHandler->extract(extractPath.toStdWString(), progressCallback, + showFilenames ? fileChangeCallback : nullptr, + errorCallback); + })); + + installationProgress->setModal(true); + installationProgress->show(); + + while (!futureWatcher.isFinished()) { + loop.processEvents(QEventLoop::AllEvents | QEventLoop::WaitForMoreEvents); + std::scoped_lock guard(mutex); + if (currentProgress != installationProgress->value()) { + installationProgress->setValue(currentProgress); + } + if (currentFileName != installationProgress->labelText()) { + installationProgress->setLabelText(currentFileName); + } + } + + installationProgress->hide(); + + future = futureWatcher.future(); + } + + // Check the result: + if (!future.result()) { + if (m_ArchiveHandler->getLastError() == Archive::Error::ERROR_EXTRACT_CANCELLED) { + if (!errorMessage.isEmpty()) { + throw MyException(tr("Extraction failed: %1").arg(errorMessage)); + } else { + return false; + } + } else { + throw MyException(tr("Extraction failed: %1") + .arg(static_cast(m_ArchiveHandler->getLastError()))); + } + } + + return true; +} + +QString InstallationManager::extractFile(std::shared_ptr entry, + bool silent) +{ + QStringList result = this->extractFiles({entry}, silent); + return result.isEmpty() ? QString() : result[0]; +} + +QStringList InstallationManager::extractFiles( + std::vector> const& entries, bool silent) +{ + // Remove the directory since mapToArchive would add them: + std::vector> files; + std::copy_if(entries.begin(), entries.end(), std::back_inserter(files), + [](auto const& entry) { + return entry->isFile(); + }); + + // Update the archive: + ArchiveFileTree::mapToArchive(*m_ArchiveHandler, files); + + // Retrieve the file path: + QStringList result; + + for (auto& entry : files) { + auto path = entry->path(); + result.append(QDir::tempPath().append("/").append(path)); + m_TempFilesToDelete.insert(path); + } + + if (!extractFiles(QDir::tempPath(), tr("Extracting files"), false, silent)) { + return QStringList(); + } + + return result; +} + +QString +InstallationManager::createFile(std::shared_ptr entry) +{ + // Use QTemporaryFile to create the temporary file with the given template: + QTemporaryFile tempFile( + QDir::cleanPath(QDir::tempPath() + QDir::separator() + "mo2-install")); + + // Turn-off autoRemove otherwise the file is deleted when destructor is called: + tempFile.setAutoRemove(false); + + // Open/Close the file so that installer can use it properly: + if (!tempFile.open()) { + return QString(); + } + tempFile.close(); + + // fileName() returns the full path since we provide a full path in the constructor: + const QString absPath = tempFile.fileName(); + + m_CreatedFiles[entry] = absPath; + m_TempFilesToDelete.insert(QDir::temp().relativeFilePath(absPath)); + + // Returns the path with native separators: + return QDir::toNativeSeparators(absPath); +} + +void InstallationManager::cleanCreatedFiles( + std::shared_ptr fileTree) +{ + // We simply have to check if all the entries have fileTree as a parent: + for (auto it = std::begin(m_CreatedFiles); it != std::end(m_CreatedFiles);) { + + // Find the parent - Could this be in FileTreeEntry? + bool found = false; + { + auto parent = it->first->parent(); + while (parent && !found) { + if (parent == fileTree) { + found = true; + } else { + parent = parent->parent(); + } + } + } + + // If the parent was not found, we remove the entry, otherwize we move to the next + // one: + if (!found) { + it = m_CreatedFiles.erase(it); + } else { + ++it; + } + } +} + +IPluginInstaller::EInstallResult +InstallationManager::installArchive(GuessedValue& modName, + const QString& archiveName, int modId) +{ + // in earlier versions the modName was copied here and the copy passed to install. I + // don't know why I did this and it causes a problem if this is called by the bundle + // installer and the bundled installer adds additional names that then end up being + // used, because the caller will then not have the right name. + return install(archiveName, modName, modId).result(); +} + +QString InstallationManager::generateBackupName(const QString& directoryName) const +{ + QString backupName = directoryName + "_backup"; + if (QDir(backupName).exists()) { + int idx = 2; + QString temp = backupName + QString::number(idx); + while (QDir(temp).exists()) { + ++idx; + temp = backupName + QString::number(idx); + } + backupName = temp; + } + return backupName; +} + +InstallationResult InstallationManager::testOverwrite(GuessedValue& modName) +{ + QString targetDirectory = + QDir::fromNativeSeparators(m_ModsDirectory + "/" + modName); + + // this is only returned on success + InstallationResult result{IPluginInstaller::RESULT_SUCCESS}; + + while (QDir(targetDirectory).exists()) { + Settings& settings(Settings::instance()); + + const bool backup = settings.keepBackupOnInstall(); + QueryOverwriteDialog overwriteDialog(m_ParentWidget, + backup ? QueryOverwriteDialog::BACKUP_YES + : QueryOverwriteDialog::BACKUP_NO); + + if (overwriteDialog.exec()) { + settings.setKeepBackupOnInstall(overwriteDialog.backup()); + + if (overwriteDialog.backup()) { + QString backupDirectory = generateBackupName(targetDirectory); + if (!copyDir(targetDirectory, backupDirectory, false)) { + reportError(tr("Failed to create backup")); + return {IPluginInstaller::RESULT_FAILED}; + } + } + + result.m_merged = overwriteDialog.action() == QueryOverwriteDialog::ACT_MERGE; + result.m_replaced = overwriteDialog.action() == QueryOverwriteDialog::ACT_REPLACE; + result.m_backup = overwriteDialog.backup(); + + if (overwriteDialog.action() == QueryOverwriteDialog::ACT_RENAME) { + bool ok = false; + QString name = QInputDialog::getText(m_ParentWidget, tr("Mod Name"), tr("Name"), + QLineEdit::Normal, modName, &ok); + if (ok && !name.isEmpty()) { + modName.update(name, GUESS_USER); + if (!ensureValidModName(modName)) { + return {IPluginInstaller::RESULT_FAILED}; + } + targetDirectory = QDir::fromNativeSeparators(m_ModsDirectory) + "/" + modName; + } + } else if (overwriteDialog.action() == QueryOverwriteDialog::ACT_REPLACE) { + unsigned int idx = ModInfo::getIndex(modName); + if (idx != UINT_MAX) { + auto modInfo = ModInfo::getByIndex(idx); + // mark the old install file as uninstalled + emit modReplaced(modInfo->installationFile()); + } + // save original settings like categories. Because it makes sense + QString metaFilename = targetDirectory + "/meta.ini"; + QFile settingsFile(metaFilename); + QByteArray originalSettings; + if (settingsFile.open(QIODevice::ReadOnly)) { + originalSettings = settingsFile.readAll(); + settingsFile.close(); + } + + // remove the directory with all content, then recreate it empty + shellDelete(QStringList(targetDirectory)); + if (!QDir().mkdir(targetDirectory)) { + // windows may keep the directory around for a moment, preventing its + // re-creation. Not sure if this still happens with shellDelete +#ifdef _WIN32 + Sleep(100); +#else + QThread::msleep(100); +#endif + QDir().mkdir(targetDirectory); + } + // restore the saved settings + if (settingsFile.open(QIODevice::WriteOnly)) { + settingsFile.write(originalSettings); + settingsFile.close(); + } else { + log::error("failed to restore original settings: {}", metaFilename); + } + return result; + } else if (overwriteDialog.action() == QueryOverwriteDialog::ACT_MERGE) { + return result; + } else /* if (overwriteDialog.action() == QueryOverwriteDialog::ACT_NONE) */ + { + return {IPluginInstaller::RESULT_CANCELED}; + } + } else { + return {IPluginInstaller::RESULT_CANCELED}; + } + } + + QDir().mkdir(targetDirectory); + + return result; +} + +bool InstallationManager::ensureValidModName(GuessedValue& name) const +{ + while (name->isEmpty()) { + bool ok; + name.update( + QInputDialog::getText( + m_ParentWidget, tr("Invalid name"), + tr("The name you entered is invalid, please enter a different one."), + QLineEdit::Normal, "", &ok), + GUESS_USER); + if (!ok) { + return false; + } + } + return true; +} + +InstallationResult InstallationManager::doInstall(GuessedValue& modName, + QString gameName, int modID, + const QString& version, + const QString& newestVersion, + int categoryID, int fileCategoryID, + const QString& repository) +{ + if (!ensureValidModName(modName)) { + return {IPluginInstaller::RESULT_FAILED}; + } + + bool merge = false; + // determine target directory + InstallationResult result = testOverwrite(modName); + if (!result) { + return result; + } + + result.m_name = modName; + + QString targetDirectory = QDir(m_ModsDirectory + "/" + modName).canonicalPath(); + QString targetDirectoryNative = QDir::toNativeSeparators(targetDirectory); + + log::debug("installing to \"{}\"", targetDirectoryNative); + if (!extractFiles(targetDirectory, "", true, false)) { + return {IPluginInstaller::RESULT_CANCELED}; + } + + // Copy the created files: + for (auto& p : m_CreatedFiles) { + QString destPath = + QDir::cleanPath(targetDirectory + QDir::separator() + p.first->path()); + log::debug("Moving {} to {}.", p.second, destPath); + + // We need to remove the path if it exists: + if (QFile::exists(destPath)) { + QFile::remove(destPath); + } + + QDir dir = QFileInfo(destPath).absoluteDir(); + if (!dir.exists()) { + dir.mkpath("."); + } + + QFile::copy(p.second, destPath); + } + + QSettings settingsFile(targetDirectory + "/meta.ini", QSettings::IniFormat); + + // overwrite settings only if they are actually are available or haven't been set + // before + if ((gameName != "") || !settingsFile.contains("gameName")) { + settingsFile.setValue("gameName", gameName); + } + if ((modID != 0) || !settingsFile.contains("modid")) { + settingsFile.setValue("modid", modID); + } + if (!settingsFile.contains("version") || + (!version.isEmpty() && + (!merge || (VersionInfo(version) >= + VersionInfo(settingsFile.value("version").toString()))))) { + settingsFile.setValue("version", version); + } + if (!newestVersion.isEmpty() || !settingsFile.contains("newestVersion")) { + settingsFile.setValue("newestVersion", newestVersion); + } + // issue #51 used to overwrite the manually set categories + if (!settingsFile.contains("category")) { + settingsFile.setValue("category", QString::number(categoryID)); + } + settingsFile.setValue("nexusFileStatus", fileCategoryID); + settingsFile.setValue("installationFile", storeMetaPath(m_CurrentFile)); + settingsFile.setValue("repository", repository); + + if (!merge) { + // this does not clear the list we have in memory but the mod is going to have to be + // re-read anyway btw.: installedFiles were written with beginWriteArray but we can + // still clear it with beginGroup. nice + settingsFile.beginGroup("installedFiles"); + settingsFile.remove(""); + settingsFile.endGroup(); + } + + return result; +} + +bool InstallationManager::wasCancelled() const +{ + return m_ArchiveHandler->getLastError() == Archive::Error::ERROR_EXTRACT_CANCELLED; +} + +bool InstallationManager::isRunning() const +{ + return m_IsRunning; +} + +void InstallationManager::postInstallCleanup() +{ + // Clear the list of created files: + m_CreatedFiles.clear(); + + // Close the archive: + m_ArchiveHandler->close(); + + // directories we may want to remove. sorted from longest to shortest to ensure we + // remove subdirectories first. + auto longestFirst = [](const QString& LHS, const QString& RHS) -> bool { + if (LHS.size() != RHS.size()) + return LHS.size() > RHS.size(); + else + return LHS < RHS; + }; + + std::set> + directoriesToRemove(longestFirst); + + // clean up temp files + // TODO: this doesn't yet remove directories. Also, the files may be left there if + // this point isn't reached + for (const QString& tempFile : m_TempFilesToDelete) { + QFileInfo fileInfo(QDir::tempPath() + "/" + tempFile); + if (fileInfo.exists()) { + if (!fileInfo.isReadable() || !fileInfo.isWritable()) { + QFile::setPermissions(fileInfo.absoluteFilePath(), + QFile::ReadOther | QFile::WriteOther); + } + if (!QFile::remove(fileInfo.absoluteFilePath())) { + log::warn("Unable to delete {}", fileInfo.absoluteFilePath()); + } + } + directoriesToRemove.insert(fileInfo.absolutePath()); + } + + m_TempFilesToDelete.clear(); + + // try to delete each directory we had temporary files in. the call fails for + // non-empty directories which is ok + for (const QString& dir : directoriesToRemove) { + QDir().rmdir(dir); + } +} + +InstallationResult InstallationManager::install(const QString& fileName, + GuessedValue& modName, + int modID) +{ + m_IsRunning = true; + ON_BLOCK_EXIT([this]() { + m_IsRunning = false; + }); + + QFileInfo fileInfo(fileName); + if (!getSupportedExtensions().contains(fileInfo.suffix(), Qt::CaseInsensitive)) { + reportError(tr("File format \"%1\" not supported").arg(fileInfo.suffix())); + return InstallationResult(IPluginInstaller::RESULT_FAILED); + } + + modName.setFilter(&fixDirectoryName); + + modName.update(QFileInfo(fileName).completeBaseName(), GUESS_FALLBACK); + + // read out meta information from the download if available + QString gameName = ""; + QString version = ""; + QString newestVersion = ""; + int category = 0; + int categoryID = 0; + int fileCategoryID = 1; + QString repository = "Nexus"; + + QString metaName = fileName + ".meta"; + if (QFile(metaName).exists()) { + QSettings metaFile(metaName, QSettings::IniFormat); + gameName = metaFile.value("gameName", "").toString(); + modID = metaFile.value("modID", 0).toInt(); + QTextDocument doc; + doc.setHtml(metaFile.value("name", "").toString()); + modName.update(doc.toPlainText(), GUESS_FALLBACK); + modName.update(metaFile.value("modName", "").toString(), GUESS_META); + + version = metaFile.value("version", "").toString(); + newestVersion = metaFile.value("newestVersion", "").toString(); + category = metaFile.value("category", 0).toInt(); + unsigned int categoryIndex = CategoryFactory::instance().resolveNexusID(category); + if (category != 0 && categoryIndex == 0U && + Settings::instance().nexus().categoryMappings()) { + QMessageBox nexusQuery; + nexusQuery.setWindowTitle(tr("No category found")); + nexusQuery.setText(tr( + "This Nexus category has not yet been mapped. Do you wish to proceed without " + "setting a category, proceed and disable automatic Nexus mappings, or stop " + "and configure your category mappings?")); + QPushButton* proceedButton = + nexusQuery.addButton(tr("&Proceed"), QMessageBox::YesRole); + QPushButton* disableButton = + nexusQuery.addButton(tr("&Disable"), QMessageBox::AcceptRole); + QPushButton* stopButton = + nexusQuery.addButton(tr("&Stop && Configure"), QMessageBox::DestructiveRole); + nexusQuery.exec(); + if (nexusQuery.clickedButton() == disableButton) { + Settings::instance().nexus().setCategoryMappings(false); + } else if (nexusQuery.clickedButton() == stopButton) { + return MOBase::IPluginInstaller::RESULT_CATEGORYREQUESTED; + } + } else { + categoryID = CategoryFactory::instance().getCategoryID(categoryIndex); + } + repository = metaFile.value("repository", "").toString(); + fileCategoryID = metaFile.value("fileCategory", 1).toInt(); + } + + if (version.isEmpty()) { + QDateTime lastMod = fileInfo.lastModified(); + version = "d" + lastMod.toString("yyyy.M.d"); + } + + { // guess the mod name and mod if from the file name if there was no meta + // information + QString guessedModName; + int guessedModID = modID; + NexusInterface::interpretNexusFileName(QFileInfo(fileName).fileName(), + guessedModName, guessedModID, false); + if ((modID == 0) && (guessedModID != -1)) { + modID = guessedModID; + } else if (modID != guessedModID) { + log::debug("passed mod id: {}, guessed id: {}", modID, guessedModID); + } + + modName.update(guessedModName, GUESS_GOOD); + } + + m_CurrentFile = fileInfo.absoluteFilePath(); + if (fileInfo.dir() == QDir(m_DownloadsDirectory)) { + m_CurrentFile = fileInfo.fileName(); + } + log::debug("using mod name \"{}\" (id {}) -> {}", QString(modName), modID, + m_CurrentFile); + + // If there's an archive already open, close it. This happens with the bundle + // installer when it uncompresses a split archive, then finds it has a real archive + // to deal with. + m_ArchiveHandler->close(); + + // open the archive and construct the directory tree the installers work on + + bool archiveOpen = + m_ArchiveHandler->open(fileName.toStdWString(), [this]() -> std::wstring { + m_Password = QString(); + + // Note: If we are not in the Qt event thread, we cannot use queryPassword() + // directly, so we emit passwordRequested() that is connected to + // queryPassword(). The connection is made using Qt::BlockingQueuedConnection, + // so the emit "call" is actually blocking. We cannot use emit if we are in the + // even thread, otherwize we have a deadlock. + if (QThread::currentThread() != QApplication::instance()->thread()) { + emit passwordRequested(); + } else { + queryPassword(); + } + return m_Password.toStdWString(); + }); + if (!archiveOpen) { + log::debug("integrated archiver can't open {}: {} ({})", fileName, + getErrorString(m_ArchiveHandler->getLastError()), + m_ArchiveHandler->getLastError()); + } + ON_BLOCK_EXIT(std::bind(&InstallationManager::postInstallCleanup, this)); + + std::shared_ptr filesTree = + archiveOpen ? ArchiveFileTree::makeTree(*m_ArchiveHandler) : nullptr; + + auto installers = m_PluginContainer->plugins(); + + std::sort(installers.begin(), installers.end(), + [](IPluginInstaller* lhs, IPluginInstaller* rhs) { + return lhs->priority() > rhs->priority(); + }); + + InstallationResult installResult(IPluginInstaller::RESULT_NOTATTEMPTED); + + for (IPluginInstaller* installer : installers) { + // don't use inactive installers (installer can't be null here but vc static code + // analysis thinks it could) + if ((installer == nullptr) || !m_PluginContainer->isEnabled(installer)) { + continue; + } + + // try only manual installers if that was requested + if (installResult.result() == IPluginInstaller::RESULT_MANUALREQUESTED) { + if (!installer->isManualInstaller()) { + continue; + } + } else if (installResult.result() != IPluginInstaller::RESULT_NOTATTEMPTED) { + break; + } + + try { + { // simple case + IPluginInstallerSimple* installerSimple = + dynamic_cast(installer); + if ((installerSimple != nullptr) && (filesTree != nullptr) && + (installer->isArchiveSupported(filesTree))) { + installResult.m_result = + installerSimple->install(modName, filesTree, version, modID); + if (installResult) { + + // Downcast to an actual ArchiveFileTree and map to the archive. Test if + // the tree is still an ArchiveFileTree, otherwize it means the installer + // did some bad stuff. + ArchiveFileTree* p = dynamic_cast(filesTree.get()); + if (p == nullptr) { + throw IncompatibilityException( + tr("Invalid file tree returned by plugin.")); + } + + // Detach the file tree (this ensure the parent is null and call to path() + // stops at this root): + p->detach(); + + p->mapToArchive(*m_ArchiveHandler); + + // Clean the created files: + cleanCreatedFiles(filesTree); + + // the simple installer only prepares the installation, the rest + // works the same for all installers + installResult = doInstall(modName, gameName, modID, version, newestVersion, + categoryID, fileCategoryID, repository); + } + } + } + + if (installResult.result() != IPluginInstaller::RESULT_CANCELED) { // custom case + IPluginInstallerCustom* installerCustom = + dynamic_cast(installer); + if ((installerCustom != nullptr) && + (((filesTree != nullptr) && installer->isArchiveSupported(filesTree)) || + ((filesTree == nullptr) && + installerCustom->isArchiveSupported(fileName)))) { + std::set installerExt = installerCustom->supportedExtensions(); + if (installerExt.find(fileInfo.suffix()) != installerExt.end()) { + installResult.m_result = + installerCustom->install(modName, gameName, fileName, version, modID); + unsigned int idx = ModInfo::getIndex(modName); + if (idx != UINT_MAX) { + ModInfo::Ptr info = ModInfo::getByIndex(idx); + info->setRepository(repository); + } + } + } + } + } catch (const IncompatibilityException& e) { + log::error("plugin \"{}\" incompatible: {}", installer->name(), e.what()); + } + + // act upon the installation result. at this point the files have already been + // extracted to the correct location + switch (installResult.result()) { + case IPluginInstaller::RESULT_FAILED: { + QMessageBox::information(qApp->activeWindow(), tr("Installation failed"), + tr("Something went wrong while installing this mod."), + QMessageBox::Ok); + return installResult; + } break; + case IPluginInstaller::RESULT_SUCCESS: + case IPluginInstaller::RESULT_SUCCESSCANCEL: { + if (filesTree != nullptr) { + auto iniTweakEntry = filesTree->find("INI Tweaks", FileTreeEntry::DIRECTORY); + installResult.m_iniTweaks = + iniTweakEntry != nullptr && !iniTweakEntry->astree()->empty(); + } + installResult.m_result = IPluginInstaller::RESULT_SUCCESS; + return installResult; + } break; + case IPluginInstaller::RESULT_NOTATTEMPTED: + case IPluginInstaller::RESULT_MANUALREQUESTED: { + continue; + } + default: + return installResult; + } + } + if (installResult.result() == IPluginInstaller::RESULT_NOTATTEMPTED) { + reportError( + tr("None of the available installer plugins were able to handle that archive.\n" + "This is likely due to a corrupted or incompatible download or unrecognized " + "archive format.")); + } + + return installResult; +} + +QString InstallationManager::getErrorString(Archive::Error errorCode) +{ + switch (errorCode) { + case Archive::Error::ERROR_NONE: { + return tr("no error"); + } break; + case Archive::Error::ERROR_LIBRARY_NOT_FOUND: { + return tr("7z.dll not found"); + } break; + case Archive::Error::ERROR_LIBRARY_INVALID: { + return tr("7z.dll isn't valid"); + } break; + case Archive::Error::ERROR_ARCHIVE_NOT_FOUND: { + return tr("archive not found"); + } break; + case Archive::Error::ERROR_FAILED_TO_OPEN_ARCHIVE: { + return tr("failed to open archive"); + } break; + case Archive::Error::ERROR_INVALID_ARCHIVE_FORMAT: { + return tr("unsupported archive type"); + } break; + case Archive::Error::ERROR_LIBRARY_ERROR: { + return tr("internal library error"); + } break; + case Archive::Error::ERROR_ARCHIVE_INVALID: { + return tr("archive invalid"); + } break; + default: { + // this probably means the archiver.dll is newer than this + return tr("unknown archive error"); + } break; + } +} + +QStringList InstallationManager::getSupportedExtensions() const +{ + std::set supportedExtensions( + {"zip", "rar", "7z", "fomod", "001"}); + for (auto* installer : m_PluginContainer->plugins()) { + if (m_PluginContainer->isEnabled(installer)) { + if (auto* installerCustom = dynamic_cast(installer)) { + std::set extensions = installerCustom->supportedExtensions(); + supportedExtensions.insert(extensions.begin(), extensions.end()); + } + } + } + return QStringList(supportedExtensions.begin(), supportedExtensions.end()); +} + +void InstallationManager::notifyInstallationStart(QString const& archive, + bool reinstallation, + ModInfo::Ptr currentMod) +{ + auto& installers = m_PluginContainer->plugins(); + for (auto* installer : installers) { + if (m_PluginContainer->isEnabled(installer)) { + installer->onInstallationStart(archive, reinstallation, currentMod.get()); + } + } +} + +void InstallationManager::notifyInstallationEnd(const InstallationResult& result, + ModInfo::Ptr newMod) +{ + auto& installers = m_PluginContainer->plugins(); + for (auto* installer : installers) { + if (m_PluginContainer->isEnabled(installer)) { + installer->onInstallationEnd(result.result(), newMod.get()); + } + } +} diff --git a/src/src/installationmanager.h b/src/src/installationmanager.h new file mode 100644 index 0000000..462baa6 --- /dev/null +++ b/src/src/installationmanager.h @@ -0,0 +1,357 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef INSTALLATIONMANAGER_H +#define INSTALLATIONMANAGER_H + +#include +#include +#include +#include +#include +#include + +#include +#include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#endif +#include +#include + +#include "modinfo.h" +#include "plugincontainer.h" + +// contains installation result from the manager, internal class +// for MO2 that is not forwarded to plugin +class InstallationResult +{ +public: + // result status of the installation + // + auto result() const { return m_result; } + + // information about the installation, only valid for successful + // installation + // + QString name() const { return m_name; } + bool backupCreated() const { return m_backup; } + bool merged() const { return m_merged; } + bool replaced() const { return m_replaced; } + bool hasIniTweaks() const { return m_iniTweaks; } + bool mergedOrReplaced() const { return merged() || replaced(); } + + // check if the installation was a success + // + explicit operator bool() const + { + return result() == MOBase::IPluginInstaller::EInstallResult::RESULT_SUCCESS; + } + +private: + friend class InstallationManager; + + // create a failed result + InstallationResult(MOBase::IPluginInstaller::EInstallResult result = + MOBase::IPluginInstaller::EInstallResult::RESULT_FAILED); + + MOBase::IPluginInstaller::EInstallResult m_result; + + QString m_name; + + bool m_iniTweaks; + bool m_backup; + bool m_merged; + bool m_replaced; +}; + +class InstallationManager : public QObject, public MOBase::IInstallationManager +{ + Q_OBJECT + +public: + /** + * @brief constructor + * + * @param parent parent object. + **/ + explicit InstallationManager(); + + virtual ~InstallationManager(); + + void setParentWidget(QWidget* widget); + + /** + * @brief Notify all installer plugins that an installation is about to start. + * + * @param archive Path to the archive that is going to be installed. + * @param reinstallation True if this is a reinstallation, false otherwise. + * @param currentMod The installed mod corresponding to the archive being installed, + * or a null if there is no such mod. + */ + void notifyInstallationStart(QString const& archive, bool reinstallation, + ModInfo::Ptr currentMod); + + /** + * @brief notify all installer plugins that an installation has ended. + * + * @param result The result of the installation process. + * @param currentMod The newly install mod, if result is SUCCESS, a null pointer + * otherwise. + */ + void notifyInstallationEnd(const InstallationResult& result, ModInfo::Ptr newMod); + + /** + * @brief update the directory where mods are to be installed + * @param modsDirectory the mod directory + * @note this is called a lot, probably redundantly + */ + void setModsDirectory(const QString& modsDirectory) + { + m_ModsDirectory = modsDirectory; + } + + /** + * + */ + void setPluginContainer(const PluginContainer* pluginContainer); + + /** + * @brief update the directory where downloads are stored + * @param downloadDirectory the download directory + */ + void setDownloadDirectory(const QString& downloadDirectory) + { + m_DownloadsDirectory = downloadDirectory; + } + + /** + * @brief install a mod from an archive + * + * @param fileName absolute file name of the archive to install + * @param modName suggested name of the mod. If this is empty (the default), a name + *will be guessed based on the filename. The user will always have a chance to rename + *the mod + * @return true if the archive was installed, false if installation failed or was + *refused + * @exception std::exception an exception may be thrown if the archive can't be opened + *(maybe the format is invalid or the file is damaged) + **/ + InstallationResult install(const QString& fileName, + MOBase::GuessedValue& modName, int modID = 0); + + /** + * @return true if the installation was canceled + **/ + bool wasCancelled() const; + + /** + * @return true if an installation is currently in progress + **/ + bool isRunning() const; + + /** + * @brief retrieve a string describing the specified error code + * + * @param errorCode an error code as returned by the archiving function + * @return the error string + * @todo This function doesn't belong here, it is only public because the SelfUpdater + *class also uses "Archive" to get to the package.txt file + **/ + static QString getErrorString(Archive::Error errorCode); + + /** + * @return the extensions of archives supported by this installation manager. + */ + QStringList getSupportedExtensions() const override; + + /** + * @brief Extract the specified file from the currently opened archive to a temporary + * location. + * + * This method cannot be used to extract directory. + * + * @param entry Entry corresponding to the file to extract. + * @param silent If true, the dialog showing extraction progress will not be shown. + * + * @return the absolute path to the temporary file. + * + * @note The call will fail with an exception if no archive is open (plugins deriving + * from IPluginInstallerSimple can rely on that, custom installers should not). + * @note The temporary file is automatically cleaned up after the installation. + * @note This call can be very slow if the archive is large and "solid". + */ + QString extractFile(std::shared_ptr entry, + bool silent = false) override; + + /** + * @brief Extract the specified files from the currently opened archive to a temporary + * location. + * + * This method cannot be used to extract directory. + * + * @param entres Entries corresponding to the files to extract. + * @param silent If true, the dialog showing extraction progress will not be shown. + * + * @return the list of absolute paths to the temporary files. + * + * @note The call will fail with an exception if no archive is open (plugins deriving + * from IPluginInstallerSimple can rely on that, custom installers should not). + * @note The temporary file is automatically cleaned up after the installation. + * @note This call can be very slow if the archive is large and "solid". + * + * The flatten argument is not present here while it is present in the deprecated + * QStringList version for multiple reasons: 1) it was never used, 2) it is kind of + * fishy because there is no way to know if a file is going to be overriden, 3) it is + * quite easy to flatten a IFileTree and thus to given a list of entries flattened + * (this was not possible with the QStringList version since these were based on the + * name of the file inside the archive). + */ + QStringList + extractFiles(std::vector> const& entries, + bool silent = false) override; + + /** + * @brief Create a new file on the disk corresponding to the given entry. + * + * This method can be used by installer that needs to create files that are not in the + * original archive. At the end of the installation, if there are entries in the final + * tree that were used to create files, the corresponding files will be moved to the + * mod folder. + * + * @param entry The entry for which a temporary file should be created. + * + * @return the path to the created file. + */ + QString createFile(std::shared_ptr entry) override; + + /** + * @brief Installs the given archive. + * + * @param modName Suggested name of the mod. + * @param archiveFile Path to the archive to install. + * @param modId ID of the mod, if available. + * + * @return the installation result. + */ + MOBase::IPluginInstaller::EInstallResult + installArchive(MOBase::GuessedValue& modName, const QString& archiveName, + int modId = 0) override; + + /** + * @param modName current possible names for the mod + * + * @return an installation result containing information from the user. + */ + InstallationResult testOverwrite(MOBase::GuessedValue& modName); + + QString generateBackupName(const QString& directoryName) const; + +private: + // actually perform the installation (write files to the disk, etc.), returns the + // installation result + // + InstallationResult doInstall(MOBase::GuessedValue& modName, QString gameName, + int modID, const QString& version, + const QString& newestVersion, int categoryID, + int fileCategoryID, const QString& repository); + + /** + * @brief Clean the list of created files by removing all entries that are not + * in the given tree. + * + * @param tree The parent tree. Usually the tree returned by the installer. + */ + void cleanCreatedFiles(std::shared_ptr fileTree); + + bool ensureValidModName(MOBase::GuessedValue& name) const; + + void postInstallCleanup(); + +private slots: + + /** + * @brief Query user for password and update the m_Password field. + */ + void queryPassword(); + +signals: + + /** + * @brief Emitted when a password is requested from the archive wrapper. + */ + void passwordRequested(); + + /** + * @brief Progress update from the extraction. + */ + void progressUpdate(); + + /** + * @brief An existing mod has been replaced with a newly installed one. + */ + void modReplaced(const QString fileName); + +private: + struct CaseInsensitive + { + bool operator()(const QString& LHS, const QString& RHS) const + { + return QString::compare(LHS, RHS, Qt::CaseInsensitive) < 0; + } + }; + + /** + * @brief Extract the files from the archived that are not disabled (that have + * output filenames associated with them) to the given path. + * + * @param extractPath Path (on the disk) were the extracted files should be put. + * + * This method is mainly a convenience method for the extractFiles() methods. + * + * @return true if the extraction was successful, false if the extraciton was + * cancelled. If an error occured, an exception is thrown. + */ + bool extractFiles(QString extractPath, QString title, bool showFilenames, + bool silent); + +private: + // The plugin container, mostly to check if installer are enabled or not. + const PluginContainer* m_PluginContainer; + + bool m_IsRunning; + + QWidget* m_ParentWidget; + + QString m_ModsDirectory; + QString m_DownloadsDirectory; + + // Archive management. + std::unique_ptr m_ArchiveHandler; + QString m_CurrentFile; + QString m_Password; + + // Map from entries in the tree that is used by the installer and absolute + // paths to temporary files. + std::map, QString> m_CreatedFiles; + std::set m_TempFilesToDelete; +}; + +#endif // INSTALLATIONMANAGER_H diff --git a/src/src/instancemanager.cpp b/src/src/instancemanager.cpp new file mode 100644 index 0000000..ba1d320 --- /dev/null +++ b/src/src/instancemanager.cpp @@ -0,0 +1,951 @@ +/* +Copyright (C) 2016 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "instancemanager.h" +#include "createinstancedialog.h" +#include "createinstancedialogpages.h" +#include "filesystemutilities.h" +#include "instancemanagerdialog.h" +#include "nexusinterface.h" +#include "plugincontainer.h" +#include "selectiondialog.h" +#include "settings.h" +#include "shared/appconfig.h" +#include "shared/util.h" +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; + +Instance::Instance(QString dir, bool portable, QString profileName) + : m_dir(std::move(dir)), m_portable(portable), m_plugin(nullptr), + m_profile(std::move(profileName)) +{ +#ifndef _WIN32 + // Ensure portable instances have a ModOrganizer.sh launcher script. + if (m_portable && !m_dir.isEmpty()) { + const QString launchPath = QDir(m_dir).filePath("ModOrganizer.sh"); + if (!QFileInfo::exists(launchPath)) { + const bool flatpak = QFileInfo::exists(QStringLiteral("/.flatpak-info")); + QFile launchFile(launchPath); + if (launchFile.open(QIODevice::WriteOnly | QIODevice::Text)) { + QTextStream out(&launchFile); + out << "#!/bin/bash\n"; + out << "# Auto-generated by fluorine-manager\n"; + out << "INSTANCE_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n"; + if (flatpak) { + out << "exec flatpak run com.fluorine.manager --instance " + "\"${INSTANCE_DIR}\" \"$@\"\n"; + } else { + out << "MO2_BIN=\"$(which ModOrganizer 2>/dev/null || echo " + "ModOrganizer)\"\n"; + out << "exec \"${MO2_BIN}\" --instance \"${INSTANCE_DIR}\" \"$@\"\n"; + } + launchFile.close(); + QFile::setPermissions( + launchPath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner | + QFileDevice::ExeOwner | QFileDevice::ReadGroup | + QFileDevice::ExeGroup | QFileDevice::ReadOther | + QFileDevice::ExeOther); + } + } + } +#endif +} + +QString Instance::displayName() const +{ + if (isPortable()) + return QObject::tr("Portable"); + else + return QDir(m_dir).dirName(); +} + +QString Instance::gameName() const +{ + return m_gameName; +} + +QString Instance::gameDirectory() const +{ + return m_gameDir; +} + +QString Instance::directory() const +{ + return m_dir; +} + +QString Instance::baseDirectory() const +{ + return m_baseDir; +} + +MOBase::IPluginGame* Instance::gamePlugin() const +{ + return m_plugin; +} + +QString Instance::profileName() const +{ + return m_profile; +} + +QString Instance::iniPath() const +{ + return InstanceManager::singleton().iniPath(m_dir); +} + +bool Instance::isPortable() const +{ + return m_portable; +} + +bool Instance::isActive() const +{ + auto& m = InstanceManager::singleton(); + + if (auto i = m.currentInstance()) { + if (m_portable) { + return i->isPortable(); + } else { + return (i->displayName() == displayName()); + } + } + + return false; +} + +bool Instance::readFromIni() +{ + Settings s(iniPath()); + + if (s.iniStatus() != QSettings::NoError) { + log::error("can't read ini {}", iniPath()); + return false; + } + + // game name and directory are from ini unless overridden by setGame() + if (m_gameName.isEmpty()) { + if (auto v = s.game().name()) + m_gameName = *v; + } + + if (m_gameDir.isEmpty()) { + if (auto v = s.game().directory()) + m_gameDir = *v; + } + + if (m_gameVariant.isEmpty()) { + if (auto v = s.game().edition()) { + m_gameVariant = *v; + } + } + + if (m_baseDir.isEmpty()) { + m_baseDir = s.paths().base(); + } + + // figuring out profile from ini if it's missing + getProfile(s); + + return true; +} + +Instance::SetupResults Instance::setup(PluginContainer& plugins) +{ + // read initial values from the ini + log::debug("reading instance ini from '{}'", iniPath()); + if (!readFromIni()) { + log::error("readFromIni() failed for '{}'", iniPath()); + return SetupResults::BadIni; + } + log::debug("ini loaded: game='{}', dir='{}', variant='{}'", + m_gameName, m_gameDir, m_gameVariant); + + // getting game plugin + log::debug("searching for matching game plugin..."); + const auto r = getGamePlugin(plugins); + if (r != SetupResults::Okay) { + log::error("getGamePlugin() failed with result {}", static_cast(r)); + return r; + } + log::debug("matched game plugin '{}'", m_plugin->gameName()); + + // error if the variant missing and required by the plugin + if (m_gameVariant.isEmpty() && m_plugin->gameVariants().size() > 1) { + return SetupResults::MissingVariant; + } else { + m_plugin->setGameVariant(m_gameVariant); + } + + // update the ini in case anything was missing + log::debug("updating ini"); + updateIni(); + + // the game directory may be different than what the plugin detected, the user + // can change it in the settings and might have multiple versions of the game + // installed + log::debug("setting game path to '{}'", m_gameDir); + m_plugin->setGamePath(m_gameDir); + + return SetupResults::Okay; +} + +void Instance::updateIni() +{ + Settings s(iniPath()); + + if (s.iniStatus() != QSettings::NoError) { + log::error("can't open ini {}", iniPath()); + return; + } + + // updating the settings since some of these values might have been missing + s.game().setName(m_gameName); + s.game().setDirectory(m_gameDir); + s.game().setSelectedProfileName(m_profile); + + if (!m_gameVariant.isEmpty()) { + // don't write a variant to the ini if the plugin doesn't require one + s.game().setEdition(m_gameVariant); + } +} + +void Instance::setGame(const QString& name, const QString& dir) +{ + m_gameName = name; + m_gameDir = dir; +} + +void Instance::setVariant(const QString& name) +{ + m_gameVariant = name; +} + +Instance::SetupResults Instance::getGamePlugin(PluginContainer& plugins) +{ + if (!m_gameName.isEmpty() && !m_gameDir.isEmpty()) { + // normal case: both the name and dir are in the ini + + // find the plugin by name + for (IPluginGame* game : plugins.plugins()) { + log::debug(" checking game plugin '{}'...", game->gameName()); + if (m_gameName.compare(game->gameName(), Qt::CaseInsensitive) == 0) { + // plugin found, check if the game directory is valid + log::debug(" name matched, calling looksValid('{}')...", m_gameDir); + if (!game->looksValid(m_gameDir)) { + // the directory from the ini is not valid anymore + log::warn("game plugin {} says dir {} from ini {} is not valid", + game->gameName(), m_gameDir, iniPath()); + + // note that some plugins return true for isInstalled() if a path + // is found in the registry, but without actually checking if it's + // valid + + log::debug(" calling isInstalled()..."); + if (game->isInstalled() && game->looksValid(game->gameDirectory())) { + // bad game directory but the plugin reports there's a valid one + // somewhere; take it instead + log::warn("game plugin {} found a game at {}, taking it", game->gameName(), + game->gameDirectory().absolutePath()); + + m_gameDir = game->gameDirectory().absolutePath(); + } else { + // game seems to be gone completely + log::warn("game plugin {} found no game installation at all", + game->gameName()); + return SetupResults::GameGone; + } + } + + m_plugin = game; + return SetupResults::Okay; + } + } + + log::warn("game plugin {} not found", m_gameName); + return SetupResults::PluginGone; + } else if (m_gameName.isEmpty() && !m_gameDir.isEmpty()) { + // the name is missing, but there's a directory; find a plugin that can + // handle it + + log::warn("game name is missing from ini {} but dir {} is available", iniPath(), + m_gameDir); + + for (IPluginGame* game : plugins.plugins()) { + log::info(" checking plugin '{}' with looksValid('{}')...", game->gameName(), m_gameDir); + if (game->looksValid(m_gameDir)) { + // take it + log::warn("found plugin {} that can use dir {}", game->gameName(), m_gameDir); + + m_plugin = game; + m_gameName = game->gameName(); + + return SetupResults::Okay; + } + } + + log::error("no plugins can use dir {}", m_gameDir); + return SetupResults::GameGone; + } else if (!m_gameName.isEmpty() && m_gameDir.isEmpty()) { + // dir is missing, find a plugin with the correct name and use the install + // dir it detected + + log::warn("game dir is missing from ini {} but name {} is available", iniPath(), + m_gameName); + + for (IPluginGame* game : plugins.plugins()) { + if (m_gameName.compare(game->gameName(), Qt::CaseInsensitive) == 0) { + // plugin found, use its detected installation dir + + if (game->isInstalled()) { + log::warn("found plugin {} that matches name in ini {}, using auto detected " + "game dir {}", + game->gameName(), iniPath(), game->gameDirectory().absolutePath()); + + m_plugin = game; + m_gameDir = game->gameDirectory().absolutePath(); + + return SetupResults::Okay; + } else { + log::warn("found plugin {} that matches name in ini {}, but no game install " + "detected by plugin", + game->gameName(), iniPath()); + + return SetupResults::GameGone; + } + } + } + + // plugin seems to be gone + log::error("no plugin matches name {}", m_gameName); + return SetupResults::PluginGone; + } else { + // can't do anything with these two missing + log::error("both game name and dir are missing from ini {}", iniPath()); + return SetupResults::IniMissingGame; + } +} + +void Instance::getProfile(const Settings& s) +{ + if (!m_profile.isEmpty()) { + // there's already a profile set up, probably an override + return; + } + + if (auto name = s.game().selectedProfileName()) { + // use last profile + m_profile = *name; + return; + } + + // profile missing from ini, use the default + m_profile = QString::fromStdWString(AppConfig::defaultProfileName()); + + log::warn("no profile found in ini {}, using default '{}'", iniPath(), m_profile); +} + +// returns a list of files and folders that must be deleted when deleting +// this instance +// +std::vector Instance::objectsForDeletion() const +{ + // native separators and ending slash + auto prettyDir = [](auto s) { + if (!s.endsWith("/") || !s.endsWith("\\")) { + s += "/"; + } + + return QDir::toNativeSeparators(s); + }; + + // native separators + auto prettyFile = [](auto s) { + return QDir::toNativeSeparators(s); + }; + + // lowercase, native separators and ending slash + auto canonicalDir = [](QString s) { + s = s.toLower(); + + if (!s.endsWith("/") || !s.endsWith("\\")) { + s += "/"; + } + + return QDir::toNativeSeparators(s); + }; + + // lower and native separators + auto canonicalFile = [](auto s) { + return QDir::toNativeSeparators(s.toLower()); + }; + + // whether the given directory is contained in the root + auto dirInRoot = [&](const QString& root, const QString& dir) { + return canonicalDir(dir).startsWith(canonicalDir(root)); + }; + + // whether the given file is contained in the root + auto fileInRoot = [&](const QString& root, const QString& file) { + return canonicalFile(file).startsWith(canonicalDir(root)); + }; + + Settings settings(iniPath()); + + if (settings.iniStatus() != QSettings::NoError) { + log::error("can't read ini {}", iniPath()); + return {}; + } + + const QString loc = directory(); + const QString base = settings.paths().base(); + + // directories that might contain the individual files and directories + // set in the path settings + std::vector roots; + + // a portable instance has its location in the installation directory, + // don't delete that + if (!isPortable()) { + if (QDir(loc).exists()) { + roots.push_back({loc, true}); + } + } + + // the base directory is the location directory by default, don't add it + // if it's the same + if (canonicalDir(base) != canonicalDir(loc)) { + if (QDir(base).exists()) { + roots.push_back({base, false}); + } + } + + // all the directories that are part of an instance; none of them are + // mandatory for deletion + const std::vector dirs = { + settings.paths().downloads(), + settings.paths().mods(), + settings.paths().cache(), + settings.paths().profiles(), + settings.paths().overwrite(), + QDir(m_dir).filePath(QString::fromStdWString(AppConfig::dumpsDir())), + QDir(m_dir).filePath(QString::fromStdWString(AppConfig::logPath())), + }; + + // all the files that are part of an instance + const std::vector files = { + {iniPath(), true}, // the ini file must be deleted + }; + + // this will contain the root directories, plus all the individual + // directories that are not inside these roots + std::vector cleanDirs; + + for (const auto& f : dirs) { + bool inRoots = false; + + for (const auto& root : roots) { + if (dirInRoot(root.path, f.path)) { + inRoots = true; + break; + } + } + + if (!inRoots) { + // not in roots, this is a path that was changed by the user; make + // sure it exists + if (QDir(f.path).exists()) { + cleanDirs.push_back({prettyDir(f.path), f.mandatoryDelete}); + } + } + } + + // prepending the roots + for (auto itor = roots.rbegin(); itor != roots.rend(); ++itor) { + cleanDirs.insert(cleanDirs.begin(), {prettyDir(itor->path), itor->mandatoryDelete}); + } + + // this will contain the individual files that are not inside the roots; + // not that this only contains the INI file for now, so most of this is + // useless + std::vector cleanFiles; + + for (const auto& f : files) { + bool inRoots = false; + + for (const auto& root : roots) { + if (fileInRoot(root.path, f.path)) { + inRoots = true; + break; + } + } + + if (!inRoots) { + // not in roots, this is a path that was changed by the user; make + // sure it exists + if (QFileInfo(f.path).exists()) { + cleanFiles.push_back({prettyFile(f.path), f.mandatoryDelete}); + } + } + } + + // contains all the directories and files to be deleted + std::vector all; + all.insert(all.end(), cleanDirs.begin(), cleanDirs.end()); + all.insert(all.end(), cleanFiles.begin(), cleanFiles.end()); + + // mandatory on top + std::stable_sort(all.begin(), all.end()); + + return all; +} + +InstanceManager::InstanceManager() +{ + GlobalSettings::updateRegistryKey(); +} + +InstanceManager& InstanceManager::singleton() +{ + static InstanceManager s_Instance; + return s_Instance; +} + +void InstanceManager::overrideInstance(const QString& instanceName) +{ + m_overrideInstanceName = instanceName; +} + +void InstanceManager::overrideProfile(const QString& profileName) +{ + m_overrideProfileName = profileName; +} + +void InstanceManager::clearOverrides() +{ + m_overrideInstanceName = {}; + m_overrideProfileName = {}; +} + +std::unique_ptr InstanceManager::currentInstance() const +{ + const QString profile = m_overrideProfileName ? *m_overrideProfileName : ""; + + const bool hasPortable = portableInstanceExists(); + + const QString name = m_overrideInstanceName ? *m_overrideInstanceName + : GlobalSettings::currentInstance(); + + if (!allowedToChangeInstance()) { + // force portable instance + return std::make_unique(portablePath(), true, profile); + } + + if (hasPortable && !m_overrideInstanceName.has_value()) { + // Prefer a colocated ModOrganizer.ini for portable bundles unless a + // specific instance was explicitly requested on the command line. + log::debug("portable instance detected at '{}', preferring it over stored global " + "instance '{}'", + portablePath(), name); + return std::make_unique(portablePath(), true, profile); + } + + if (name.isEmpty()) { + if (hasPortable) { + // use portable + return std::make_unique(portablePath(), true, profile); + } else { + // no instance set + return {}; + } + } + + // If the instance name is an absolute path to a directory containing + // ModOrganizer.ini, treat it as a portable instance at that path. + if (QDir::isAbsolutePath(name)) { + const QString iniPath = QDir(name).filePath("ModOrganizer.ini"); + if (QFileInfo::exists(iniPath)) { + return std::make_unique(name, true, profile); + } + } + + return std::make_unique(instancePath(name), false, profile); +} + +void InstanceManager::clearCurrentInstance() +{ + setCurrentInstance(""); + m_overrideInstanceName = {}; +} + +void InstanceManager::setCurrentInstance(const QString& name) +{ + GlobalSettings::setCurrentInstance(name); +} + +QString InstanceManager::instancePath(const QString& instanceName) const +{ + return QDir::fromNativeSeparators(globalInstancesRootPath() + "/" + instanceName); +} + +QString InstanceManager::globalInstancesRootPath() const +{ + return QDir::fromNativeSeparators( + QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation)); +} + +QString InstanceManager::iniPath(const QString& instanceDir) const +{ + return QDir(instanceDir).filePath(QString::fromStdWString(AppConfig::iniFileName())); +} + +std::vector InstanceManager::globalInstancePaths() const +{ + const QDir root(globalInstancesRootPath()); + const auto dirs = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + + std::vector list; + + for (auto&& d : dirs) { + const QFileInfo iniFile(QDir(root.filePath(d)), "ModOrganizer.ini"); + log::debug("Checking for INI at path '{}'", iniFile.absoluteFilePath()); + + if (iniFile.exists()) { + log::debug("Found INI at path '{}'", iniFile.absoluteFilePath()); + list.push_back(root.filePath(d)); + } + } + + return list; +} + +bool InstanceManager::hasAnyInstances() const +{ + return portableInstanceExists() || !globalInstancePaths().empty(); +} + +QString InstanceManager::portablePath() const +{ + return AppConfig::basePath(); +} + +bool InstanceManager::portableInstanceExists() const +{ + return QFile::exists(AppConfig::basePath() + "/" + + QString::fromStdWString(AppConfig::iniFileName())); +} + +bool InstanceManager::allowedToChangeInstance() const +{ + const auto lockFile = qApp->applicationDirPath() + "/" + + QString::fromStdWString(AppConfig::portableLockFileName()); + + return !QFile::exists(lockFile); +} + +MOBase::IPluginGame* +InstanceManager::gamePluginForDirectory(const QString& instanceDir, + PluginContainer& plugins) const +{ + return const_cast( + gamePluginForDirectory(instanceDir, const_cast(plugins))); +} + +const MOBase::IPluginGame* +InstanceManager::gamePluginForDirectory(const QString& instanceDir, + const PluginContainer& plugins) const +{ + const QString ini = iniPath(instanceDir); + + // reading ini + Settings s(ini); + + if (s.iniStatus() != QSettings::NoError) { + log::error("failed to load settings from {}", ini); + return nullptr; + } + + // using game name from ini, if available + const auto instanceGameName = s.game().name(); + + if (instanceGameName && !instanceGameName->isEmpty()) { + for (const IPluginGame* game : plugins.plugins()) { + if (instanceGameName->compare(game->gameName(), Qt::CaseInsensitive) == 0) { + return game; + } + } + + log::error("no plugin has game name '{}' that was found in ini {}", + *instanceGameName, ini); + } else { + log::error("no game name found in ini {}", ini); + } + + // using game directory from ini, if available + const auto gameDir = s.game().directory(); + + if (gameDir && !gameDir->isEmpty()) { + for (const IPluginGame* game : plugins.plugins()) { + if (game->looksValid(*gameDir)) { + return game; + } + } + + log::error("no plugin appears to support game directory '{}' from ini {}", *gameDir, + ini); + } else { + log::error("no game directory found in ini {}", ini); + } + + // looking for a plugin that can handle the directory + log::debug("falling back on looksValid check"); + + for (const IPluginGame* game : plugins.plugins()) { + if (game->looksValid(instanceDir)) { + return game; + } + } + + return nullptr; +} + +QString InstanceManager::makeUniqueName(const QString& instanceName) const +{ + const QString sanitized = MOBase::sanitizeFileName(instanceName); + + // trying "name (N)" + QString name = sanitized; + for (int i = 2; i < 100; ++i) { + if (!instanceExists(name)) { + return name; + } + + name = QString("%1 (%2)").arg(sanitized).arg(i); + } + + return {}; +} + +bool InstanceManager::instanceExists(const QString& instanceName) const +{ + const QDir root = globalInstancesRootPath(); + return root.exists(instanceName); +} + +std::unique_ptr selectInstance() +{ + auto& m = InstanceManager::singleton(); + + // since there is no instance currently active, load plugins with a null + // OrganizerCore; see PluginContainer::initPlugin() + NexusInterface ni(nullptr); + PluginContainer pc(nullptr); + pc.loadPlugins(); + + if (m.hasAnyInstances()) { + // there is at least one instance available, show the instance manager + // dialog + InstanceManagerDialog dlg(pc); + + // the dialog normally restarts MO when an instance is selected, but this + // is not necessary here since MO hasn't really started yet + dlg.setRestartOnSelect(false); + + dlg.show(); + dlg.activateWindow(); + dlg.raise(); + + if (dlg.exec() != QDialog::Accepted) { + return {}; + } + + } else { + // no instances configured, ask the user to create one + CreateInstanceDialog dlg(pc, nullptr); + + if (dlg.exec() != QDialog::Accepted) { + return {}; + } + } + + // return the new instance or the selection + return m.currentInstance(); +} + +// shows the game selection page of the create instance dialog so the user can +// pick which game is managed by this instance +// +// this is used below in setupInstance() when the game directory is gone or +// no plugins can recognize it +// +SetupInstanceResults selectGame(Instance& instance, PluginContainer& pc) +{ + CreateInstanceDialog dlg(pc, nullptr); + + // only show the game page + dlg.setSinglePage(instance.displayName()); + + dlg.show(); + dlg.activateWindow(); + dlg.raise(); + + if (dlg.exec() != QDialog::Accepted) { + // cancelled + return SetupInstanceResults::SelectAnother; + } + + // this info will be used instead of the ini, which should fix this + // particular problem + instance.setGame(dlg.creationInfo().game->gameName(), + dlg.creationInfo().gameLocation); + + return SetupInstanceResults::TryAgain; +} + +// shows the game variant page of the create instance dialog so the user can +// pick which game variant is installed +// +// this is used below in setupInstance() when there is no variant in the ini +// but the game plugin requires one; this can happen when the ini is broken +// or when a new variant has become supported by the plugin for a game the +// user already has an instance for +// +SetupInstanceResults selectVariant(Instance& instance, PluginContainer& pc) +{ + CreateInstanceDialog dlg(pc, nullptr); + + // the variant page uses the game page to know which game was selected, so + // set it manually + dlg.getPage()->select(instance.gamePlugin(), instance.gameDirectory()); + + // only show the variant page + dlg.setSinglePage(instance.displayName()); + + dlg.show(); + dlg.activateWindow(); + dlg.raise(); + + if (dlg.exec() != QDialog::Accepted) { + return SetupInstanceResults::SelectAnother; + } + + // this info will be used instead of the ini, which should fix this + // particular problem + instance.setVariant(dlg.creationInfo().gameVariant); + + return SetupInstanceResults::TryAgain; +} + +SetupInstanceResults setupInstance(Instance& instance, PluginContainer& pc) +{ + // set up the instance + log::debug("setupInstance() called for '{}', ini='{}'", + instance.displayName(), instance.iniPath()); + const auto setupResult = instance.setup(pc); + log::debug("instance.setup() returned {}", static_cast(setupResult)); + + switch (setupResult) { + case Instance::SetupResults::Okay: { + // all good + return SetupInstanceResults::Okay; + } + + case Instance::SetupResults::BadIni: { + // unreadable ini, there's not much that can be done, select another + // instance + + reportError(QObject::tr("Cannot open instance '%1', failed to read INI file %2.") + .arg(instance.displayName()) + .arg(instance.iniPath())); + + return SetupInstanceResults::SelectAnother; + } + + case Instance::SetupResults::IniMissingGame: { + // both the game name and directory are missing from the ini; although + // this shouldn't happen, setup() is able to handle when either is + // missing, but not both + // + // ask the user for the game managed by this instance + + reportError( + QObject::tr( + "Cannot open instance '%1', the managed game was not found in the INI " + "file %2. Select the game managed by this instance.") + .arg(instance.displayName()) + .arg(instance.iniPath())); + + return selectGame(instance, pc); + } + + case Instance::SetupResults::PluginGone: { + // there is no plugin that can handle the game name/directory from the + // ini, so this instance is unusable + + reportError( + QObject::tr("Cannot open instance '%1', the game plugin '%2' doesn't exist. It " + "may have been deleted by an antivirus. Select another instance.") + .arg(instance.displayName()) + .arg(instance.gameName())); + + return SetupInstanceResults::SelectAnother; + } + + case Instance::SetupResults::GameGone: { + // the game directory doesn't exist or the plugin doesn't recognize it; + // ask the user for the game managed by this instance + + reportError( + QObject::tr( + "Cannot open instance '%1', the game directory '%2' doesn't exist or " + "the game plugin '%3' doesn't recognize it. Select the game managed " + "by this instance.") + .arg(instance.displayName()) + .arg(instance.gameDirectory()) + .arg(instance.gameName())); + + return selectGame(instance, pc); + } + + case Instance::SetupResults::MissingVariant: { + // the game variant is missing from the ini, ask the user for it + return selectVariant(instance, pc); + } + + default: { + // shouldn't happen + return SetupInstanceResults::Exit; + } + } +} diff --git a/src/src/instancemanager.h b/src/src/instancemanager.h new file mode 100644 index 0000000..794728a --- /dev/null +++ b/src/src/instancemanager.h @@ -0,0 +1,364 @@ +#ifndef MODORGANIZER_INSTANCEMANAGER_INCLUDED +#define MODORGANIZER_INSTANCEMANAGER_INCLUDED + +#include +#include + +namespace MOBase +{ +class IPluginGame; +} + +class Settings; +class PluginContainer; + +// represents an instance, either global or portable +// +// if setup() is not called, the game plugin is not available and the INI is +// not processed at all, so name(), directory() and isPortable() really are the +// only meaningful functions +// +// setup() must be called when MO wants to use the instance, it will read the +// INI, figure out the game plugin to use and set it up by calling +// setGameVaraint(), setGamePath(), etc. on it +// +// when setup() fails because the game name/directory or variant are missing, +// setGame() and setVariant() can be called before retrying setup(); this +// happens on startup if that information is missing +// +class Instance +{ +public: + // returned by setup() + // + enum class SetupResults + { + // instance is ready to be used + Okay, + + // error while reading the INI + BadIni, + + // both the game name and directory are missing from the ini; setup() will + // attempt to recover if either are missing, but not when both are + IniMissingGame, + + // either: + // 1) there is no plugin with the given name, or + // 2) if the name is missing, no plugin can handle the game directory + PluginGone, + + // the selected plugin does not consider the game directory as being valid + GameGone, + + // there is no game variant specified in the INI, but the plugin requires + // one + MissingVariant + }; + + // a file or directory owned by this instance, used by objectsForDeletion() + // + struct Object + { + // path to the file or directory + QString path; + + // whether this object must be deleted to properly delete the instance; + // typically true only for the instance directory itself, but not for the + // base directory, etc. + bool mandatoryDelete; + + Object(QString p, bool d = false) : path(std::move(p)), mandatoryDelete(d) {} + + // puts mandatory delete on top + // + bool operator<(const Object& o) const + { + if (mandatoryDelete && !o.mandatoryDelete) { + return true; + } else if (!mandatoryDelete && o.mandatoryDelete) { + return false; + } + + return false; + } + }; + + // an instance that lives in the given directory; `portable` must be `true` + // if this is a portable instance + // + // `profileName` can be given to override what's in the INI; this typically + // happens when the profile is overriden on the command line + // + Instance(QString dir, bool portable, QString profileName = {}); + + // reads in values from the INI if they were not given yet: + // - game name + // - game directory + // - game variant + // - profile name + // + // note that setup() already calls this + // + // returns false if the ini couldn't be read from + // + bool readFromIni(); + + // finds the appropriate game plugin and sets it up so MO can use it; this + // calls readFromIni() first + // + // setup() tries to recover from some errors, but can fail for a variety of + // reasons, see SetupResults + // + SetupResults setup(PluginContainer& plugins); + + // overrides the game name and directory + // + void setGame(const QString& name, const QString& dir); + + // overrides the game variant + // + void setVariant(const QString& name); + + // returns the instance name; this is the directory name or "Portable" for + // portable instances + // + // be careful when using this function to check whether two instances are the + // same, some parts of MO use an empty string to represent portable instances, + // but this function will return "Portable" for them; it's safer to check + // for isPortable() first + // + // can be called without setup() + // + QString displayName() const; + + // returns either: + // 1) the game name from the INI, if readFromIni() was called; + // 2) gameName() from the game plugin if it was missing and setup() was + // called; or + // 3) whatever was given in setGame() + // + QString gameName() const; + + // returns either: + // 1) the game directory from the INI, if readFromIni() was called; + // 2) gameDirectory() from the game plugin if it was missing and setup() + // was called; or + // 3) whatever was given in setGame() + // + QString gameDirectory() const; + + // returns the instance directory as given in the constructor + // + QString directory() const; + + // returns the base directory; empty if readFromIni() hasn't been called + // + QString baseDirectory() const; + + // returns whether this is a portable instance, as given in the constructor + // + bool isPortable() const; + + // returns the selected game plugin; will return null if setup() hasn't been + // called, or if it failed + // + MOBase::IPluginGame* gamePlugin() const; + + // returns either: + // 1) the profile name given in the constructor if not empty; + // 2) the profile name from the INI if readFromIni() was called, or + // 3) the default profile name if it's missing (see + // AppConfig::defaultProfileName()) + // + QString profileName() const; + + // returns the path to the INI file for this instance; the file may not + // exist + // + QString iniPath() const; + + // whether this is the currently active instance in MO + // + bool isActive() const; + + // returns a list of files and directories that must be deleted when deleting + // this instance; this will read the INI and fail if it's not accessible + // + // returns an empty list on failure + // + std::vector objectsForDeletion() const; + +private: + QString m_dir; + bool m_portable; + QString m_gameName, m_gameDir, m_gameVariant, m_baseDir; + MOBase::IPluginGame* m_plugin; + QString m_profile; + + // figures out the game plugin for this instance + // + SetupResults getGamePlugin(PluginContainer& plugins); + + // figures out the profile name for this instance + // + void getProfile(const Settings& s); + + // updates the ini with the given values and the ones found by setup() + // + void updateIni(); +}; + +// manages global and portable instances +// +class InstanceManager +{ +public: + // there is only one manager; this isn't called instance() because it's hella + // confusing + // + static InstanceManager& singleton(); + + // overrides instance name found in registry + // + void overrideInstance(const QString& instanceName); + + // overrides profile name from INI for currentInstance() + // + void overrideProfile(const QString& profileName); + + // clears instance and profile overrides, used when restarting MO to select + // another instance + // + void clearOverrides(); + + // returns a game plugin that considers the given directory valid + // + // this will check for an INI file in the directory and use its game name + // and directory if available + // + // if there is no INI, if it's missing these values or if there are no game + // plugins that can handle these values, this returns the first plugin that + // considers the given directory valid + // + // returns null if all of this fails + // + const MOBase::IPluginGame* + gamePluginForDirectory(const QString& dir, const PluginContainer& plugins) const; + + MOBase::IPluginGame* gamePluginForDirectory(const QString& dir, + PluginContainer& plugins) const; + + // clears the instance name from the registry; on restart, this will make MO + // either select the portable instance if it exists, or display the instance + // selection/creation dialog + // + void clearCurrentInstance(); + + // returns the current instance from the registry; this may be empty if the + // instance name in the registry is empty or non-existent and there is no + // portable instance set up + // + std::unique_ptr currentInstance() const; + + // sets the instance name in the registry so the same instance is opened next + // time MO runs + // + void setCurrentInstance(const QString& name); + + // whether MO should allow the user to change the current instance from the + // user interface + // + bool allowedToChangeInstance() const; + + // whether a portable instance exists; this basically checks for an INI in + // the application directory + // + bool portableInstanceExists() const; + + // whether any instance exists, whether global or portable + // + bool hasAnyInstances() const; + + // returns the absolute path to the portable instance, regardless of whether + // one exists + // + QString portablePath() const; + + // returns the absolute path to the directory that contains global instances + // (typically AppData/Local/ModOrganizer) + // + QString globalInstancesRootPath() const; + + // returns the list of absolute path to all existing global instances; this + // does not include the portable instance + // + std::vector globalInstancePaths() const; + + // sanitizes the given instance name and either + // 1) returns it if there is no instance with this name + // 2) tries to add " (N)" at the end until it works + // + // may return an empty string if no unique name can be found + // + QString makeUniqueName(const QString& instanceName) const; + + // returns whether a global instance with this name already exists + // + bool instanceExists(const QString& instanceName) const; + + // returns the absolute path of a global instance with the given name; this + // does not check if the name is valid or if exists + // + QString instancePath(const QString& instanceName) const; + + // returns the absolute path to the INI file for the given instance directory; + // the file may not exist + // + QString iniPath(const QString& instanceDir) const; + +private: + InstanceManager(); + +private: + std::optional m_overrideInstanceName; + std::optional m_overrideProfileName; +}; + +// see setupInstance() +// +enum class SetupInstanceResults +{ + Okay, + TryAgain, + SelectAnother, + Exit +}; + +// if there are no instances configured, global or portable, shows the +// create instance dialog and returns the new instance or empty if the user +// cancelled +// +// if there is at least one instance available, unconditionally show the +// instance manager dialog and returns the selected instance or empty if the +// user cancelled +// +std::unique_ptr selectInstance(); + +// calls instance.setup() tries to handle problems by itself: +// +// - if the ini is missing some information, will show dialogs and ask the user +// to fill in what's required (such as the game directory, variant, etc.); +// if successful, returns TryAgain and setupInstance() can be called again +// with the same instance +// +// - if the instance cannot be used (no game plugin found for it, ini can't +// be read, etc.), returns SelectAnother +// +// - if the user cancels at any point, returns Exit +// +// - if the instance has been set up correctly, returns Okay +// +SetupInstanceResults setupInstance(Instance& instance, PluginContainer& pc); + +#endif // MODORGANIZER_INSTANCEMANAGER_INCLUDED diff --git a/src/src/instancemanagerdialog.cpp b/src/src/instancemanagerdialog.cpp new file mode 100644 index 0000000..f0f3f61 --- /dev/null +++ b/src/src/instancemanagerdialog.cpp @@ -0,0 +1,736 @@ +#include "instancemanagerdialog.h" +#include "createinstancedialog.h" +#include "filesystemutilities.h" +#include "instancemanager.h" +#include "plugincontainer.h" +#include "selectiondialog.h" +#include "settings.h" +#include "shared/appconfig.h" +#include "shared/util.h" +#include "ui_instancemanagerdialog.h" +#include +#include +#include + +using namespace MOBase; + +// returns the icon for the given instance or an empty 32x32 icon if the game +// plugin couldn't be found +// +QIcon instanceIcon(PluginContainer& pc, const Instance& i) +{ + auto* game = InstanceManager::singleton().gamePluginForDirectory(i.directory(), pc); + + if (!game) { + QPixmap empty(32, 32); + empty.fill(QColor(0, 0, 0, 0)); + return QIcon(empty); + } + + // it's possible to have the game installed in a way that the game plugin + // couldn't auto detect; in this case, the instance would have a valid game + // directory, but the plugin wouldn't know about it + // + // it's also possible, but unlikely, to have multiple installations of the + // same game that have different icons for the same exe + // + // so the game directory specified for the instance needs to be given to the + // game plugin to get the appropriate icon, but since these game plugin + // objects are created on startup and are global, they should retain their + // auto detected path + // + // if not, creating a new instance for a specific plugin would use the game + // directory of the instance for which the icon was most recently shown, which + // would be really inconsistent + // + // + // this game plugin could also be the currently active plugin for the + // current instance, which should _definitely_ keep pointing to the same + // directory as before + + // remember old game directory + // + // note that gameDirectory() returns a QDir, which doesn't support empty + // strings (they get converted to "." automatically!), but the plugin _will_ + // try to return an empty string when the game has not been auto-detected + // + // so gameDirectory() _cannot_ reliably be used if `isInstalled()` is false + const QString old = game->isInstalled() ? game->gameDirectory().path() : ""; + + // revert + Guard g([&] { + game->setGamePath(old); + }); + + // set directory for this instance + game->setGamePath(i.gameDirectory()); + + return game->gameIcon(); +} + +// pops up a dialog to ask for an instance name when renaming +// +QString getInstanceName(QWidget* parent, const QString& title, const QString& moreText, + const QString& label, const QString& oldName = {}) +{ + auto& m = InstanceManager::singleton(); + + QDialog dlg(parent); + dlg.setWindowTitle(title); + + auto* ly = new QVBoxLayout(&dlg); + + auto* bb = new QDialogButtonBox(QDialogButtonBox::Cancel | QDialogButtonBox::Ok); + + auto* text = new QLineEdit(oldName); + text->selectAll(); + + auto* error = new QLabel; + + if (!moreText.isEmpty()) { + auto* lb = new QLabel(moreText); + lb->setWordWrap(true); + ly->addWidget(lb); + ly->addSpacing(10); + } + + auto* lb = new QLabel(label); + lb->setWordWrap(true); + ly->addWidget(lb); + + ly->addWidget(text); + ly->addWidget(error); + ly->addStretch(); + ly->addWidget(bb); + + auto check = [&] { + bool okay = false; + + if (text->text().isEmpty()) { + error->setText(""); + } else if (!MOBase::validFileName(text->text())) { + error->setText(QObject::tr("The instance name must be a valid folder name.")); + } else { + const auto name = MOBase::sanitizeFileName(text->text()); + + if ((name != oldName) && m.instanceExists(text->text())) { + error->setText(QObject::tr("An instance with this name already exists.")); + } else { + okay = true; + } + } + + error->setVisible(!okay); + bb->button(QDialogButtonBox::Ok)->setEnabled(okay); + }; + + QObject::connect(text, &QLineEdit::textChanged, [&] { + check(); + }); + QObject::connect(bb, &QDialogButtonBox::accepted, [&] { + dlg.accept(); + }); + QObject::connect(bb, &QDialogButtonBox::rejected, [&] { + dlg.reject(); + }); + + check(); + + dlg.resize({400, 120}); + if (dlg.exec() != QDialog::Accepted) { + return {}; + } + + return MOBase::sanitizeFileName(text->text()); +} + +InstanceManagerDialog::~InstanceManagerDialog() = default; + +InstanceManagerDialog::InstanceManagerDialog(PluginContainer& pc, QWidget* parent) + : QDialog(parent), ui(new Ui::InstanceManagerDialog), m_pc(pc), m_model(nullptr), + m_restartOnSelect(true) +{ + ui->setupUi(this); + + ui->splitter->setSizes({250, 1}); + ui->splitter->setStretchFactor(0, 0); + ui->splitter->setStretchFactor(1, 1); + + m_model = new QStandardItemModel; + ui->list->setModel(m_model); + + m_filter.setEdit(ui->filter); + m_filter.setList(ui->list); + m_filter.setFilteredBorder(false); + + updateInstances(); + updateList(); + selectActiveInstance(); + + connect(ui->createNew, &QPushButton::clicked, [&] { + createNew(); + }); + + connect(ui->list->selectionModel(), &QItemSelectionModel::selectionChanged, [&] { + onSelection(); + }); + connect(ui->list, &QListView::activated, [&] { + openSelectedInstance(); + }); + + connect(ui->rename, &QPushButton::clicked, [&] { + rename(); + }); + connect(ui->exploreLocation, &QPushButton::clicked, [&] { + exploreLocation(); + }); + connect(ui->exploreBaseDirectory, &QPushButton::clicked, [&] { + exploreBaseDirectory(); + }); + connect(ui->exploreGame, &QPushButton::clicked, [&] { + exploreGame(); + }); + + connect(ui->convertToGlobal, &QPushButton::clicked, [&] { + convertToGlobal(); + }); + connect(ui->convertToPortable, &QPushButton::clicked, [&] { + convertToPortable(); + }); + connect(ui->openINI, &QPushButton::clicked, [&] { + openINI(); + }); + connect(ui->deleteInstance, &QPushButton::clicked, [&] { + deleteInstance(); + }); + + connect(ui->switchToInstance, &QPushButton::clicked, [&] { + openSelectedInstance(); + }); + connect(ui->close, &QPushButton::clicked, [&] { + close(); + }); +} + +void InstanceManagerDialog::showEvent(QShowEvent* e) +{ + // there might not be a global Settings object if this is called on startup + // when there's no current instance + const auto* s = Settings::maybeInstance(); + + if (s) { + s->geometry().restoreGeometry(this); + } + + QDialog::showEvent(e); +} + +void InstanceManagerDialog::done(int r) +{ + // there might not be a global Settings object if this is called on startup + // when there's no current instance + auto* s = Settings::maybeInstance(); + + if (s) { + s->geometry().saveGeometry(this); + } + + QDialog::done(r); +} + +void InstanceManagerDialog::updateInstances() +{ + auto& m = InstanceManager::singleton(); + + m_instances.clear(); + + for (auto&& d : m.globalInstancePaths()) { + m_instances.push_back(std::make_unique(d, false)); + } + + // sort first, prepend portable after so it's always on top + std::sort(m_instances.begin(), m_instances.end(), [](auto&& a, auto&& b) { + return (MOBase::naturalCompare(a->displayName(), b->displayName()) < 0); + }); + + if (m.portableInstanceExists()) { + m_instances.insert(m_instances.begin(), + std::make_unique(m.portablePath(), true)); + } + + // read all inis, ignore errors + for (auto&& i : m_instances) { + i->readFromIni(); + } +} + +void InstanceManagerDialog::updateList() +{ + const auto prevSelIndex = singleSelectionIndex(); + const auto* prevSel = singleSelection(); + + m_model->clear(); + + std::size_t sel = NoSelection; + + // creating items for instances + for (std::size_t i = 0; i < m_instances.size(); ++i) { + const auto& ii = *m_instances[i]; + + auto* item = new QStandardItem(ii.displayName()); + item->setIcon(instanceIcon(m_pc, ii)); + + m_model->appendRow(item); + + if (&ii == prevSel) { + sel = i; + } + } + + // keep current selection or select the next one if there was a selection; + // there's no selection when opening the dialog, that's handled in the ctor + if (prevSel) { + if (m_instances.empty()) { + select(-1); + } else { + if (sel == NoSelection) { + if (prevSelIndex >= m_instances.size()) { + sel = m_instances.size() - 1; + } else { + sel = prevSelIndex; + } + } + + select(sel); + } + } +} + +void InstanceManagerDialog::select(std::size_t i) +{ + if (i < m_instances.size()) { + const auto& ii = m_instances[i]; + fillData(*ii); + + ui->list->selectionModel()->select( + m_filter.mapFromSource(m_filter.sourceModel()->index(i, 0)), + QItemSelectionModel::ClearAndSelect); + } else { + clearData(); + } +} + +void InstanceManagerDialog::select(const QString& name) +{ + for (std::size_t i = 0; i < m_instances.size(); ++i) { + if (m_instances[i]->displayName() == name) { + select(i); + return; + } + } + + log::error("can't select instance {}, not in list", name); +} + +void InstanceManagerDialog::selectActiveInstance() +{ + const auto active = InstanceManager::singleton().currentInstance(); + + if (active) { + for (std::size_t i = 0; i < m_instances.size(); ++i) { + if (m_instances[i]->displayName() == active->displayName()) { + select(i); + + ui->list->scrollTo(m_filter.mapFromSource(m_filter.sourceModel()->index(i, 0))); + + return; + } + } + } + + select(0); +} + +void InstanceManagerDialog::openSelectedInstance() +{ + const auto i = singleSelectionIndex(); + if (i == NoSelection) { + return; + } + + const auto& to = *m_instances[i]; + + if (!confirmSwitch(to)) { + return; + } + + if (to.isPortable()) { + InstanceManager::singleton().setCurrentInstance(""); + } else { + InstanceManager::singleton().setCurrentInstance(to.displayName()); + } + + if (m_restartOnSelect) { + ExitModOrganizer(Exit::Restart); + } + + accept(); +} + +bool InstanceManagerDialog::confirmSwitch(const Instance& to) +{ + // there might not be a global Settings object if this is called on startup + // when there's no current instance + const auto* s = Settings::maybeInstance(); + + // if there is are no settings, no instances are loaded and the confirmation + // wouldn't make sense + if (!s) { + return true; + } + + if (!s->interface().showChangeGameConfirmation()) { + // user disabled confirmation + return true; + } + + MOBase::TaskDialog dlg(this); + + const auto r = dlg.title(tr("Switching instances")) + .main(tr("Mod Organizer must restart to manage the instance '%1'.") + .arg(to.displayName())) + .content(tr("This confirmation can be disabled in the settings.")) + .icon(QMessageBox::Question) + .button({tr("Restart Mod Organizer"), QMessageBox::Ok}) + .button({tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + return (r == QMessageBox::Ok); +} + +void InstanceManagerDialog::rename() +{ + auto* i = singleSelection(); + if (!i) { + return; + } + + const auto selIndex = singleSelectionIndex(); + + auto& m = InstanceManager::singleton(); + if (i->isActive()) { + QMessageBox::information(this, tr("Rename instance"), + tr("The active instance cannot be renamed.")); + return; + } + + // getting new name + const auto newName = getInstanceName(this, tr("Rename instance"), "", + tr("Instance name"), i->displayName()); + + if (newName.isEmpty()) { + return; + } + + // renaming + const QString src = i->directory(); + const QString dest = + QDir::toNativeSeparators(QFileInfo(src).dir().path() + "/" + newName); + + log::info("renaming {} to {}", src, dest); + + const auto r = shell::Rename(QFileInfo(src), QFileInfo(dest), false); + + if (!r) { + QMessageBox::critical(this, tr("Error"), + tr("Failed to rename \"%1\" to \"%2\": %3") + .arg(src) + .arg(dest) + .arg(r.toString())); + + return; + } + + // updating ui + auto newInstance = std::make_unique(dest, false); + i = newInstance.get(); + + m_model->item(selIndex)->setText(newName); + m_instances[selIndex] = std::move(newInstance); + + fillData(*i); +} + +void InstanceManagerDialog::exploreLocation() +{ + if (const auto* i = singleSelection()) { + shell::Explore(i->directory()); + } +} + +void InstanceManagerDialog::exploreBaseDirectory() +{ + if (const auto* i = singleSelection()) { + shell::Explore(i->baseDirectory()); + } +} + +void InstanceManagerDialog::exploreGame() +{ + if (const auto* i = singleSelection()) { + shell::Explore(i->gameDirectory()); + } +} + +void InstanceManagerDialog::openINI() +{ + if (const auto* i = singleSelection()) { + shell::Open(i->iniPath()); + } +} + +void InstanceManagerDialog::deleteInstance() +{ + const auto* i = singleSelection(); + if (!i) { + return; + } + + auto& m = InstanceManager::singleton(); + if (i->isActive()) { + QMessageBox::information(this, tr("Deleting instance"), + tr("The active instance cannot be deleted.")); + return; + } + + // creating dialog + + const auto Recycle = QMessageBox::Save; + const auto Delete = QMessageBox::Yes; + const auto Cancel = QMessageBox::Cancel; + + const auto files = i->objectsForDeletion(); + + MOBase::TaskDialog dlg(this); + + dlg.title(tr("Deleting instance")) + .main(tr("These files and folders will be deleted")) + .content(tr("All checked items will be deleted.")) + .icon(QMessageBox::Warning) + .button({tr("Move to the recycle bin"), Recycle}) + .button({tr("Delete permanently"), Delete}) + .button({tr("Cancel"), Cancel}); + + auto* list = new QListWidget(); + list->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + list->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + list->setMaximumHeight(160); + + // filling the list + for (const auto& f : files) { + auto* item = new QListWidgetItem(f.path); + + if (f.mandatoryDelete) { + // disable, cannot uncheck mandatory items + item->setFlags(item->flags() & (~Qt::ItemIsEnabled)); + + // checked by default + item->setCheckState(Qt::Checked); + } else { + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + + // unchecked by default + item->setCheckState(Qt::Unchecked); + } + + list->addItem(item); + } + + dlg.addContent(list); + dlg.setWidth(600); + + const auto r = dlg.exec(); + + if (r != Recycle && r != Delete) { + return; + } + + // gathering all the selected items + QStringList selected; + + for (int i = 0; i < list->count(); ++i) { + if (list->item(i)->checkState() == Qt::Checked) { + selected.append(list->item(i)->text()); + } + } + + if (selected.isEmpty()) { + QMessageBox::information(this, tr("Deleting instance"), tr("Nothing to delete.")); + + return; + } + + // deleting + if (!doDelete(selected, (r == Recycle))) { + return; + } + + // updating ui + updateInstances(); + updateList(); +} + +void InstanceManagerDialog::setRestartOnSelect(bool b) +{ + m_restartOnSelect = b; +} + +bool InstanceManagerDialog::doDelete(const QStringList& files, bool recycle) +{ + // logging + for (auto&& f : files) { + if (recycle) { + log::info("will recycle {}", f); + } else { + log::info("will delete {}", f); + } + } + + if (MOBase::shellDelete(files, recycle, this)) { + return true; + } + + const auto e = GetLastError(); + if (e == ERROR_CANCELLED) { + log::debug("deletion cancelled by user"); + } else { + log::error("failed to delete, {}", formatSystemMessage(e)); + } + + return false; +} + +void InstanceManagerDialog::convertToGlobal() +{ + // not implemented +} + +void InstanceManagerDialog::convertToPortable() +{ + // not implemented +} + +void InstanceManagerDialog::onSelection() +{ + const auto i = singleSelectionIndex(); + if (i == NoSelection) { + return; + } + + select(i); +} + +void InstanceManagerDialog::createNew() +{ + // there might not be settings available; the dialog can be shown when the + // last selected instance doesn't exist anymore + CreateInstanceDialog dlg(m_pc, Settings::maybeInstance(), this); + + if (dlg.exec() != QDialog::Accepted) { + return; + } + + if (dlg.switching()) { + // restarting MO + accept(); + return; + } + + updateInstances(); + updateList(); + + select(dlg.creationInfo().instanceName); +} + +std::size_t InstanceManagerDialog::singleSelectionIndex() const +{ + const auto sel = + m_filter.mapSelectionToSource(ui->list->selectionModel()->selection()); + + if (sel.size() != 1) { + return NoSelection; + } + + return static_cast(sel.indexes()[0].row()); +} + +const Instance* InstanceManagerDialog::singleSelection() const +{ + const auto i = singleSelectionIndex(); + if (i == NoSelection) { + return nullptr; + } + + return m_instances[i].get(); +} + +void InstanceManagerDialog::fillData(const Instance& ii) +{ + ui->name->setText(ii.displayName()); + ui->location->setText(ii.directory()); + ui->baseDirectory->setText(ii.baseDirectory()); + ui->gameName->setText(ii.gameName()); + ui->gameDir->setText(ii.gameDirectory()); + setButtonsEnabled(true); + + const auto& m = InstanceManager::singleton(); + + ui->rename->setEnabled(!ii.isPortable()); + + if (ii.isPortable()) { + ui->convertToPortable->setVisible(false); + ui->convertToGlobal->setVisible(true); + ui->convertToGlobal->setEnabled(true); + } else { + ui->convertToPortable->setVisible(true); + ui->convertToGlobal->setVisible(false); + + if (m.portableInstanceExists()) { + ui->convertToPortable->setEnabled(false); + ui->convertToPortable->setToolTip(tr("A portable instance already exists.")); + } else { + ui->convertToPortable->setEnabled(false); + ui->convertToPortable->setToolTip(""); + } + } + + // not implemented, hide the buttons + ui->convertToPortable->setVisible(false); + ui->convertToGlobal->setVisible(false); +} + +void InstanceManagerDialog::clearData() +{ + ui->name->clear(); + ui->location->clear(); + ui->baseDirectory->clear(); + ui->gameName->clear(); + ui->gameDir->clear(); + + setButtonsEnabled(false); + + ui->convertToPortable->setVisible(false); + ui->convertToGlobal->setVisible(false); +} + +void InstanceManagerDialog::setButtonsEnabled(bool b) +{ + ui->rename->setEnabled(b); + ui->exploreLocation->setEnabled(b); + ui->exploreBaseDirectory->setEnabled(b); + ui->exploreGame->setEnabled(b); + ui->convertToPortable->setEnabled(b); + ui->convertToGlobal->setEnabled(b); + ui->deleteInstance->setEnabled(b); + ui->switchToInstance->setEnabled(b); +} diff --git a/src/src/instancemanagerdialog.h b/src/src/instancemanagerdialog.h new file mode 100644 index 0000000..f6e2db6 --- /dev/null +++ b/src/src/instancemanagerdialog.h @@ -0,0 +1,145 @@ +#ifndef MODORGANIZER_INSTANCEMANAGERDIALOG_INCLUDED +#define MODORGANIZER_INSTANCEMANAGERDIALOG_INCLUDED + +#include +#include + +namespace Ui +{ +class InstanceManagerDialog; +}; + +class Instance; +class PluginContainer; + +// a dialog to manage existing instances +// +class InstanceManagerDialog : public QDialog +{ + Q_OBJECT + +public: + explicit InstanceManagerDialog(PluginContainer& pc, QWidget* parent = nullptr); + + ~InstanceManagerDialog(); + + // selects the instance having the given index in the list + // + void select(std::size_t i); + + // selects the instance by name + // + void select(const QString& name); + + // select the instance that is currently in use in MO + // + void selectActiveInstance(); + + // switches to the selected instance; restarts MO, unless + // was called setRestartOnSelect(false) + // + void openSelectedInstance(); + + // renames the currently selected instance + // + void rename(); + + // explores the directory of the selected instance + // + void exploreLocation(); + + // explores the base directory of the selected instance + // + void exploreBaseDirectory(); + + // explores the game directory of the selected instance + // + void exploreGame(); + + // converts the selected, portable instance to a global one; not implemented + // + void convertToGlobal(); + + // converts the selected, global instance to a portable one; not implemented + // + void convertToPortable(); + + // opens the ini of the selected instance in the shell + // + void openINI(); + + // deletes the selected instance + // + void deleteInstance(); + + // sets whether the dialog should restart MO when selecting an instance; this + // is false on startup when no instances exist + // + void setRestartOnSelect(bool b); + + // saves geometry + // + void done(int r) override; + +protected: + // restores geometry + // + void showEvent(QShowEvent* e) override; + +private: + static const std::size_t NoSelection = -1; + + std::unique_ptr ui; + PluginContainer& m_pc; + std::vector> m_instances; + MOBase::FilterWidget m_filter; + QStandardItemModel* m_model; + bool m_restartOnSelect; + + // refreshes the list instances from disk + // + void updateInstances(); + + // updates the ui for the selected instance + // + void onSelection(); + + // opens the create instance dialog + // + void createNew(); + + // shows a confirmation to the user before switching + // + bool confirmSwitch(const Instance& to); + + // returns the index of selected instance, NoSelection if none + // + std::size_t singleSelectionIndex() const; + + // returns the InstanceInfo associated with the selected instance, null if + // none + // + const Instance* singleSelection() const; + + // fills the instance list on the ui + // + void updateList(); + + // fills the ui for the selected instance + // + void fillData(const Instance& ii); + + // clears the ui when there's no selection + // + void clearData(); + + // enables/disables buttons like rename, explore... + // + void setButtonsEnabled(bool b); + + // deletes the given files, returns false on error + // + bool doDelete(const QStringList& files, bool recycle); +}; + +#endif // MODORGANIZER_INSTANCEMANAGERDIALOG_INCLUDED diff --git a/src/src/instancemanagerdialog.ui b/src/src/instancemanagerdialog.ui new file mode 100644 index 0000000..44f4ff1 --- /dev/null +++ b/src/src/instancemanagerdialog.ui @@ -0,0 +1,444 @@ + + + InstanceManagerDialog + + + + 0 + 0 + 884 + 539 + + + + Instance manager + + + + + + + 0 + + + 0 + + + 0 + + + 5 + + + + + Create new instance + + + + :/MO/gui/add:/MO/gui/add + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + <html><head/><body><p><a href="https://github.com/ModOrganizer2/modorganizer/wiki/Instances">What is an instance?</a></p></body></html> + + + true + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::ScrollPerPixel + + + QAbstractItemView::ScrollPerPixel + + + QListView::Adjust + + + true + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Filter + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + + + + + Name + + + + + + + Explore + + + + + + + Game + + + + + + + true + + + + + + + true + + + + + + + Location + + + + + + + Rename + + + + + + + Game location + + + + + + + Base folder + + + + + + + true + + + + + + + Explore + + + + + + + true + + + + + + + Explore + + + + + + + + + + Qt::Vertical + + + + 20 + 10 + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Convert to portable + + + + + + + Convert to global + + + + + + + Open INI + + + + + + + Delete instance... + + + + :/MO/gui/remove:/MO/gui/remove + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Switch to this instance + + + + :/MO/gui/next:/MO/gui/next + + + + + + + Close + + + true + + + + + + + + + + + LinkLabel + QLabel +
    linklabel.h
    +
    +
    + + + + +
    diff --git a/src/src/iuserinterface.h b/src/src/iuserinterface.h new file mode 100644 index 0000000..9ff0df1 --- /dev/null +++ b/src/src/iuserinterface.h @@ -0,0 +1,32 @@ +#ifndef IUSERINTERFACE_H +#define IUSERINTERFACE_H + +#include "modinfodialogfwd.h" +#include +#include +#include +#include +#include + +class IUserInterface +{ +public: + virtual void registerModPage(MOBase::IPluginModPage* modPage) = 0; + + virtual void installTranslator(const QString& name) = 0; + + virtual bool closeWindow() = 0; + virtual void setWindowEnabled(bool enabled) = 0; + + virtual void displayModInformation(ModInfoPtr modInfo, unsigned int modIndex, + ModInfoTabIDs tabID) = 0; + + virtual void updateBSAList(const QStringList& defaultArchives, + const QStringList& activeArchives) = 0; + + virtual MOBase::DelayedFileWriterBase& archivesWriter() = 0; + + virtual QMainWindow* mainWindow() = 0; +}; + +#endif // IUSERINTERFACE_H diff --git a/src/src/json.h b/src/src/json.h new file mode 100644 index 0000000..89b8b70 --- /dev/null +++ b/src/src/json.h @@ -0,0 +1,179 @@ +#ifndef MODORGANIZER_JSON_INCLUDED +#define MODORGANIZER_JSON_INCLUDED + +#include +#include +#include +#include +#include + +namespace json +{ + +class failed +{}; + +namespace details +{ + + QString typeName(const QJsonValue& v) + { + if (v.isUndefined()) { + return "undefined"; + } else if (v.isNull()) { + return "null"; + } else if (v.isArray()) { + return "an array"; + } else if (v.isBool()) { + return "a bool"; + } else if (v.isDouble()) { + return "a double"; + } else if (v.isObject()) { + return "an object"; + } else if (v.isString()) { + return "a string"; + } else { + return "an unknown type"; + } + } + + QString typeName(const QJsonDocument& doc) + { + if (doc.isEmpty()) { + return "empty"; + } else if (doc.isNull()) { + return "null"; + } else if (doc.isArray()) { + return "an array"; + } else if (doc.isObject()) { + return "an object"; + } else { + return "an unknown type"; + } + } + + template + T convert(const QJsonValue& v) = delete; + + template <> + bool convert(const QJsonValue& v) + { + if (!v.isBool()) { + throw failed(); + } + + return v.toBool(); + } + + template <> + QJsonObject convert(const QJsonValue& v) + { + if (!v.isObject()) { + throw failed(); + } + + return v.toObject(); + } + + template <> + QString convert(const QJsonValue& v) + { + if (!v.isString()) { + throw failed(); + } + + return v.toString(); + } + + template <> + QJsonArray convert(const QJsonValue& v) + { + if (!v.isArray()) { + throw failed(); + } + + return v.toArray(); + } + + template <> + qint64 convert(const QJsonValue& v) + { + if (!v.isDouble()) { + throw failed(); + } + + return static_cast(v.toDouble()); + } + +} // namespace details + +template +T convert(const QJsonValue& value, const char* what) +{ + try { + return details::convert(value); + } catch (failed&) { + MOBase::log::error("'{}' is a {}, not a {}", what, details::typeName(value), + typeid(T).name()); + + throw; + } +} + +template +T convertWarn(const QJsonValue& value, const char* what, T def = {}) +{ + try { + return details::convert(value); + } catch (failed&) { + MOBase::log::warn("'{}' is a {}, not a {}", what, details::typeName(value), + typeid(T).name()); + + return def; + } +} + +template +T get(const QJsonObject& o, const char* e) +{ + if (!o.contains(e)) { + MOBase::log::error("property '{}' is missing", e); + throw failed(); + } + + return convert(o[e], e); +} + +template +T getWarn(const QJsonObject& o, const char* e, T def = {}) +{ + if (!o.contains(e)) { + MOBase::log::warn("property '{}' is missing", e); + return def; + } + + return convertWarn(o[e], e); +} + +template +T getOpt(const QJsonObject& o, const char* e, T def = {}) +{ + if (!o.contains(e)) { + return def; + } + + return convertWarn(o[e], e); +} + +template +void requireObject(const Value& v, const char* what) +{ + if (!v.isObject()) { + MOBase::log::error("{} is {}, not an object", what, details::typeName(v)); + throw failed(); + } +} + +} // namespace json + +#endif // MODORGANIZER_JSON_INCLUDED diff --git a/src/src/lcdnumber.cpp b/src/src/lcdnumber.cpp new file mode 100644 index 0000000..3096fe4 --- /dev/null +++ b/src/src/lcdnumber.cpp @@ -0,0 +1,34 @@ +/* +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "lcdnumber.h" + +#include +#include + +LCDNumber::LCDNumber(QWidget* parent) : QLCDNumber(parent) {} + +void LCDNumber::mousePressEvent(QMouseEvent* event) +{ + m_toolTipPosition = mapToGlobal(event->pos()); + QTimer::singleShot(100, this, SLOT(showToolTip())); +} + +void LCDNumber::showToolTip() +{ + QToolTip::showText(m_toolTipPosition, toolTip()); +} diff --git a/src/src/lcdnumber.h b/src/src/lcdnumber.h new file mode 100644 index 0000000..573d79a --- /dev/null +++ b/src/src/lcdnumber.h @@ -0,0 +1,35 @@ +/* +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include +#include +#include + +class LCDNumber : public QLCDNumber +{ + Q_OBJECT + +public: + LCDNumber(QWidget* parent = nullptr); + void mousePressEvent(QMouseEvent* event); + +public slots: + void showToolTip(); + +private: + QPoint m_toolTipPosition; +}; diff --git a/src/src/listdialog.cpp b/src/src/listdialog.cpp new file mode 100644 index 0000000..8084a0b --- /dev/null +++ b/src/src/listdialog.cpp @@ -0,0 +1,79 @@ +/* +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "listdialog.h" +#include "settings.h" +#include "ui_listdialog.h" + +ListDialog::ListDialog(QWidget* parent) + : QDialog(parent), ui(new Ui::ListDialog), m_Choices() +{ + ui->setupUi(this); + ui->filterEdit->setFocus(); + connect(ui->choiceList, &QListWidget::itemDoubleClicked, this, &QDialog::accept); +} + +ListDialog::~ListDialog() +{ + delete ui; +} + +int ListDialog::exec() +{ + GeometrySaver gs(Settings::instance(), this); + return QDialog::exec(); +} + +void ListDialog::setChoices(QStringList choices) +{ + m_Choices = choices; + ui->choiceList->clear(); + ui->choiceList->addItems(m_Choices); +} + +QString ListDialog::getChoice() const +{ + if (ui->choiceList->selectedItems().length()) { + return ui->choiceList->currentItem()->text(); + } else { + return ""; + } +} + +void ListDialog::on_filterEdit_textChanged(QString filter) +{ + QStringList newChoices; + for (auto choice : m_Choices) { + if (choice.contains(filter, Qt::CaseInsensitive)) { + newChoices << choice; + } + } + ui->choiceList->clear(); + ui->choiceList->addItems(newChoices); + + if (newChoices.length() == 1) { + QListWidgetItem* item = ui->choiceList->item(0); + item->setSelected(true); + ui->choiceList->setCurrentItem(item); + } + + if (!filter.isEmpty()) { + ui->choiceList->setStyleSheet("QListWidget { border: 2px ridge #f00; }"); + } else { + ui->choiceList->setStyleSheet(""); + } +} diff --git a/src/src/listdialog.h b/src/src/listdialog.h new file mode 100644 index 0000000..ec2556b --- /dev/null +++ b/src/src/listdialog.h @@ -0,0 +1,34 @@ +#ifndef LISTDIALOG_H +#define LISTDIALOG_H + +#include + +namespace Ui +{ +class ListDialog; +} + +class ListDialog : public QDialog +{ + Q_OBJECT + +public: + explicit ListDialog(QWidget* parent = nullptr); + ~ListDialog(); + + // also saves and restores geometry + // + int exec() override; + + void setChoices(QStringList choices); + QString getChoice() const; + +public slots: + void on_filterEdit_textChanged(QString filter); + +private: + Ui::ListDialog* ui; + QStringList m_Choices; +}; + +#endif // LISTDIALOG_H diff --git a/src/src/listdialog.ui b/src/src/listdialog.ui new file mode 100644 index 0000000..7aa3eba --- /dev/null +++ b/src/src/listdialog.ui @@ -0,0 +1,103 @@ + + + ListDialog + + + + 0 + 0 + 260 + 380 + + + + Select an item... + + + + + + + 0 + 0 + + + + + 240 + 280 + + + + QAbstractItemView::ScrollPerPixel + + + + + + + + + + Filter + + + + + + + Qt::LeftToRight + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + MOBase::LineEditClear + QLineEdit +
    lineeditclear.h
    +
    +
    + + + + buttonBox + accepted() + ListDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + ListDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + +
    diff --git a/src/src/loghighlighter.cpp b/src/src/loghighlighter.cpp new file mode 100644 index 0000000..524e6e3 --- /dev/null +++ b/src/src/loghighlighter.cpp @@ -0,0 +1,47 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "loghighlighter.h" + +LogHighlighter::LogHighlighter(QObject* parent) : QSyntaxHighlighter(parent) {} + +void LogHighlighter::highlightBlock(const QString& text) +{ + int spacePos = text.indexOf(" "); + if (spacePos != -1) { + QString type = text.mid(0, spacePos); + if (type == "DEBUG") { + setFormat(0, text.length(), Qt::gray); + } else if (type == "INFO") { + setFormat(0, text.length(), Qt::darkGreen); + } else if (type == "ERROR") { + setFormat(0, text.length(), Qt::red); + } + } + + int markPos = text.indexOf("injecting to"); + if (markPos != -1) { + setFormat(markPos + 12, text.length(), Qt::blue); + } + + markPos = text.indexOf("using profile"); + if (markPos != -1) { + setFormat(markPos + 13, text.length(), Qt::blue); + } +} diff --git a/src/src/loghighlighter.h b/src/src/loghighlighter.h new file mode 100644 index 0000000..d807004 --- /dev/null +++ b/src/src/loghighlighter.h @@ -0,0 +1,43 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef LOGHIGHLIGHTER_H +#define LOGHIGHLIGHTER_H + +#include + +/** + * @brief Syntax highlighter to make log files from mo.dll more readable. + * @note this is currently not used! + **/ +class LogHighlighter : public QSyntaxHighlighter +{ + Q_OBJECT +public: + explicit LogHighlighter(QObject* parent = 0); + +signals: + +public slots: + +protected: + virtual void highlightBlock(const QString& text); +}; + +#endif // LOGHIGHLIGHTER_H diff --git a/src/src/loglist.cpp b/src/src/loglist.cpp new file mode 100644 index 0000000..34df015 --- /dev/null +++ b/src/src/loglist.cpp @@ -0,0 +1,413 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "loglist.h" +#include "copyeventfilter.h" +#include "env.h" +#include "organizercore.h" +#include + +using namespace MOBase; + +static LogModel* g_instance = nullptr; +const std::size_t MaxLines = 1000; + +static std::unique_ptr m_console; +static bool m_stdout = false; +static std::mutex m_stdoutMutex; + +LogModel::LogModel() {} + +void LogModel::create() +{ + g_instance = new LogModel; +} + +LogModel& LogModel::instance() +{ + return *g_instance; +} + +void LogModel::add(MOBase::log::Entry e) +{ + QMetaObject::invokeMethod( + this, + [this, e] { + onEntryAdded(std::move(e)); + }, + Qt::QueuedConnection); +} + +QString LogModel::formattedMessage(const QModelIndex& index) const +{ + if (!index.isValid()) { + return ""; + } + return QString::fromStdString(m_entries[index.row()].formattedMessage); +} + +void LogModel::clear() +{ + beginResetModel(); + m_entries.clear(); + endResetModel(); +} + +const std::deque& LogModel::entries() const +{ + return m_entries; +} + +void LogModel::onEntryAdded(MOBase::log::Entry e) +{ + bool full = false; + if (m_entries.size() > MaxLines) { + m_entries.pop_front(); + full = true; + } + + const int row = static_cast(m_entries.size()); + + if (!full) { + beginInsertRows(QModelIndex(), row, row + 1); + } + + m_entries.emplace_back(std::move(e)); + + if (!full) { + endInsertRows(); + } else { + emit dataChanged(createIndex(row, 0), createIndex(row + 1, columnCount({}))); + } +} + +QModelIndex LogModel::index(int row, int column, const QModelIndex&) const +{ + return createIndex(row, column, row); +} + +QModelIndex LogModel::parent(const QModelIndex&) const +{ + return QModelIndex(); +} + +int LogModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) + return 0; + else + return static_cast(m_entries.size()); +} + +int LogModel::columnCount(const QModelIndex&) const +{ + return 3; +} + +QVariant LogModel::data(const QModelIndex& index, int role) const +{ + using namespace std::chrono; + + const auto row = static_cast(index.row()); + if (row >= m_entries.size()) { + return {}; + } + + const auto& e = m_entries[row]; + + if (role == Qt::DisplayRole) { + if (index.column() == 0) { + const auto ms = duration_cast(e.time.time_since_epoch()); + const auto s = duration_cast(ms); + + const std::time_t tt = s.count(); + const int frac = static_cast(ms.count() % 1000); + + const auto time = QDateTime::fromSecsSinceEpoch(tt).time().addMSecs(frac); + return time.toString("hh:mm:ss.zzz"); + } else if (index.column() == 2) { + return QString::fromStdString(e.message); + } + } + + if (role == Qt::DecorationRole) { + if (index.column() == 1) { + switch (e.level) { + case log::Warning: + return QIcon(":/MO/gui/warning"); + + case log::Error: + return QIcon(":/MO/gui/problem"); + + case log::Debug: + return QIcon(":/MO/gui/debug"); + case log::Info: + return QIcon(":/MO/gui/information"); + default: + return {}; + } + } + } + + return QVariant(); +} + +QVariant LogModel::headerData(int, Qt::Orientation, int) const +{ + return {}; +} + +LogList::LogList(QWidget* parent) + : QTreeView(parent), m_core(nullptr), m_copyFilter(this, [=](auto&& index) { + return LogModel::instance().formattedMessage(index); + }) +{ + setModel(&LogModel::instance()); + + header()->setMinimumSectionSize(0); + header()->resizeSection(1, 20); + header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + + setAutoScroll(true); + scrollToBottom(); + + connect(this, &QWidget::customContextMenuRequested, [&](auto&& pos) { + onContextMenu(pos); + }); + + connect(model(), &LogModel::rowsInserted, this, [&] { + onNewEntry(); + }); + connect(model(), &LogModel::dataChanged, this, [&] { + onNewEntry(); + }); + + m_timer.setSingleShot(true); + connect(&m_timer, &QTimer::timeout, [&] { + scrollToBottom(); + }); + + installEventFilter(&m_copyFilter); +} + +void LogList::onNewEntry() +{ + m_timer.start(std::chrono::milliseconds(10)); +} + +void LogList::setCore(OrganizerCore& core) +{ + m_core = &core; +} + +void LogList::copyToClipboard() +{ + std::string s; + + for (const auto& e : LogModel::instance().entries()) { + s += e.formattedMessage + "\n"; + } + + if (!s.empty()) { + // last newline + s.pop_back(); + } + + QApplication::clipboard()->setText(QString::fromStdString(s)); +} + +void LogList::clear() +{ + static_cast(model())->clear(); +} + +void LogList::openLogsFolder() +{ + QString logsPath = qApp->property("dataPath").toString() + "/" + + QString::fromStdWString(AppConfig::logPath()); + shell::Explore(logsPath); +} + +QMenu* LogList::createMenu(QWidget* parent) +{ + auto* menu = new QMenu(parent); + + menu->addAction(tr("Copy"), [&] { + m_copyFilter.copySelection(); + }); + menu->addAction(tr("&Copy all"), [&] { + copyToClipboard(); + }); + menu->addSeparator(); + menu->addAction(tr("C&lear all"), [&] { + clear(); + }); + menu->addAction(tr("&Open logs folder"), [&] { + openLogsFolder(); + }); + + auto* levels = new QMenu(tr("&Level")); + menu->addMenu(levels); + + auto* ag = new QActionGroup(menu); + + auto addAction = [&](auto&& text, auto&& level) { + auto* a = new QAction(text, ag); + + a->setCheckable(true); + a->setChecked(log::getDefault().level() == level); + + connect(a, &QAction::triggered, [this, level] { + if (m_core) { + m_core->setLogLevel(level); + } + }); + + levels->addAction(a); + }; + + addAction(tr("&Debug"), log::Debug); + addAction(tr("&Info"), log::Info); + addAction(tr("&Warnings"), log::Warning); + addAction(tr("&Errors"), log::Error); + + return menu; +} + +void LogList::onContextMenu(const QPoint& pos) +{ + auto* menu = createMenu(this); + menu->popup(viewport()->mapToGlobal(pos)); +} + +log::Levels convertQtLevel(QtMsgType t) +{ + switch (t) { + case QtDebugMsg: + return log::Debug; + + case QtWarningMsg: + return log::Warning; + + case QtCriticalMsg: // fall-through + case QtFatalMsg: + return log::Error; + + case QtInfoMsg: // fall-through + default: + return log::Info; + } +} + +void qtLogCallback(QtMsgType type, const QMessageLogContext& context, + const QString& message) +{ + std::string_view file = ""; + + if (type != QtDebugMsg) { + if (context.file) { + file = context.file; + + const auto lastSep = file.find_last_of("/\\"); + if (lastSep != std::string_view::npos) { + file = {context.file + lastSep + 1}; + } + } + } + + if (file.empty()) { + log::log(convertQtLevel(type), "{}", message.toStdString()); + } else { + log::log(convertQtLevel(type), "[{}:{}] {}", file, context.line, + message.toStdString()); + } +} + +void logToStdout(bool b) +{ + m_stdout = b; + + // logging to stdout is already set up in uibase by log::createDefault(), + // all it needs is to redirect stdout to the console, which is done by + // creating an env::Console object + + if (m_stdout) { + m_console.reset(new env::Console); + } else { + m_console.reset(); + } +} + +void initLogging() +{ + LogModel::create(); + + log::LoggerConfiguration conf; + conf.maxLevel = MOBase::log::Debug; + conf.pattern = "%^[%Y-%m-%d %H:%M:%S.%e %L] %v%$"; + conf.utc = true; + + log::createDefault(conf); + + log::getDefault().setCallback([](log::Entry e) { + LogModel::instance().add(e); + }); + + const char* username = std::getenv("USERNAME"); + if (username == nullptr || username[0] == '\0') { + username = std::getenv("USER"); + } + + if (username != nullptr && username[0] != '\0') { + log::getDefault().addToBlacklist(std::string("\\") + username, "\\USERNAME"); + log::getDefault().addToBlacklist(std::string("/") + username, "/USERNAME"); + } + + qInstallMessageHandler(qtLogCallback); +} + +bool createAndMakeWritable(const std::wstring& subPath) +{ + QString const dataPath = qApp->property("dataPath").toString(); + QString fullPath = dataPath + "/" + QString::fromStdWString(subPath); + + if (!QDir(fullPath).exists() && !QDir().mkdir(fullPath)) { + QMessageBox::critical(nullptr, QObject::tr("Error"), + QObject::tr("Failed to create \"%1\". Your user " + "account probably lacks permission.") + .arg(fullPath)); + return false; + } else { + return true; + } +} + +bool setLogDirectory(const QString& dir) +{ + const auto logFile = dir + "/" + QString::fromStdWString(AppConfig::logPath()) + "/" + + QString::fromStdWString(AppConfig::logFileName()); + + if (!createAndMakeWritable(AppConfig::logPath())) { + return false; + } + + log::getDefault().setFile(MOBase::log::File::single(logFile.toStdWString())); + + return true; +} diff --git a/src/src/loglist.h b/src/src/loglist.h new file mode 100644 index 0000000..93eb78a --- /dev/null +++ b/src/src/loglist.h @@ -0,0 +1,91 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef LOGBUFFER_H +#define LOGBUFFER_H + +#include "copyeventfilter.h" +#include "shared/appconfig.h" +#include +#include +#include + +class OrganizerCore; + +class LogModel : public QAbstractItemModel +{ + Q_OBJECT + +public: + static void create(); + static LogModel& instance(); + + void add(MOBase::log::Entry e); + void clear(); + + const std::deque& entries() const; + + QString formattedMessage(const QModelIndex& index) const; + +protected: + QModelIndex index(int row, int column, const QModelIndex& parent) const override; + QModelIndex parent(const QModelIndex& child) const override; + int rowCount(const QModelIndex& parent) const override; + int columnCount(const QModelIndex& parent) const override; + QVariant data(const QModelIndex& index, int role) const override; + + QVariant headerData(int section, Qt::Orientation ori, + int role = Qt::DisplayRole) const override; + +private: + std::deque m_entries; + + LogModel(); + void onEntryAdded(MOBase::log::Entry e); +}; + +class LogList : public QTreeView +{ + Q_OBJECT; + +public: + LogList(QWidget* parent = nullptr); + + void setCore(OrganizerCore& core); + + void copyToClipboard(); + void clear(); + void openLogsFolder(); + + QMenu* createMenu(QWidget* parent = nullptr); + +private: + OrganizerCore* m_core; + QTimer m_timer; + CopyEventFilter m_copyFilter; + + void onContextMenu(const QPoint& pos); + void onNewEntry(); +}; + +void logToStdout(bool b); +void initLogging(); +bool setLogDirectory(const QString& dir); + +#endif // LOGBUFFER_H diff --git a/src/src/loot.cpp b/src/src/loot.cpp new file mode 100644 index 0000000..2454a34 --- /dev/null +++ b/src/src/loot.cpp @@ -0,0 +1,943 @@ +#include "loot.h" +#include "json.h" +#include "lootdialog.h" +#include "organizercore.h" +#include "spawn.h" +#include +#include + +using namespace MOBase; +using namespace json; + +static QString LootReportPath = QDir::temp().absoluteFilePath("lootreport.json"); +static const DWORD PipeTimeout = 500; + +#ifdef _WIN32 +class AsyncPipe +{ +public: + AsyncPipe() : m_ioPending(false) + { + std::fill(std::begin(m_buffer), std::end(m_buffer), 0); + std::memset(&m_ov, 0, sizeof(m_ov)); + } + + env::HandlePtr create() + { + // creating pipe + env::HandlePtr out(createPipe()); + if (out.get() == INVALID_HANDLE_VALUE) { + return {}; + } + + HANDLE readEventHandle = ::CreateEvent(nullptr, TRUE, FALSE, nullptr); + + if (readEventHandle == NULL) { + const auto e = GetLastError(); + log::error("CreateEvent failed for loot, {}", formatSystemMessage(e)); + return {}; + } + + m_ov.hEvent = readEventHandle; + m_readEvent.reset(readEventHandle); + + return out; + } + + std::string read() + { + if (m_ioPending) { + return checkPending(); + } else { + return tryRead(); + } + } + +private: + static const std::size_t bufferSize = 50000; + + env::HandlePtr m_stdout; + env::HandlePtr m_readEvent; + char m_buffer[bufferSize]; + OVERLAPPED m_ov; + bool m_ioPending; + + HANDLE createPipe() + { + static const wchar_t* PipeName = L"\\\\.\\pipe\\lootcli_pipe"; + + SECURITY_ATTRIBUTES sa = {}; + sa.nLength = sizeof(SECURITY_ATTRIBUTES); + sa.bInheritHandle = TRUE; + + env::HandlePtr pipe; + + // creating pipe + { + HANDLE pipeHandle = + ::CreateNamedPipe(PipeName, PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, 1, 50'000, + 50'000, PipeTimeout, &sa); + + if (pipeHandle == INVALID_HANDLE_VALUE) { + const auto e = GetLastError(); + log::error("CreateNamedPipe failed, {}", formatSystemMessage(e)); + return INVALID_HANDLE_VALUE; + } + + pipe.reset(pipeHandle); + } + + { + // duplicating the handle to read from it + HANDLE outputRead = INVALID_HANDLE_VALUE; + + const auto r = + DuplicateHandle(GetCurrentProcess(), pipe.get(), GetCurrentProcess(), + &outputRead, 0, TRUE, DUPLICATE_SAME_ACCESS); + + if (!r) { + const auto e = GetLastError(); + log::error("DuplicateHandle for pipe failed, {}", formatSystemMessage(e)); + return INVALID_HANDLE_VALUE; + } + + m_stdout.reset(outputRead); + } + + // creating handle to pipe which is passed to CreateProcess() + HANDLE outputWrite = ::CreateFileW(PipeName, FILE_WRITE_DATA | SYNCHRONIZE, 0, &sa, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0); + + if (outputWrite == INVALID_HANDLE_VALUE) { + const auto e = GetLastError(); + log::error("CreateFileW for pipe failed, {}", formatSystemMessage(e)); + return INVALID_HANDLE_VALUE; + } + + return outputWrite; + } + + std::string tryRead() + { + DWORD bytesRead = 0; + + if (!::ReadFile(m_stdout.get(), m_buffer, bufferSize, &bytesRead, &m_ov)) { + const auto e = GetLastError(); + + switch (e) { + case ERROR_IO_PENDING: { + m_ioPending = true; + break; + } + + case ERROR_BROKEN_PIPE: { + // broken pipe probably means lootcli is finished + break; + } + + default: { + log::error("{}", formatSystemMessage(e)); + break; + } + } + + return {}; + } + + return {m_buffer, m_buffer + bytesRead}; + } + + std::string checkPending() + { + DWORD bytesRead = 0; + + const auto r = WaitForSingleObject(m_readEvent.get(), PipeTimeout); + + if (r == WAIT_FAILED) { + const auto e = GetLastError(); + log::error("WaitForSingleObject in AsyncPipe failed, {}", formatSystemMessage(e)); + return {}; + } + + if (!::GetOverlappedResult(m_stdout.get(), &m_ov, &bytesRead, FALSE)) { + const auto e = GetLastError(); + + switch (e) { + case ERROR_IO_INCOMPLETE: { + break; + } + + case WAIT_TIMEOUT: { + break; + } + + case ERROR_BROKEN_PIPE: { + // broken pipe probably means lootcli is finished + break; + } + + default: { + log::error("GetOverlappedResult failed, {}", formatSystemMessage(e)); + break; + } + } + + return {}; + } + + ::ResetEvent(m_readEvent.get()); + m_ioPending = false; + + return {m_buffer, m_buffer + bytesRead}; + } +}; +#else +// Linux stub: loot is not supported on Linux yet +class AsyncPipe +{ +public: + env::HandlePtr create() { return {}; } + std::string read() { return {}; } +}; +#endif + +log::Levels levelFromLoot(lootcli::LogLevels level) +{ + using LC = lootcli::LogLevels; + + switch (level) { + case LC::Trace: // fall-through + case LC::Debug: + return log::Debug; + + case LC::Info: + return log::Info; + + case LC::Warning: + return log::Warning; + + case LC::Error: + return log::Error; + + default: + return log::Info; + } +} + +QString Loot::Report::toMarkdown() const +{ + QString s; + + if (!okay) { + s += "## " + tr("Loot failed to run") + "\n"; + + if (errors.empty() && warnings.empty()) { + s += tr("No errors were reported. The log below might have more information.\n"); + } + } + + s += errorsMarkdown(); + + if (okay) { + s += "\n" + successMarkdown(); + } + + return s; +} + +QString Loot::Report::successMarkdown() const +{ + QString s; + + if (!messages.empty()) { + s += "### " + QObject::tr("General messages") + "\n"; + + for (auto&& m : messages) { + s += " - " + m.toMarkdown() + "\n"; + } + } + + if (!plugins.empty()) { + if (!s.isEmpty()) { + s += "\n"; + } + + s += "### " + QObject::tr("Plugins") + "\n"; + + for (auto&& p : plugins) { + const auto ps = p.toMarkdown(); + if (!ps.isEmpty()) { + s += ps + "\n"; + } + } + } + + if (s.isEmpty()) { + s += "**" + QObject::tr("No messages.") + "**\n"; + } + + s += stats.toMarkdown(); + + return s; +} + +QString Loot::Report::errorsMarkdown() const +{ + QString s; + + if (!errors.empty()) { + s += "### " + tr("Errors") + ":\n"; + + for (auto&& e : errors) { + s += " - " + e + "\n"; + } + } + + if (!warnings.empty()) { + if (!s.isEmpty()) { + s += "\n"; + } + + s += "### " + tr("Warnings") + ":\n"; + + for (auto&& w : warnings) { + s += " - " + w + "\n"; + } + } + + return s; +} + +QString Loot::Stats::toMarkdown() const +{ + return QString("`stats: %1s, lootcli %2, loot %3`") + .arg(QString::number(time / 1000.0, 'f', 2)) + .arg(lootcliVersion) + .arg(lootVersion); +} + +QString Loot::Plugin::toMarkdown() const +{ + QString s; + + if (!incompatibilities.empty()) { + s += " - **" + QObject::tr("Incompatibilities") + ": "; + + QString fs; + for (auto&& f : incompatibilities) { + if (!fs.isEmpty()) { + fs += ", "; + } + + fs += f.displayName.isEmpty() ? f.name : f.displayName; + } + + s += fs + "**\n"; + } + + if (!missingMasters.empty()) { + s += " - **" + QObject::tr("Missing masters") + ": "; + + QString ms; + for (auto&& m : missingMasters) { + if (!ms.isEmpty()) { + ms += ", "; + } + + ms += m; + } + + s += ms + "**\n"; + } + + for (auto&& m : messages) { + s += " - " + m.toMarkdown() + "\n"; + } + + for (auto&& d : dirty) { + s += " - " + d.toMarkdown(false) + "\n"; + } + + if (!s.isEmpty()) { + s = "#### " + name + "\n" + s; + } + + return s; +} + +QString Loot::Dirty::toString(bool isClean) const +{ + if (isClean) { + return QObject::tr("Verified clean by %1") + .arg(cleaningUtility.isEmpty() ? "?" : cleaningUtility); + } + + QString s = cleaningString(); + + if (!info.isEmpty()) { + s += " " + info; + } + + return s; +} + +QString Loot::Dirty::toMarkdown(bool isClean) const +{ + return toString(isClean); +} + +QString Loot::Dirty::cleaningString() const +{ + return QObject::tr("%1 found %2 ITM record(s), %3 deleted reference(s) and %4 " + "deleted navmesh(es).") + .arg(cleaningUtility.isEmpty() ? "?" : cleaningUtility) + .arg(itm) + .arg(deletedReferences) + .arg(deletedNavmesh); +} + +QString Loot::Message::toMarkdown() const +{ + QString s; + + switch (type) { + case log::Error: { + s += "**" + QObject::tr("Error") + "**: "; + break; + } + + case log::Warning: { + s += "**" + QObject::tr("Warning") + "**: "; + break; + } + + default: { + break; + } + } + + s += text; + + return s; +} + +Loot::Loot(OrganizerCore& core) + : m_core(core), m_thread(nullptr), m_cancel(false), m_result(false) +{} + +Loot::~Loot() +{ + if (m_thread) { + m_thread->wait(); + } + + deleteReportFile(); +} + +bool Loot::start(QWidget* parent, bool didUpdateMasterList) +{ + deleteReportFile(); + + log::debug("starting loot"); + + m_pipe.reset(new AsyncPipe); + + env::HandlePtr stdoutHandle = m_pipe->create(); + if (!stdoutHandle) { + return false; + } + + // vfs + m_core.prepareVFS(); + + // spawning + if (!spawnLootcli(parent, didUpdateMasterList, std::move(stdoutHandle))) { + return false; + } + + // starting thread + log::debug("starting loot thread"); + m_thread.reset(QThread::create([&] { + lootThread(); + })); + m_thread->start(); + + return true; +} + +bool Loot::spawnLootcli(QWidget* parent, bool didUpdateMasterList, + env::HandlePtr stdoutHandle) +{ +#ifdef _WIN32 + const auto logLevel = m_core.settings().diagnostics().lootLogLevel(); + + QStringList parameters; + parameters << "--game" << m_core.managedGame()->lootGameName() + + << "--gamePath" + << QString("\"%1\"").arg( + m_core.managedGame()->gameDirectory().absolutePath()) + + << "--pluginListPath" + << QString("\"%1/loadorder.txt\"").arg(m_core.profilePath()) + + << "--logLevel" + << QString::fromStdString(lootcli::logLevelToString(logLevel)) + + << "--out" << QString("\"%1\"").arg(LootReportPath) + + << "--language" << m_core.settings().interface().language(); + + if (didUpdateMasterList) { + parameters << "--skipUpdateMasterlist"; + } + + spawn::SpawnParameters sp; + sp.binary = QFileInfo(qApp->applicationDirPath() + "/loot/lootcli.exe"); + sp.arguments = parameters.join(" "); + sp.currentDirectory.setPath(qApp->applicationDirPath() + "/loot"); + sp.hooked = true; + sp.stdOut = stdoutHandle.get(); + + HANDLE lootHandle = spawn::startBinary(parent, sp); + + if (lootHandle == INVALID_HANDLE_VALUE) { + emit log(log::Levels::Error, tr("failed to start loot")); + return false; + } + + m_lootProcess.reset(lootHandle); + + return true; +#else + Q_UNUSED(parent); + Q_UNUSED(didUpdateMasterList); + Q_UNUSED(stdoutHandle); + emit log(log::Levels::Error, tr("loot is not supported on Linux")); + return false; +#endif +} + +void Loot::cancel() +{ + if (!m_cancel) { + log::debug("loot received cancel request"); + m_cancel = true; + } +} + +bool Loot::result() const +{ + return m_result; +} + +const QString& Loot::outPath() const +{ + return LootReportPath; +} + +const Loot::Report& Loot::report() const +{ + return m_report; +} + +const std::vector& Loot::errors() const +{ + return m_errors; +} + +const std::vector& Loot::warnings() const +{ + return m_warnings; +} + +void Loot::lootThread() +{ + try { + m_result = false; + + if (waitForCompletion()) { + m_result = true; + } + + m_report = createReport(); + } catch (...) { + log::error("unhandled exception in loot thread"); + } + + log::debug("finishing loot thread"); + emit finished(); +} + +bool Loot::waitForCompletion() +{ +#ifdef _WIN32 + bool terminating = false; + + log::debug("loot thread waiting for completion on lootcli"); + + for (;;) { + DWORD res = WaitForSingleObject(m_lootProcess.get(), 100); + + if (res == WAIT_OBJECT_0) { + log::debug("lootcli has completed"); + // done + break; + } + + if (res == WAIT_FAILED) { + const auto e = GetLastError(); + log::error("failed to wait on loot process, {}", formatSystemMessage(e)); + return false; + } + + if (m_cancel) { + log::debug("terminating lootcli process"); + ::TerminateProcess(m_lootProcess.get(), 1); + + log::debug("waiting for loocli process to terminate"); + WaitForSingleObject(m_lootProcess.get(), INFINITE); + + log::debug("lootcli terminated"); + return false; + } + + processStdout(m_pipe->read()); + } + + if (m_cancel) { + return false; + } + + processStdout(m_pipe->read()); + + // checking exit code + DWORD exitCode = 0; + + if (!::GetExitCodeProcess(m_lootProcess.get(), &exitCode)) { + const auto e = GetLastError(); + log::error("failed to get exit code for loot, {}", formatSystemMessage(e)); + return false; + } + + if (exitCode != 0UL) { + emit log(log::Levels::Error, + tr("Loot failed. Exit code was: 0x%1").arg(exitCode, 0, 16)); + return false; + } + + return true; +#else + return false; +#endif +} + +void Loot::processStdout(const std::string& lootOut) +{ + emit output(QString::fromStdString(lootOut)); + + m_outputBuffer += lootOut; + if (m_outputBuffer.empty()) { + return; + } + + std::size_t start = 0; + + for (;;) { + const auto newline = m_outputBuffer.find("\n", start); + if (newline == std::string::npos) { + break; + } + + const std::string_view line(m_outputBuffer.c_str() + start, newline - start); + const auto m = lootcli::parseMessage(line); + + if (m.type == lootcli::MessageType::None) { + log::error("unrecognised loot output: '{}'", line); + continue; + } + + processMessage(m); + + start = newline + 1; + } + + m_outputBuffer.erase(0, start); +} + +void Loot::processMessage(const lootcli::Message& m) +{ + switch (m.type) { + case lootcli::MessageType::Log: { + const auto level = levelFromLoot(m.logLevel); + + if (level == log::Error) { + m_errors.push_back(QString::fromStdString(m.log)); + } else if (level == log::Warning) { + m_warnings.push_back(QString::fromStdString(m.log)); + } + + emit log(level, QString::fromStdString(m.log)); + break; + } + + case lootcli::MessageType::Progress: { + emit progress(m.progress); + break; + } + } +} + +Loot::Report Loot::createReport() const +{ + Report r; + + r.okay = m_result; + r.errors = m_errors; + r.warnings = m_warnings; + + if (m_result) { + processOutputFile(r); + } + + return r; +} + +void Loot::deleteReportFile() +{ + if (QFile::exists(LootReportPath)) { + log::debug("deleting temporary loot report '{}'", LootReportPath); + const auto r = shell::Delete(QFileInfo(LootReportPath)); + + if (!r) { + log::error("failed to remove temporary loot json report '{}': {}", LootReportPath, + r.toString()); + } + } +} + +void Loot::processOutputFile(Report& r) const +{ + log::debug("parsing json output file at '{}'", LootReportPath); + + QFile outFile(LootReportPath); + if (!outFile.open(QIODevice::ReadOnly)) { + emit log(MOBase::log::Error, QString("failed to open file, %1 (error %2)") + .arg(outFile.errorString()) + .arg(outFile.error())); + + return; + } + + QJsonParseError e; + const QJsonDocument doc = QJsonDocument::fromJson(outFile.readAll(), &e); + if (doc.isNull()) { + emit log(MOBase::log::Error, + QString("invalid json, %1 (error %2)").arg(e.errorString()).arg(e.error)); + + return; + } + + requireObject(doc, "root"); + + const QJsonObject object = doc.object(); + + r.messages = reportMessages(getOpt(object, "messages")); + r.plugins = reportPlugins(getOpt(object, "plugins")); + r.stats = reportStats(getWarn(object, "stats")); +} + +std::vector Loot::reportPlugins(const QJsonArray& plugins) const +{ + std::vector v; + + for (auto pluginValue : plugins) { + const auto o = convertWarn(pluginValue, "plugin"); + if (o.isEmpty()) { + continue; + } + + auto p = reportPlugin(o); + if (!p.name.isEmpty()) { + v.emplace_back(std::move(p)); + } + } + + return v; +} + +Loot::Plugin Loot::reportPlugin(const QJsonObject& plugin) const +{ + Plugin p; + + p.name = getWarn(plugin, "name"); + if (p.name.isEmpty()) { + return {}; + } + + // ignore disabled plugins; lootcli doesn't know if a plugin is enabled or not + // and will report information on any plugin that's in the filesystem + if (!m_core.pluginList()->isEnabled(p.name)) { + return {}; + } + + if (plugin.contains("incompatibilities")) { + p.incompatibilities = reportFiles(getOpt(plugin, "incompatibilities")); + } + + if (plugin.contains("messages")) { + p.messages = reportMessages(getOpt(plugin, "messages")); + } + + if (plugin.contains("dirty")) { + p.dirty = reportDirty(getOpt(plugin, "dirty")); + } + + if (plugin.contains("clean")) { + p.clean = reportDirty(getOpt(plugin, "clean")); + } + + if (plugin.contains("missingMasters")) { + p.missingMasters = reportStringArray(getOpt(plugin, "missingMasters")); + } + + p.loadsArchive = getOpt(plugin, "loadsArchive", false); + p.isMaster = getOpt(plugin, "isMaster", false); + p.isLightMaster = getOpt(plugin, "isLightMaster", false); + + return p; +} + +Loot::Stats Loot::reportStats(const QJsonObject& stats) const +{ + Stats s; + + s.time = getWarn(stats, "time"); + s.lootcliVersion = getWarn(stats, "lootcliVersion"); + s.lootVersion = getWarn(stats, "lootVersion"); + + return s; +} + +std::vector Loot::reportMessages(const QJsonArray& array) const +{ + std::vector v; + + for (auto messageValue : array) { + const auto o = convertWarn(messageValue, "message"); + if (o.isEmpty()) { + continue; + } + + Message m; + + const auto type = getWarn(o, "type"); + + if (type == "info") { + m.type = log::Info; + } else if (type == "warn") { + m.type = log::Warning; + } else if (type == "error") { + m.type = log::Error; + } else { + log::error("unknown message type '{}'", type); + m.type = log::Info; + } + + m.text = getWarn(o, "text"); + + if (!m.text.isEmpty()) { + v.emplace_back(std::move(m)); + } + } + + return v; +} + +std::vector Loot::reportFiles(const QJsonArray& array) const +{ + std::vector v; + + for (auto&& fileValue : array) { + const auto o = convertWarn(fileValue, "file"); + if (o.isEmpty()) { + continue; + } + + File f; + + f.name = getWarn(o, "name"); + f.displayName = getOpt(o, "displayName"); + + if (!f.name.isEmpty()) { + v.emplace_back(std::move(f)); + } + } + + return v; +} + +std::vector Loot::reportDirty(const QJsonArray& array) const +{ + std::vector v; + + for (auto&& dirtyValue : array) { + const auto o = convertWarn(dirtyValue, "dirty"); + + Dirty d; + + d.crc = getWarn(o, "crc"); + d.itm = getOpt(o, "itm"); + d.deletedReferences = getOpt(o, "deletedReferences"); + d.deletedNavmesh = getOpt(o, "deletedNavmesh"); + d.cleaningUtility = getOpt(o, "cleaningUtility"); + d.info = getOpt(o, "info"); + + v.emplace_back(std::move(d)); + } + + return v; +} + +std::vector Loot::reportStringArray(const QJsonArray& array) const +{ + std::vector v; + + for (auto&& sv : array) { + auto s = convertWarn(sv, "string"); + if (s.isEmpty()) { + continue; + } + + v.emplace_back(std::move(s)); + } + + return v; +} + +bool runLoot(QWidget* parent, OrganizerCore& core, bool didUpdateMasterList) +{ + core.savePluginList(); + + try { + Loot loot(core); + LootDialog dialog(parent, core, loot); + + if (!loot.start(parent, didUpdateMasterList)) { + return false; + } + + dialog.exec(); + + return dialog.result(); +#ifdef _WIN32 + } catch (const UsvfsConnectorException& e) { + log::debug("{}", e.what()); + return false; +#endif + } catch (const std::exception& e) { + reportError(QObject::tr("failed to run loot: %1").arg(e.what())); + return false; + } +} diff --git a/src/src/loot.h b/src/src/loot.h new file mode 100644 index 0000000..1ba31cf --- /dev/null +++ b/src/src/loot.h @@ -0,0 +1,144 @@ +#ifndef MODORGANIZER_LOOT_H +#define MODORGANIZER_LOOT_H + +#include "envmodule.h" +#include +#include +#include +#ifdef _WIN32 +#include +#endif + +Q_DECLARE_METATYPE(lootcli::Progress); +Q_DECLARE_METATYPE(MOBase::log::Levels); + +class OrganizerCore; +class AsyncPipe; + +class Loot : public QObject +{ + Q_OBJECT; + +public: + struct Message + { + MOBase::log::Levels type; + QString text; + + QString toMarkdown() const; + }; + + struct File + { + QString name; + QString displayName; + }; + + struct Dirty + { + qint64 crc = 0; + qint64 itm = 0; + qint64 deletedReferences = 0; + qint64 deletedNavmesh = 0; + QString cleaningUtility; + QString info; + + QString toString(bool isClean) const; + QString toMarkdown(bool isClean) const; + QString cleaningString() const; + }; + + struct Plugin + { + QString name; + std::vector incompatibilities; + std::vector messages; + std::vector dirty, clean; + std::vector missingMasters; + bool loadsArchive = false; + bool isMaster = false; + bool isLightMaster = false; + + QString toMarkdown() const; + }; + + struct Stats + { + qint64 time = 0; + QString lootcliVersion; + QString lootVersion; + + QString toMarkdown() const; + }; + + struct Report + { + bool okay = false; + std::vector errors, warnings; + std::vector messages; + std::vector plugins; + Stats stats; + + QString toMarkdown() const; + + private: + QString successMarkdown() const; + QString errorsMarkdown() const; + }; + + Loot(OrganizerCore& core); + ~Loot(); + + bool start(QWidget* parent, bool didUpdateMasterList); + void cancel(); + bool result() const; + + const QString& outPath() const; + const Report& report() const; + const std::vector& errors() const; + const std::vector& warnings() const; + +signals: + void output(const QString& s); + void progress(const lootcli::Progress p); + void log(MOBase::log::Levels level, const QString& s) const; + void finished(); + +private: + OrganizerCore& m_core; + std::unique_ptr m_thread; + std::atomic m_cancel; + std::atomic m_result; + env::HandlePtr m_lootProcess; + std::unique_ptr m_pipe; + std::string m_outputBuffer; + std::vector m_errors, m_warnings; + Report m_report; + + bool spawnLootcli(QWidget* parent, bool didUpdateMasterList, + env::HandlePtr stdoutHandle); + + void lootThread(); + bool waitForCompletion(); + + void processStdout(const std::string& lootOut); + void processMessage(const lootcli::Message& m); + + Report createReport() const; + void processOutputFile(Report& r) const; + void deleteReportFile(); + + Message reportMessage(const QJsonObject& message) const; + std::vector reportPlugins(const QJsonArray& plugins) const; + Loot::Plugin reportPlugin(const QJsonObject& plugin) const; + Loot::Stats reportStats(const QJsonObject& stats) const; + + std::vector reportMessages(const QJsonArray& array) const; + std::vector reportFiles(const QJsonArray& array) const; + std::vector reportDirty(const QJsonArray& array) const; + std::vector reportStringArray(const QJsonArray& array) const; +}; + +bool runLoot(QWidget* parent, OrganizerCore& core, bool didUpdateMasterList); + +#endif // MODORGANIZER_LOOT_H diff --git a/src/src/lootdialog.cpp b/src/src/lootdialog.cpp new file mode 100644 index 0000000..e4fd46b --- /dev/null +++ b/src/src/lootdialog.cpp @@ -0,0 +1,294 @@ +#include "lootdialog.h" +#include "loot.h" +#include "organizercore.h" +#include "ui_lootdialog.h" +#ifdef MO2_WEBENGINE +#include +#endif +#include + +using namespace MOBase; + +QString progressToString(lootcli::Progress p) +{ + using P = lootcli::Progress; + + static const std::map map = { + {P::CheckingMasterlistExistence, QObject::tr("Checking masterlist existence")}, + {P::UpdatingMasterlist, QObject::tr("Updating masterlist")}, + {P::LoadingLists, QObject::tr("Loading lists")}, + {P::ReadingPlugins, QObject::tr("Reading plugins")}, + {P::SortingPlugins, QObject::tr("Sorting plugins")}, + {P::WritingLoadorder, QObject::tr("Writing loadorder.txt")}, + {P::ParsingLootMessages, QObject::tr("Parsing loot messages")}, + {P::Done, QObject::tr("Done")}}; + + auto itor = map.find(p); + if (itor == map.end()) { + return QString("unknown progress %1").arg(static_cast(p)); + } else { + return itor->second; + } +} + +MarkdownDocument::MarkdownDocument(QObject* parent) : QObject(parent) {} + +void MarkdownDocument::setText(const QString& text) +{ + if (m_text == text) + return; + + m_text = text; + emit textChanged(m_text); +} + +#ifdef MO2_WEBENGINE +MarkdownPage::MarkdownPage(QObject* parent) : QWebEnginePage(parent) {} + +bool MarkdownPage::acceptNavigationRequest(const QUrl& url, NavigationType, bool) +{ + static const QStringList allowed = {"qrc", "data"}; + + if (!allowed.contains(url.scheme())) { + shell::Open(url); + return false; + } + + return true; +} +#endif + +LootDialog::LootDialog(QWidget* parent, OrganizerCore& core, Loot& loot) + : QDialog(parent, Qt::WindowMaximizeButtonHint), ui(new Ui::LootDialog), + m_core(core), m_loot(loot), m_finished(false), m_cancelling(false) +{ + createUI(); + + QObject::connect( + &m_loot, &Loot::output, this, + [&](auto&& s) { + addOutput(s); + }, + Qt::QueuedConnection); + + QObject::connect( + &m_loot, &Loot::progress, this, + [&](auto&& p) { + setProgress(p); + }, + Qt::QueuedConnection); + + QObject::connect( + &m_loot, &Loot::log, this, + [&](auto&& lv, auto&& s) { + log(lv, s); + }, + Qt::QueuedConnection); + + QObject::connect( + &m_loot, &Loot::finished, this, + [&] { + onFinished(); + }, + Qt::QueuedConnection); +} + +LootDialog::~LootDialog() = default; + +void LootDialog::setText(const QString& s) +{ + ui->progressText->setText(s); +} + +void LootDialog::setProgress(lootcli::Progress p) +{ + // don't overwrite the "stopping loot" message even if lootcli generates a new + // progress message + if (!m_cancelling) { + setText(progressToString(p)); + } + + if (p == lootcli::Progress::Done) { + ui->progressBar->setRange(0, 1); + ui->progressBar->setValue(1); + } +} + +void LootDialog::addOutput(const QString& s) +{ + if (m_core.settings().diagnostics().lootLogLevel() > lootcli::LogLevels::Debug) { + return; + } + + const auto lines = s.split(QRegularExpression("[\\r\\n]"), Qt::SkipEmptyParts); + + for (auto&& line : lines) { + if (line.isEmpty()) { + continue; + } + + addLineOutput(line); + } +} + +bool LootDialog::result() const +{ + return m_loot.result(); +} + +void LootDialog::cancel() +{ + if (!m_finished && !m_cancelling) { + log::debug("loot dialog: cancelling"); + m_loot.cancel(); + + setText(tr("Stopping LOOT...")); + addLineOutput("stopping loot"); + + ui->buttons->setEnabled(false); + m_cancelling = true; + } +} + +void LootDialog::openReport() +{ + const auto path = m_loot.outPath(); + shell::Open(path); +} + +int LootDialog::exec() +{ + auto& s = m_core.settings(); + + GeometrySaver gs(s, this); + s.geometry().restoreState(&m_expander); + + const auto r = QDialog::exec(); + + s.geometry().saveState(&m_expander); + + return r; +} + +void LootDialog::accept() +{ + // no-op +} + +void LootDialog::reject() +{ + if (m_finished) { + log::debug("loot dialog reject: loot finished, closing"); + QDialog::reject(); + } else { + log::debug("loot dialog reject: not finished, cancelling"); + cancel(); + } +} + +void LootDialog::createUI() +{ + ui->setupUi(this); + ui->progressBar->setMaximum(0); + +#ifdef MO2_WEBENGINE + auto* page = new MarkdownPage(this); + ui->report->setPage(page); + + auto* channel = new QWebChannel(this); + channel->registerObject("content", &m_report); + page->setWebChannel(channel); + + const QString path = QApplication::applicationDirPath() + "/resources/markdown.html"; + QFile f(path); + + if (f.open(QFile::ReadOnly)) { + const QString html = f.readAll(); + if (!html.isEmpty()) { + ui->report->setHtml(html); + } else { + log::error("failed to read '{}', {}", path, f.errorString()); + } + } else { + log::error("can't open '{}', {}", path, f.errorString()); + } +#endif + + m_expander.set(ui->details, ui->detailsPanel); + ui->openJsonReport->setEnabled(false); + connect(ui->openJsonReport, &QPushButton::clicked, [&] { + openReport(); + }); + + ui->buttons->setStandardButtons(QDialogButtonBox::Cancel); + + m_report.setText(tr("Running LOOT...")); + + resize(650, 450); + setSizeGripEnabled(true); +} + +void LootDialog::closeEvent(QCloseEvent* e) +{ + if (m_finished) { + log::debug("loot dialog close event: finished, closing"); + QDialog::closeEvent(e); + } else { + log::debug("loot dialog close event: not finished, cancelling"); + cancel(); + e->ignore(); + } +} + +void LootDialog::addLineOutput(const QString& line) +{ + ui->output->appendPlainText(line); +} + +void LootDialog::onFinished() +{ + log::debug("loot dialog: loot is finished"); + + m_finished = true; + + if (m_cancelling) { + log::debug("loot dialog: was cancelling, closing"); + close(); + } else { + log::debug("loot dialog: showing report"); + + showReport(); + + ui->openJsonReport->setEnabled(true); + ui->buttons->setStandardButtons(QDialogButtonBox::Close); + + // if loot failed, the Done progress won't be received; this makes sure + // the progress bar is stopped + setProgress(lootcli::Progress::Done); + } +} + +void LootDialog::log(log::Levels lv, const QString& s) +{ + if (lv >= log::Levels::Warning) { + log::log(lv, "{}", s); + } + + if (m_core.settings().diagnostics().lootLogLevel() > lootcli::LogLevels::Debug) { + addLineOutput(QString("[%1] %2").arg(log::levelToString(lv)).arg(s)); + } +} + +void LootDialog::showReport() +{ + const auto& lootReport = m_loot.report(); + + if (m_loot.result()) { + m_core.pluginList()->clearAdditionalInformation(); + for (auto&& p : lootReport.plugins) { + m_core.pluginList()->addLootReport(p.name, p); + } + } + + m_report.setText(lootReport.toMarkdown()); +} diff --git a/src/src/lootdialog.h b/src/src/lootdialog.h new file mode 100644 index 0000000..51e0aeb --- /dev/null +++ b/src/src/lootdialog.h @@ -0,0 +1,82 @@ +#ifndef MODORGANIZER_LOOTDIALOG_H +#define MODORGANIZER_LOOTDIALOG_H + +#include +#include +#include + +namespace Ui +{ +class LootDialog; +} + +class OrganizerCore; +class Loot; + +class MarkdownDocument : public QObject +{ + Q_OBJECT; + Q_PROPERTY(QString text MEMBER m_text NOTIFY textChanged FINAL); + +public: + explicit MarkdownDocument(QObject* parent = nullptr); + void setText(const QString& text); + +signals: + void textChanged(const QString& text); + +private: + QString m_text; +}; + +#ifdef MO2_WEBENGINE +class MarkdownPage : public QWebEnginePage +{ + Q_OBJECT; + +public: + explicit MarkdownPage(QObject* parent = nullptr); + +protected: + bool acceptNavigationRequest(const QUrl& url, NavigationType, bool) override; +}; +#endif + +class LootDialog : public QDialog +{ + Q_OBJECT; + +public: + LootDialog(QWidget* parent, OrganizerCore& core, Loot& loot); + ~LootDialog(); + + void setText(const QString& s); + void setProgress(lootcli::Progress p); + + void addOutput(const QString& s); + bool result() const; + void cancel(); + void openReport(); + + int exec() override; + void accept() override; + void reject() override; + +private: + std::unique_ptr ui; + MOBase::ExpanderWidget m_expander; + OrganizerCore& m_core; + Loot& m_loot; + bool m_finished; + bool m_cancelling; + MarkdownDocument m_report; + + void createUI(); + void closeEvent(QCloseEvent* e) override; + void addLineOutput(const QString& line); + void onFinished(); + void log(MOBase::log::Levels lv, const QString& s); + void showReport(); +}; + +#endif // MODORGANIZER_LOOTDIALOG_H diff --git a/src/src/lootdialog.ui b/src/src/lootdialog.ui new file mode 100644 index 0000000..b9e62f1 --- /dev/null +++ b/src/src/lootdialog.ui @@ -0,0 +1,230 @@ + + + LootDialog + + + + 0 + 0 + 457 + 600 + + + + LOOT + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Progress + + + + + + + 24 + + + false + + + + + + + QFrame::StyledPanel + + + QFrame::Sunken + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Details + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Open JSON report + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + + + + buttons + accepted() + LootDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttons + rejected() + LootDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/src/main.cpp b/src/src/main.cpp new file mode 100644 index 0000000..b4c4b6f --- /dev/null +++ b/src/src/main.cpp @@ -0,0 +1,330 @@ +#include "commandline.h" +#include "env.h" +#include "instancemanager.h" +#include "loglist.h" +#include "moapplication.h" +#include "multiprocess.h" +#include "nxmhandler_linux.h" +#include "organizercore.h" +#include "shared/util.h" +#include "thread_utils.h" +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#include +#include +#endif + +using namespace MOBase; + +#ifdef _WIN32 +thread_local LPTOP_LEVEL_EXCEPTION_FILTER g_prevExceptionFilter = nullptr; +thread_local std::terminate_handler g_prevTerminateHandler = nullptr; +#endif + +int run(int argc, char* argv[]); + +int main(int argc, char* argv[]) +{ + const int r = run(argc, argv); + std::cout << "mod organizer done\n"; + return r; +} + +int run(int argc, char* argv[]) +{ +#ifndef _WIN32 + if (argc >= 3 && QString(argv[1]) == "nxm-handle") { + QString nxmUrl = QString::fromLocal8Bit(argv[2]); + if (nxmUrl == "nxm-handle" && argc >= 4) { + nxmUrl = QString::fromLocal8Bit(argv[3]); + } + return NxmHandlerLinux::sendToSocket(nxmUrl) ? 0 : 1; + } +#endif + + MOShared::SetThisThreadName("main"); + setExceptionHandlers(); + + cl::CommandLine cl; +#ifdef _WIN32 + if (auto r = cl.process(GetCommandLineW())) { + return *r; + } +#else + // Build a wstring from argv for the CommandLine parser. + // Each argument must be quoted so that po::split_unix() round-trips + // correctly when paths contain spaces. + std::wstring cmdLine; + for (int i = 0; i < argc; ++i) { + if (i > 0) cmdLine += L' '; + std::string arg(argv[i]); + std::wstring warg(arg.begin(), arg.end()); + if (warg.find(L' ') != std::wstring::npos) { + cmdLine += L'"'; + cmdLine += warg; + cmdLine += L'"'; + } else { + cmdLine += warg; + } + } + if (auto r = cl.process(cmdLine)) { + return *r; + } +#endif + + initLogging(); + + // must be after logging + TimeThis tt("main() multiprocess"); + + MOApplication app(argc, argv); + + // check if the command line wants to run something right now + if (auto r = cl.runPostApplication(app)) { + return *r; + } + + // check if there's another process running + MOMultiProcess multiProcess(cl.multiple()); + + if (multiProcess.ephemeral()) { + // this is not the primary process + + if (cl.forwardToPrimary(multiProcess)) { + // but there's something on the command line that could be forwarded to + // it, so just exit + return 0; + } + + QMessageBox::information( + nullptr, QObject::tr("Mod Organizer"), + QObject::tr("An instance of Mod Organizer is already running")); + + return 1; + } + + // check if the command line wants to run something right now + if (auto r = cl.runPostMultiProcess(multiProcess)) { + return *r; + } + + tt.stop(); + + // stuff that's done only once, even if MO restarts in the loop below + app.firstTimeSetup(multiProcess); + + // force the "Select instance" dialog on startup, only for first loop or when + // the current instance cannot be used + bool pick = cl.pick(); + + // MO runs in a loop because it can be restarted in several ways, such as + // when switching instances or changing some settings + for (;;) { + try { + auto& m = InstanceManager::singleton(); + + if (cl.instance()) { + m.overrideInstance(*cl.instance()); + } + + if (cl.profile()) { + m.overrideProfile(*cl.profile()); + } + + // set up plugins, OrganizerCore, etc. + { + const auto r = app.setup(multiProcess, pick); + pick = false; + + if (r == RestartExitCode || r == ReselectExitCode) { + // resets things when MO is "restarted" + app.resetForRestart(); + + // don't reprocess command line + cl.clear(); + + if (r == ReselectExitCode) { + pick = true; + } + + continue; + } else if (r != 0) { + // something failed, quit + return r; + } + } + + // check if the command line wants to run something right now + if (auto r = cl.runPostOrganizer(app.core())) { + return *r; + } + +#ifndef _WIN32 + NxmHandlerLinux nxmHandler; + if (!nxmHandler.startListener()) { + log::warn("nxm listener could not be started"); + } else { + QObject::connect(&nxmHandler, &NxmHandlerLinux::nxmReceived, &app.core(), + [&](const NxmLink& link) { + app.core().downloadRequestedNXM( + QString("nxm://%1/mods/%2/files/%3?key=%4&expires=%5") + .arg(link.game_domain) + .arg(link.mod_id) + .arg(link.file_id) + .arg(link.key) + .arg(link.expires)); + }); + } +#endif + + // run the main window + const auto r = app.run(multiProcess); + + if (r == RestartExitCode) { + // resets things when MO is "restarted" + app.resetForRestart(); + + // don't reprocess command line + cl.clear(); + + continue; + } + + return r; + } catch (const std::exception& e) { + reportError(e.what()); + return 1; + } + } +} + +#ifdef _WIN32 +LONG WINAPI onUnhandledException(_EXCEPTION_POINTERS* ptrs) +{ + const auto path = OrganizerCore::getGlobalCoreDumpPath(); + const auto type = OrganizerCore::getGlobalCoreDumpType(); + + const auto r = env::coredump(path.empty() ? nullptr : path.c_str(), type); + + if (r) { + log::error("ModOrganizer has crashed, core dump created."); + } else { + log::error("ModOrganizer has crashed, core dump failed"); + } + + // g_prevExceptionFilter somehow sometimes point to this function, making this + // recurse and create hundreds of core dump, not sure why + if (g_prevExceptionFilter && ptrs && g_prevExceptionFilter != onUnhandledException) + return g_prevExceptionFilter(ptrs); + else + return EXCEPTION_CONTINUE_SEARCH; +} + +void onTerminate() noexcept +{ + __try { + // force an exception to get a valid stack trace for this thread + *(int*)0 = 42; + } __except (onUnhandledException(GetExceptionInformation()), + EXCEPTION_EXECUTE_HANDLER) { + } + + if (g_prevTerminateHandler) { + g_prevTerminateHandler(); + } else { + std::abort(); + } +} + +void setExceptionHandlers() +{ + if (g_prevExceptionFilter) { + // already called + return; + } + + g_prevExceptionFilter = SetUnhandledExceptionFilter(onUnhandledException); + g_prevTerminateHandler = std::set_terminate(onTerminate); +} + +#else // Linux + +// Defined in fuseconnector.cpp — returns the current FUSE mount point +// (or nullptr if nothing is mounted). The backing buffer is a plain +// char[] so reading it in a signal handler is async-signal-safe. +extern const char* getFuseMountPointForCrashCleanup(); + +// Attempt to unmount FUSE from a signal handler context. +// fork()+exec() is async-signal-safe on Linux. +static void emergencyFuseUnmount() +{ + const char* mp = getFuseMountPointForCrashCleanup(); + if (mp == nullptr) { + return; + } + + const pid_t child = fork(); + if (child == 0) { + // Child — try lazy unmount so it always succeeds even if busy. + execlp("fusermount3", "fusermount3", "-uz", mp, nullptr); + execlp("fusermount", "fusermount", "-uz", mp, nullptr); + _exit(1); + } else if (child > 0) { + // Parent — wait briefly for the child to finish. + int status = 0; + waitpid(child, &status, 0); + } +} + +static void linuxCrashHandler(int sig) +{ + // Reset to default immediately to avoid recursion + signal(sig, SIG_DFL); + + // Best-effort FUSE cleanup before we die + emergencyFuseUnmount(); + + const char* sigName = (sig == SIGSEGV) ? "SIGSEGV" + : (sig == SIGABRT) ? "SIGABRT" + : (sig == SIGFPE) ? "SIGFPE" + : "UNKNOWN"; + + fprintf(stderr, "\n=== MO2 CRASH: signal %s (%d) ===\n", sigName, sig); + + // Print backtrace + void* frames[64]; + int count = backtrace(frames, 64); + fprintf(stderr, "Backtrace (%d frames):\n", count); + backtrace_symbols_fd(frames, count, STDERR_FILENO); + fprintf(stderr, "=== END BACKTRACE ===\n"); + + // Re-raise for core dump + raise(sig); +} + +static void linuxTermHandler(int sig) +{ + // Graceful shutdown: unmount FUSE and exit cleanly. + signal(sig, SIG_DFL); + emergencyFuseUnmount(); + raise(sig); +} + +void setExceptionHandlers() +{ + signal(SIGSEGV, linuxCrashHandler); + signal(SIGABRT, linuxCrashHandler); + signal(SIGFPE, linuxCrashHandler); + signal(SIGTERM, linuxTermHandler); + signal(SIGINT, linuxTermHandler); +} + +#endif diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp new file mode 100644 index 0000000..8a219a7 --- /dev/null +++ b/src/src/mainwindow.cpp @@ -0,0 +1,4053 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "mainwindow.h" +#include "ui_mainwindow.h" + +#include "aboutdialog.h" +#ifdef MO2_WEBENGINE +#include "browserdialog.h" +#endif +#include "categories.h" +#include "categoriesdialog.h" +#include "datatab.h" +#include "filetree.h" +#include "downloadlist.h" +#include "downloadstab.h" +#include "editexecutablesdialog.h" +#include "envshortcut.h" +#include "eventfilter.h" +#include "executableinfo.h" +#include "executableslist.h" +#include "filedialogmemory.h" +#include "filterlist.h" +#include "guessedvalue.h" +#include "imodinterface.h" +#include "installationmanager.h" +#include "instancemanager.h" +#include "instancemanagerdialog.h" +#include "iplugindiagnose.h" +#include "iplugingame.h" +#include "isavegame.h" +#include "isavegameinfowidget.h" +#include "listdialog.h" +#include "localsavegames.h" +#include "messagedialog.h" +#include "modlist.h" +#include "modlistcontextmenu.h" +#include "modlistviewactions.h" +#include "motddialog.h" +#include "nexusinterface.h" +#include "nxmaccessmanager.h" +#include "organizercore.h" +#include "overwriteinfodialog.h" +#include "pluginlist.h" +#include "previewdialog.h" +#include "previewgenerator.h" +#include "problemsdialog.h" +#include "profile.h" +#include "profilesdialog.h" +#include "savestab.h" +#include "selectiondialog.h" +#include "serverinfo.h" +#include "settingsdialog.h" +#include "shared/appconfig.h" +#include "spawn.h" +#include "statusbar.h" +#include "systemtraymanager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#endif + +#include "directoryrefresher.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef MO2_WEBENGINE +#include +#endif +#include +#include + +#include +#include + +#ifndef Q_MOC_RUN +#include +#include +#include +#include +#include +#endif + +#ifdef _WIN32 +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gameplugins.h" + +#ifdef TEST_MODELS +#include "modeltest.h" +#endif // TEST_MODELS + +#pragma warning(disable : 4428) + +using namespace MOBase; +using namespace MOShared; + +const QSize SmallToolbarSize(24, 24); +const QSize MediumToolbarSize(32, 32); +const QSize LargeToolbarSize(42, 36); + +QString UnmanagedModName() +{ + return QObject::tr(""); +} + +bool runLoot(QWidget* parent, OrganizerCore& core, bool didUpdateMasterList); + +void setFilterShortcuts(QWidget* widget, QLineEdit* edit) +{ + auto activate = [=] { + edit->setFocus(); + edit->selectAll(); + }; + + auto reset = [=] { + edit->clear(); + widget->setFocus(); + }; + + auto hookActivate = [activate](auto* w) { + auto* s = new QShortcut(QKeySequence::Find, w); + s->setAutoRepeat(false); + s->setContext(Qt::WidgetWithChildrenShortcut); + QObject::connect(s, &QShortcut::activated, activate); + }; + + auto hookReset = [reset](auto* w) { + auto* s = new QShortcut(QKeySequence(Qt::Key_Escape), w); + s->setAutoRepeat(false); + s->setContext(Qt::WidgetWithChildrenShortcut); + QObject::connect(s, &QShortcut::activated, reset); + }; + + hookActivate(widget); + hookReset(widget); + + hookActivate(edit); + hookReset(edit); +} + +MainWindow::MainWindow(Settings& settings, OrganizerCore& organizerCore, + PluginContainer& pluginContainer, QWidget* parent) + : QMainWindow(parent), ui(new Ui::MainWindow), m_WasVisible(false), + m_FirstPaint(true), m_linksSeparator(nullptr), m_Tutorial(this, "MainWindow"), + m_OldProfileIndex(-1), m_OldExecutableIndex(-1), + m_CategoryFactory(CategoryFactory::instance()), m_OrganizerCore(organizerCore), + m_PluginContainer(pluginContainer), + m_ArchiveListWriter(std::bind(&MainWindow::saveArchiveList, this)), + m_LinkToolbar(nullptr), m_LinkDesktop(nullptr), m_LinkStartMenu(nullptr), + m_SystemTrayManager(nullptr), m_NumberOfProblems(0), + m_ProblemsCheckRequired(false) +{ + // disables incredibly slow menu fade in effect that looks and feels like crap. + // this was only happening to users with the windows + // "Fade or slide menus into view" effect enabled. + // maybe in the future the effects will be better at this moment they aren't. + QApplication::setEffectEnabled(Qt::UI_FadeMenu, false); + QApplication::setEffectEnabled(Qt::UI_AnimateMenu, false); + QApplication::setEffectEnabled(Qt::UI_AnimateCombo, false); + QApplication::setEffectEnabled(Qt::UI_AnimateTooltip, false); + QApplication::setEffectEnabled(Qt::UI_FadeTooltip, false); + +#ifdef MO2_WEBENGINE + QWebEngineProfile::defaultProfile()->setPersistentCookiesPolicy( + QWebEngineProfile::NoPersistentCookies); + QWebEngineProfile::defaultProfile()->setHttpCacheMaximumSize(52428800); + QWebEngineProfile::defaultProfile()->setCachePath(settings.paths().cache()); + QWebEngineProfile::defaultProfile()->setPersistentStoragePath( + settings.paths().cache()); + + // qt resets the thread name somewhere within the QWebEngineProfile calls + // above + MOShared::SetThisThreadName("main"); +#endif + + ui->setupUi(this); + languageChange(settings.interface().language()); + ui->statusBar->setup(ui, settings); + + m_SystemTrayManager = new SystemTrayManager(this, ui->logDock); + + { + auto& ni = NexusInterface::instance(); + + // there are two ways to get here: + // 1) the user just started MO, and + // 2) the user has changed some setting that required a restart + // + // "restarting" MO doesn't actually re-execute the binary, it just basically + // executes most of main() again, so a bunch of things are actually not + // reset + // + // one of these things is the api status, which will have fired its events + // long before the execution gets here because stuff is still cached and no + // real request to nexus is actually done + // + // therefore, when the user starts MO normally, the user account and stats + // will be empty (which is fine) and populated later on when the api key + // check has finished + // + // in the rare case where the user restarts MO through the settings, this + // will correctly pick up the previous values + updateWindowTitle(ni.getAPIUserAccount()); + ui->statusBar->setAPI(ni.getAPIStats(), ni.getAPIUserAccount()); + } + + m_CategoryFactory.loadCategories(); + + ui->logList->setCore(m_OrganizerCore); + + setupToolbar(); + toggleMO2EndorseState(); + toggleUpdateAction(); + + TaskProgressManager::instance().tryCreateTaskbar(); + + setupModList(); + ui->espList->setup(m_OrganizerCore, this, ui); + ui->bsaList->setLocalMoveOnly(true); + ui->bsaList->setHeaderHidden(true); + + const bool pluginListAdjusted = + settings.geometry().restoreState(ui->espList->header()); + + // data tab + m_DataTab.reset(new DataTab(m_OrganizerCore, m_PluginContainer, this, ui)); + m_DataTab->restoreState(settings); + + connect(m_DataTab.get(), &DataTab::executablesChanged, [&] { + refreshExecutablesList(); + }); + + connect(m_DataTab.get(), &DataTab::originModified, [&](int id) { + originModified(id); + }); + + connect(m_DataTab.get(), &DataTab::displayModInformation, + [&](auto&& m, auto&& i, auto&& tab) { + displayModInformation(m, i, tab); + }); + + // downloads tab + m_DownloadsTab.reset(new DownloadsTab(m_OrganizerCore, ui)); + + // saves tab + m_SavesTab.reset(new SavesTab(this, m_OrganizerCore, ui)); + + // Hide stuff we do not need: + auto& features = m_OrganizerCore.gameFeatures(); + if (!features.gameFeature()) { + ui->tabWidget->removeTab(ui->tabWidget->indexOf(ui->espTab)); + } + if (!features.gameFeature()) { + ui->tabWidget->removeTab(ui->tabWidget->indexOf(ui->bsaTab)); + } + + settings.geometry().restoreState(ui->downloadView->header()); + settings.geometry().restoreState(ui->savegameList->header()); + + ui->splitter->setStretchFactor(0, 3); + ui->splitter->setStretchFactor(1, 2); + + resizeLists(pluginListAdjusted); + + QMenu* linkMenu = new QMenu(this); + m_LinkToolbar = linkMenu->addAction(QIcon(":/MO/gui/link"), tr("Toolbar and Menu"), + this, SLOT(linkToolbar())); + m_LinkDesktop = linkMenu->addAction(QIcon(":/MO/gui/link"), tr("Desktop"), this, + SLOT(linkDesktop())); + m_LinkStartMenu = linkMenu->addAction(QIcon(":/MO/gui/link"), tr("Start Menu"), this, + SLOT(linkMenu())); + ui->linkButton->setMenu(linkMenu); + + ui->listOptionsBtn->setMenu( + new ModListGlobalContextMenu(m_OrganizerCore, ui->modList, this)); + + ui->openFolderMenu->setMenu(openFolderMenu()); + + // don't allow mouse wheel to switch grouping, too many people accidentally + // turn on grouping and then don't understand what happened + EventFilter* noWheel = new EventFilter(this, [](QObject*, QEvent* event) -> bool { + return event->type() == QEvent::Wheel; + }); + + ui->groupCombo->installEventFilter(noWheel); + ui->profileBox->installEventFilter(noWheel); + + updateSortButton(); + + connect(&m_PluginContainer, SIGNAL(diagnosisUpdate()), this, + SLOT(scheduleCheckForProblems())); + + connect(&m_OrganizerCore, &OrganizerCore::directoryStructureReady, this, + &MainWindow::onDirectoryStructureChanged); + connect(m_OrganizerCore.directoryRefresher(), + SIGNAL(progress(const DirectoryRefreshProgress*)), this, + SLOT(refresherProgress(const DirectoryRefreshProgress*))); + connect(m_OrganizerCore.directoryRefresher(), SIGNAL(error(QString)), this, + SLOT(showError(QString))); + + connect(&m_OrganizerCore.settings(), SIGNAL(languageChanged(QString)), this, + SLOT(languageChange(QString))); + connect(&m_OrganizerCore.settings(), SIGNAL(styleChanged(QString)), this, + SIGNAL(styleChanged(QString))); + + connect(m_OrganizerCore.updater(), SIGNAL(restart()), this, SLOT(close())); + connect(m_OrganizerCore.updater(), SIGNAL(updateAvailable()), this, + SLOT(updateAvailable())); + connect(m_OrganizerCore.updater(), SIGNAL(motdAvailable(QString)), this, + SLOT(motdReceived(QString))); + connect(&m_OrganizerCore, &OrganizerCore::refreshTriggered, this, [this]() { + updateSortButton(); + }); + + connect(&NexusInterface::instance(), SIGNAL(requestNXMDownload(QString)), + &m_OrganizerCore, SLOT(downloadRequestedNXM(QString))); + connect(&NexusInterface::instance(), + SIGNAL(nxmDownloadURLsAvailable(QString, int, int, QVariant, QVariant, int)), + this, SLOT(nxmDownloadURLs(QString, int, int, QVariant, QVariant, int))); + connect(&NexusInterface::instance(), SIGNAL(needLogin()), &m_OrganizerCore, + SLOT(nexusApi())); + + connect(NexusInterface::instance().getAccessManager(), + &NXMAccessManager::credentialsReceived, this, &MainWindow::updateWindowTitle); + connect(&NexusInterface::instance(), &NexusInterface::requestsChanged, ui->statusBar, + &StatusBar::setAPI); + + connect(&TutorialManager::instance(), SIGNAL(windowTutorialFinished(QString)), this, + SLOT(windowTutorialFinished(QString))); + connect(ui->tabWidget, SIGNAL(currentChanged(int)), &TutorialManager::instance(), + SIGNAL(tabChanged(int))); + connect(ui->toolBar, SIGNAL(customContextMenuRequested(QPoint)), this, + SLOT(toolBar_customContextMenuRequested(QPoint))); + connect(ui->menuToolbars, &QMenu::aboutToShow, [&] { + updateToolbarMenu(); + }); + connect(ui->menuView, &QMenu::aboutToShow, [&] { + updateViewMenu(); + }); + connect(ui->actionTool->menu(), &QMenu::aboutToShow, [&] { + updateToolMenu(); + }); + connect(&m_PluginContainer, &PluginContainer::pluginEnabled, this, + [this](IPlugin* plugin) { + if (m_PluginContainer.implementInterface(plugin)) { + updateModPageMenu(); + } + }); + connect(&m_PluginContainer, &PluginContainer::pluginDisabled, this, + [this](IPlugin* plugin) { + if (m_PluginContainer.implementInterface(plugin)) { + updateModPageMenu(); + } + }); + connect(&m_PluginContainer, &PluginContainer::pluginRegistered, this, + &MainWindow::onPluginRegistrationChanged); + connect(&m_PluginContainer, &PluginContainer::pluginUnregistered, this, + &MainWindow::onPluginRegistrationChanged); + + connect(&m_OrganizerCore, &OrganizerCore::modInstalled, this, + &MainWindow::modInstalled); + + connect(&m_CategoryFactory, SIGNAL(nexusCategoryRefresh(CategoriesDialog*)), this, + SLOT(refreshNexusCategories(CategoriesDialog*))); + connect(&m_CategoryFactory, SIGNAL(categoriesSaved()), this, SLOT(categoriesSaved())); + + m_CheckBSATimer.setSingleShot(true); + connect(&m_CheckBSATimer, SIGNAL(timeout()), this, SLOT(checkBSAList())); + + setFilterShortcuts(ui->modList, ui->modFilterEdit); + setFilterShortcuts(ui->espList, ui->espFilterEdit); + setFilterShortcuts(ui->downloadView, ui->downloadFilterEdit); + + m_UpdateProblemsTimer.setSingleShot(true); + connect(&m_UpdateProblemsTimer, &QTimer::timeout, this, + &MainWindow::checkForProblemsAsync); + connect(this, &MainWindow::checkForProblemsDone, this, + &MainWindow::updateProblemsButton, Qt::ConnectionType::QueuedConnection); + + m_SaveMetaTimer.setSingleShot(false); + connect(&m_SaveMetaTimer, SIGNAL(timeout()), this, SLOT(saveModMetas())); + m_SaveMetaTimer.start(5000); + + FileDialogMemory::restore(settings); + + fixCategories(); + + m_StartTime = QTime::currentTime(); + + m_Tutorial.expose("modList", m_OrganizerCore.modList()); + m_Tutorial.expose("espList", m_OrganizerCore.pluginList()); + + m_OrganizerCore.setUserInterface(this); + m_OrganizerCore.onFinishedRun([=](const QString, unsigned int) { + if (isHidden()) { + m_SystemTrayManager->restoreFromSystemTray(); + } + }); + + connect(m_OrganizerCore.modList(), &ModList::showMessage, [=](auto&& message) { + showMessage(message); + }); + connect(m_OrganizerCore.modList(), &ModList::modRenamed, + [=](auto&& oldName, auto&& newName) { + modRenamed(oldName, newName); + }); + connect(m_OrganizerCore.modList(), &ModList::modUninstalled, [=](auto&& name) { + modRemoved(name); + }); + connect(m_OrganizerCore.modList(), &ModList::fileMoved, [=](auto&&... args) { + fileMoved(args...); + }); + connect(m_OrganizerCore.installationManager(), &InstallationManager::modReplaced, + [=](auto&& name) { + modRemoved(name); + }); + connect(m_OrganizerCore.downloadManager(), &DownloadManager::showMessage, + [=](auto&& message) { + showMessage(message); + }); + for (const QString& fileName : m_PluginContainer.pluginFileNames()) { + installTranslator(QFileInfo(fileName).baseName()); + } + + updateModPageMenu(); + + // refresh profiles so the current profile can be activated + refreshProfiles(false); + + ui->profileBox->setCurrentText(m_OrganizerCore.currentProfile()->name()); + + if (settings.archiveParsing()) { + ui->dataTabShowFromArchives->setCheckState(Qt::Checked); + ui->dataTabShowFromArchives->setEnabled(true); + } else { + ui->dataTabShowFromArchives->setCheckState(Qt::Unchecked); + ui->dataTabShowFromArchives->setEnabled(false); + } + + QApplication::instance()->installEventFilter(this); + + scheduleCheckForProblems(); + refreshExecutablesList(); + updatePinnedExecutables(); + resetActionIcons(); + processUpdates(); + + ui->modList->updateModCount(); + ui->espList->updatePluginCount(); + ui->statusBar->updateNormalMessage(m_OrganizerCore); +} + +void MainWindow::setupModList() +{ + ui->modList->setup(m_OrganizerCore, m_CategoryFactory, this, ui); + + connect(&ui->modList->actions(), &ModListViewActions::overwriteCleared, [=]() { + scheduleCheckForProblems(); + }); + connect(&ui->modList->actions(), &ModListViewActions::originModified, this, + &MainWindow::originModified); + connect(&ui->modList->actions(), &ModListViewActions::modInfoDisplayed, this, + &MainWindow::modInfoDisplayed); + + connect(m_OrganizerCore.modList(), &ModList::modPrioritiesChanged, [&]() { + m_ArchiveListWriter.write(); + }); +} + +void MainWindow::resetActionIcons() +{ + // this is a bit of a hack + // + // the .qss files have historically set qproperty-icon by id and these ids + // correspond to the QActions created in the .ui file + // + // the problem is that QActions do not support having their icon property + // set from a .qss because they're not widgets (they don't inherit from + // QWidget), and styling only works on widget + // + // a QAction _does_ have an associated icon, it just can't be set from a .qss + // file + // + // so here, a dummy QToolButton widget is created for each QAction and is + // given the same name as the action, which makes it pick up the icon + // specified in the .qss file + // + // that icon is then given to the widget used by the QAction (if it's some + // sort of button, which typically happens on the toolbar) _and_ to the + // QAction itself, which is used in the menu bar + + // clearing the notification, will be set below if the stylesheet has set + // anything for it + m_originalNotificationIcon = {}; + + // QActions created from the .ui file are children of the main window + for (QAction* action : findChildren()) { + // creating a dummy button + auto dummy = std::make_unique(); + + // reusing the action name + dummy->setObjectName(action->objectName()); + + // styling the button, this has to be done manually because the button is + // never added anywhere + style()->polish(dummy.get()); + + // the button's icon may be null if it wasn't specified in the .qss file, + // which can happen if the stylesheet just doesn't override icons, or for + // other actions like the pinned custom executables + const auto icon = dummy->icon(); + if (icon.isNull()) { + continue; + } + + // button associated with the action on the toolbar + QWidget* actionWidget = ui->toolBar->widgetForAction(action); + + if (auto* actionButton = dynamic_cast(actionWidget)) { + actionButton->setIcon(icon); + } + + // the action's icon is used by the menu bar + action->setIcon(icon); + + if (action == ui->actionNotifications) { + // if the stylesheet has set a notification icon, remember it here so it + // can be used in updateProblemsButton() + m_originalNotificationIcon = icon; + } + } + + // update the button for the potentially new icon + updateProblemsButton(); +} + +MainWindow::~MainWindow() +{ + try { + cleanup(); + + m_OrganizerCore.setUserInterface(nullptr); + +#ifdef MO2_WEBENGINE + if (m_IntegratedBrowser) { + m_IntegratedBrowser->close(); + m_IntegratedBrowser.reset(); + } +#endif + + delete ui; + } catch (std::exception& e) { + QMessageBox::critical( + nullptr, tr("Crash on exit"), + tr("MO crashed while exiting. Some settings may not be saved.\n\nError: %1") + .arg(e.what()), + QMessageBox::Ok); + } +} + +void MainWindow::updateWindowTitle(const APIUserAccount& user) +{ + //"\xe2\x80\x93" is an "em dash", a longer "-" + QString title = + QString("%1 \xe2\x80\x93 Mod Organizer v%2") + .arg(m_OrganizerCore.managedGame()->displayGameName(), + m_OrganizerCore.getVersion().string(Version::FormatCondensed)); + + if (!user.name().isEmpty()) { + const QString premium = (user.type() == APIUserAccountTypes::Premium ? "*" : ""); + title.append(QString(" (%1%2)").arg(user.name(), premium)); + } + + this->setWindowTitle(title); +} + +void MainWindow::resizeLists(bool pluginListCustom) +{ + // ensure the columns aren't so small you can't see them any more + for (int i = 0; i < ui->modList->header()->count(); ++i) { + if (ui->modList->header()->sectionSize(i) < 10) { + ui->modList->header()->resizeSection(i, 10); + } + } + + if (!pluginListCustom) { + // resize plugin list to fit content + for (int i = 0; i < ui->espList->header()->count(); ++i) { + ui->espList->header()->setSectionResizeMode(i, QHeaderView::ResizeToContents); + } + ui->espList->header()->setSectionResizeMode(0, QHeaderView::Stretch); + } +} + +void MainWindow::allowListResize() +{ + // allow resize on mod list + for (int i = 0; i < ui->modList->header()->count(); ++i) { + ui->modList->header()->setSectionResizeMode(i, QHeaderView::Interactive); + } + ui->modList->header()->setStretchLastSection(true); + + // allow resize on plugin list + for (int i = 0; i < ui->espList->header()->count(); ++i) { + ui->espList->header()->setSectionResizeMode(i, QHeaderView::Interactive); + } + ui->espList->header()->setStretchLastSection(true); +} + +void MainWindow::updateStyle(const QString&) +{ + resetActionIcons(); +} + +void MainWindow::resizeEvent(QResizeEvent* event) +{ + m_Tutorial.resize(event->size()); + QMainWindow::resizeEvent(event); +} + +void MainWindow::setupToolbar() +{ + setupActionMenu(ui->actionModPage); + setupActionMenu(ui->actionTool); + setupActionMenu(ui->actionHelp); + setupActionMenu(ui->actionEndorseMO); + + createHelpMenu(); + createEndorseMenu(); + + // find last separator, add a spacer just before it so the icons are + // right-aligned + m_linksSeparator = nullptr; + for (auto* a : ui->toolBar->actions()) { + if (a->isSeparator()) { + m_linksSeparator = a; + } + } + + if (m_linksSeparator) { + auto* spacer = new QWidget(ui->toolBar); + spacer->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + ui->toolBar->insertWidget(m_linksSeparator, spacer); + + } else { + log::warn("no separator found on the toolbar, icons won't be right-aligned"); + } + + if (!InstanceManager::singleton().allowedToChangeInstance()) { + ui->actionChange_Game->setVisible(false); + } +} + +void MainWindow::setupActionMenu(QAction* a) +{ + a->setMenu(new QMenu(this)); + + auto* w = ui->toolBar->widgetForAction(a); + if (auto* tb = dynamic_cast(w)) + tb->setPopupMode(QToolButton::InstantPopup); +} + +void MainWindow::updatePinnedExecutables() +{ + for (auto* a : ui->toolBar->actions()) { + if (a->objectName().startsWith("custom__")) { + ui->toolBar->removeAction(a); + a->deleteLater(); + } + } + + ui->menuRun->clear(); + + bool hasLinks = false; + + for (const auto& exe : *m_OrganizerCore.executablesList()) { + if (!exe.hide() && exe.isShownOnToolbar()) { + hasLinks = true; + + QAction* exeAction = + new QAction(iconForExecutable(exe.binaryInfo().filePath()), exe.title()); + + exeAction->setObjectName(QString("custom__") + exe.title()); + exeAction->setStatusTip(exe.binaryInfo().filePath()); + + if (!connect(exeAction, SIGNAL(triggered()), this, SLOT(startExeAction()))) { + log::debug("failed to connect trigger?"); + } + + if (m_linksSeparator) { + ui->toolBar->insertAction(m_linksSeparator, exeAction); + } else { + // separator wasn't found, add it to the end + ui->toolBar->addAction(exeAction); + } + + ui->menuRun->addAction(exeAction); + } + } + + // don't show the menu if there are no links + ui->menuRun->menuAction()->setVisible(hasLinks); +} + +void MainWindow::updateToolbarMenu() +{ + ui->actionMainMenuToggle->setChecked(ui->menuBar->isVisible()); + ui->actionToolBarMainToggle->setChecked(ui->toolBar->isVisible()); + ui->actionStatusBarToggle->setChecked(ui->statusBar->isVisible()); + + ui->actionToolBarSmallIcons->setChecked(ui->toolBar->iconSize() == SmallToolbarSize); + ui->actionToolBarMediumIcons->setChecked(ui->toolBar->iconSize() == + MediumToolbarSize); + ui->actionToolBarLargeIcons->setChecked(ui->toolBar->iconSize() == LargeToolbarSize); + + ui->actionToolBarIconsOnly->setChecked(ui->toolBar->toolButtonStyle() == + Qt::ToolButtonIconOnly); + ui->actionToolBarTextOnly->setChecked(ui->toolBar->toolButtonStyle() == + Qt::ToolButtonTextOnly); + ui->actionToolBarIconsAndText->setChecked(ui->toolBar->toolButtonStyle() == + Qt::ToolButtonTextUnderIcon); +} + +void MainWindow::updateViewMenu() +{ + ui->actionViewLog->setChecked(ui->logDock->isVisible()); +} + +QMenu* MainWindow::createPopupMenu() +{ + auto* m = new QMenu; + + // add all the actions from the toolbars menu + for (auto* a : ui->menuToolbars->actions()) { + m->addAction(a); + } + + m->addSeparator(); + + // other actions + m->addAction(ui->actionViewLog); + + // make sure the actions are updated + updateToolbarMenu(); + updateViewMenu(); + + return m; +} + +void MainWindow::on_actionMainMenuToggle_triggered() +{ + ui->menuBar->setVisible(!ui->menuBar->isVisible()); +} + +void MainWindow::on_actionToolBarMainToggle_triggered() +{ + ui->toolBar->setVisible(!ui->toolBar->isVisible()); +} + +void MainWindow::on_actionStatusBarToggle_triggered() +{ + ui->statusBar->setVisible(!ui->statusBar->isVisible()); +} + +void MainWindow::on_actionToolBarSmallIcons_triggered() +{ + setToolbarSize(SmallToolbarSize); +} + +void MainWindow::on_actionToolBarMediumIcons_triggered() +{ + setToolbarSize(MediumToolbarSize); +} + +void MainWindow::on_actionToolBarLargeIcons_triggered() +{ + setToolbarSize(LargeToolbarSize); +} + +void MainWindow::on_actionToolBarIconsOnly_triggered() +{ + setToolbarButtonStyle(Qt::ToolButtonIconOnly); +} + +void MainWindow::on_actionToolBarTextOnly_triggered() +{ + setToolbarButtonStyle(Qt::ToolButtonTextOnly); +} + +void MainWindow::on_actionToolBarIconsAndText_triggered() +{ + setToolbarButtonStyle(Qt::ToolButtonTextUnderIcon); +} + +void MainWindow::on_actionViewLog_triggered() +{ + ui->logDock->setVisible(!ui->logDock->isVisible()); +} + +void MainWindow::setToolbarSize(const QSize& s) +{ + for (auto* tb : findChildren()) { + tb->setIconSize(s); + } +} + +void MainWindow::setToolbarButtonStyle(Qt::ToolButtonStyle s) +{ + for (auto* tb : findChildren()) { + tb->setToolButtonStyle(s); + } +} + +void MainWindow::on_centralWidget_customContextMenuRequested(const QPoint& pos) +{ + // this allows for getting the context menu even if both the menubar and all + // the toolbars are hidden; an alternative is the Alt key handled in + // keyPressEvent() below + + // the custom context menu event bubbles up to here if widgets don't actually + // process this, which would show the menu when right-clicking button, labels, + // etc. + // + // only show the context menu when right-clicking on the central widget + // itself, which is basically just the outer edges of the main window + auto* w = childAt(pos); + if (w != ui->centralWidget) { + return; + } + + createPopupMenu()->exec(ui->centralWidget->mapToGlobal(pos)); +} + +void MainWindow::scheduleCheckForProblems() +{ + if (!m_UpdateProblemsTimer.isActive()) { + m_UpdateProblemsTimer.start(500); + } +} + +void MainWindow::updateProblemsButton() +{ + // if the current stylesheet doesn't provide an icon, this is used instead + const char* DefaultIconName = ":/MO/gui/warning"; + + const std::size_t numProblems = m_NumberOfProblems; + + // original icon without a count painted on it + const QIcon original = m_originalNotificationIcon.isNull() + ? QIcon(DefaultIconName) + : m_originalNotificationIcon; + + // final icon + QIcon final; + + if (numProblems > 0) { + ui->actionNotifications->setToolTip(tr("There are notifications to read")); + + // will contain the original icon, plus a notification count; this also + // makes sure the pixmap is exactly 64x64 by requesting the icon that's + // as close to 64x64 as possible, and then scaling it up if it's too small + QPixmap merged = original.pixmap(64, 64).scaled(64, 64); + + { + QPainter painter(&merged); + + const std::string badgeName = + std::string(":/MO/gui/badge_") + + (numProblems < 10 ? std::to_string(static_cast(numProblems)) + : "more"); + + painter.drawPixmap(32, 32, 32, 32, QPixmap(badgeName.c_str())); + } + + final = QIcon(merged); + } else { + ui->actionNotifications->setToolTip(tr("There are no notifications")); + + // no change + final = original; + } + + ui->actionNotifications->setEnabled(numProblems > 0); + + // setting the icon on the action (shown on the menu) + ui->actionNotifications->setIcon(final); + + // setting the icon on the toolbar button + if (auto* actionWidget = ui->toolBar->widgetForAction(ui->actionNotifications)) { + if (auto* button = dynamic_cast(actionWidget)) { + button->setIcon(final); + } + } + + // updating the status bar, may be null very early when MO is starting + if (ui->statusBar) { + ui->statusBar->setNotifications(numProblems > 0); + } +} + +bool MainWindow::errorReported(QString& logFile) +{ + QDir dir(qApp->property("dataPath").toString() + "/" + + QString::fromStdWString(AppConfig::logPath())); + QFileInfoList files = + dir.entryInfoList(QStringList("ModOrganizer_??_??_??_??_??.log"), QDir::Files, + QDir::Name | QDir::Reversed); + + if (files.count() > 0) { + logFile = files.at(0).absoluteFilePath(); + QFile file(logFile); + if (file.open(QIODevice::ReadOnly | QIODevice::Text)) { + char buffer[1024]; + int line = 0; + while (!file.atEnd()) { + file.readLine(buffer, 1024); + if (strncmp(buffer, "ERROR", 5) == 0) { + return true; + } + + // prevent this function from taking forever + if (line++ >= 50000) { + break; + } + } + } + } + + return false; +} + +QFuture MainWindow::checkForProblemsAsync() +{ + return QtConcurrent::run([this]() { + checkForProblemsImpl(); + }); +} + +void MainWindow::checkForProblemsImpl() +{ + m_ProblemsCheckRequired = true; + + std::scoped_lock lk(m_CheckForProblemsMutex); + + // another thread might already have checked while this one was waiting on the lock + if (m_ProblemsCheckRequired) { + m_ProblemsCheckRequired = false; + TimeThis tt("MainWindow::checkForProblemsImpl()"); + size_t numProblems = 0; + for (QObject* pluginObj : m_PluginContainer.plugins()) { + IPlugin* plugin = qobject_cast(pluginObj); + if (plugin == nullptr || m_PluginContainer.isEnabled(plugin)) { + IPluginDiagnose* diagnose = qobject_cast(pluginObj); + if (diagnose != nullptr) + numProblems += diagnose->activeProblems().size(); + } + } + m_NumberOfProblems = numProblems; + emit checkForProblemsDone(); + } +} + +void MainWindow::about() +{ + AboutDialog(m_OrganizerCore.getVersion().string(Version::FormatCondensed), this) + .exec(); +} + +void MainWindow::createEndorseMenu() +{ + auto* menu = ui->actionEndorseMO->menu(); + if (!menu) { + // shouldn't happen + return; + } + + menu->clear(); + + QAction* endorseAction = new QAction(tr("Endorse"), menu); + connect(endorseAction, SIGNAL(triggered()), this, SLOT(actionEndorseMO())); + menu->addAction(endorseAction); + + QAction* wontEndorseAction = new QAction(tr("Won't Endorse"), menu); + connect(wontEndorseAction, SIGNAL(triggered()), this, SLOT(actionWontEndorseMO())); + menu->addAction(wontEndorseAction); +} + +void MainWindow::createHelpMenu() +{ + //: Translation strings for tutorial names + static std::map translate = { + {"First Steps", QT_TR_NOOP("First Steps")}, + {"Conflict Resolution", QT_TR_NOOP("Conflict Resolution")}, + {"Overview", QT_TR_NOOP("Overview")}}; + + auto* menu = ui->actionHelp->menu(); + if (!menu) { + // this happens on startup because languageChanged() (which calls this) is + // called before the menus are actually created + return; + } + + menu->clear(); + + QAction* helpAction = new QAction(tr("Help on UI"), menu); + connect(helpAction, SIGNAL(triggered()), this, SLOT(helpTriggered())); + menu->addAction(helpAction); + + QAction* wikiAction = new QAction(tr("Documentation"), menu); + connect(wikiAction, SIGNAL(triggered()), this, SLOT(wikiTriggered())); + menu->addAction(wikiAction); + + if (!m_OrganizerCore.managedGame()->getSupportURL().isEmpty()) { + QAction* gameSupportAction = new QAction(tr("Game Support Wiki"), menu); + connect(gameSupportAction, SIGNAL(triggered()), this, SLOT(gameSupportTriggered())); + menu->addAction(gameSupportAction); + } + + QAction* discordAction = new QAction(tr("Chat on Discord"), menu); + connect(discordAction, SIGNAL(triggered()), this, SLOT(discordTriggered())); + menu->addAction(discordAction); + + QAction* issueAction = new QAction(tr("Report Issue"), menu); + connect(issueAction, SIGNAL(triggered()), this, SLOT(issueTriggered())); + menu->addAction(issueAction); + + QMenu* tutorialMenu = new QMenu(tr("Tutorials"), menu); + + typedef std::vector> ActionList; + + ActionList tutorials; + + QDirIterator dirIter(QApplication::applicationDirPath() + "/tutorials", + QStringList("*.js"), QDir::Files); + while (dirIter.hasNext()) { + dirIter.next(); + QString fileName = dirIter.fileName(); + + QFile file(dirIter.filePath()); + if (!file.open(QIODevice::ReadOnly)) { + log::error("Failed to open {}", fileName); + continue; + } + QString firstLine = QString::fromUtf8(file.readLine()); + if (firstLine.startsWith("//TL")) { + QStringList params = firstLine.mid(4).trimmed().split('#'); + if (params.size() != 2) { + log::error("invalid header line for tutorial {}, expected 2 parameters", + fileName); + continue; + } + QAction* tutAction = new QAction(tr(translate[params.at(0)]), tutorialMenu); + tutAction->setData(fileName); + tutorials.push_back(std::make_pair(params.at(1).toInt(), tutAction)); + } + } + + std::sort(tutorials.begin(), tutorials.end(), + [](const ActionList::value_type& LHS, const ActionList::value_type& RHS) { + return LHS.first < RHS.first; + }); + + for (auto iter = tutorials.begin(); iter != tutorials.end(); ++iter) { + connect(iter->second, SIGNAL(triggered()), this, SLOT(tutorialTriggered())); + tutorialMenu->addAction(iter->second); + } + + menu->addMenu(tutorialMenu); + menu->addAction(tr("About"), this, SLOT(about())); + menu->addAction(tr("About Qt"), qApp, SLOT(aboutQt())); +} + +bool MainWindow::addProfile() +{ + QComboBox* profileBox = findChild("profileBox"); + bool okClicked = false; + + QString name = QInputDialog::getText(this, tr("Name"), + tr("Please enter a name for the new profile"), + QLineEdit::Normal, QString(), &okClicked); + if (okClicked && (name.size() > 0)) { + try { + profileBox->addItem(name); + profileBox->setCurrentIndex(profileBox->count() - 1); + return true; + } catch (const std::exception& e) { + reportError(tr("failed to create profile: %1").arg(e.what())); + return false; + } + } else { + return false; + } +} + +void MainWindow::hookUpWindowTutorials() +{ + QDirIterator dirIter(QApplication::applicationDirPath() + "/tutorials", + QStringList("*.js"), QDir::Files); + while (dirIter.hasNext()) { + dirIter.next(); + QString fileName = dirIter.fileName(); + QFile file(dirIter.filePath()); + if (!file.open(QIODevice::ReadOnly)) { + log::error("Failed to open {}", fileName); + continue; + } + QString firstLine = QString::fromUtf8(file.readLine()); + if (firstLine.startsWith("//WIN")) { + QString windowName = firstLine.mid(6).trimmed(); + if (!m_OrganizerCore.settings().interface().isTutorialCompleted(windowName)) { + TutorialManager::instance().activateTutorial(windowName, fileName); + } + } + } +} + +bool MainWindow::shouldStartTutorial() const +{ + if (GlobalSettings::hideTutorialQuestion()) { + return false; + } + + QMessageBox dlg( + QMessageBox::Question, tr("Show tutorial?"), + tr("You are starting Mod Organizer for the first time. " + "Do you want to show a tutorial of its basic features? If you choose " + "no you can always start the tutorial from the \"Help\" menu."), + QMessageBox::Yes | QMessageBox::No); + + dlg.setCheckBox(new QCheckBox(tr("Never ask to show tutorials"))); + + const auto r = dlg.exec(); + + if (dlg.checkBox()->isChecked()) { + GlobalSettings::setHideTutorialQuestion(true); + } + + return (r == QMessageBox::Yes); +} + +void MainWindow::showEvent(QShowEvent* event) +{ + QMainWindow::showEvent(event); + + if (!m_WasVisible) { + ui->modList->refreshFilters(); + readSettings(); + + // this needs to be connected here instead of in the constructor because the + // actual changing of the stylesheet is done by MOApplication, which + // connects its signal in runApplication() (in main.cpp), and that happens + // _after_ the MainWindow is constructed, but _before_ it is shown + // + // by connecting the event here, changing the style setting will first be + // handled by MOApplication, and then in updateStyle(), at which point the + // stylesheet has already been set correctly + connect(this, SIGNAL(styleChanged(QString)), this, SLOT(updateStyle(QString))); + + // only the first time the window becomes visible + m_Tutorial.registerControl(); + + hookUpWindowTutorials(); + + if (m_OrganizerCore.settings().firstStart()) { + QString firstStepsTutorial = ToQString(AppConfig::firstStepsTutorial()); + if (TutorialManager::instance().hasTutorial(firstStepsTutorial)) { + if (shouldStartTutorial()) { + TutorialManager::instance().activateTutorial("MainWindow", + firstStepsTutorial); + } + } else { + log::error("{} missing", firstStepsTutorial); + QPoint pos = ui->toolBar->mapToGlobal(QPoint()); + pos.rx() += ui->toolBar->width() / 2; + pos.ry() += ui->toolBar->height(); + QWhatsThis::showText(pos, + QObject::tr("Please use \"Help\" from the toolbar to get " + "usage instructions to all elements")); + } + + if (!m_OrganizerCore.managedGame()->getSupportURL().isEmpty()) { + QMessageBox::information(this, tr("Game Support Wiki"), + tr("Do you know how to mod this game? Do you need to " + "learn? There's a game support wiki available! " + "Click OK to open the wiki. In the future, you can " + "access this link from the \"Help\" menu."), + QMessageBox::Ok); + gameSupportTriggered(); + } + + QMessageBox newCatDialog; + newCatDialog.setWindowTitle(tr("Category Setup")); + newCatDialog.setText( + tr("Please choose how to handle the default category setup.\n\n" + "If you've already connected to Nexus, you can automatically import Nexus " + "categories for this game (if applicable). Otherwise, use the old Mod " + "Organizer default category structure, or leave the categories blank (for " + "manual setup).")); + QPushButton importBtn(tr("&Import Nexus Categories")); + QPushButton defaultBtn(tr("Use &Old Category Defaults")); + QPushButton cancelBtn(tr("Do &Nothing")); + if (NexusInterface::instance().getAccessManager()->validated()) { + newCatDialog.addButton(&importBtn, QMessageBox::ButtonRole::AcceptRole); + } + newCatDialog.addButton(&defaultBtn, QMessageBox::ButtonRole::AcceptRole); + newCatDialog.addButton(&cancelBtn, QMessageBox::ButtonRole::RejectRole); + newCatDialog.exec(); + if (newCatDialog.clickedButton() == &importBtn) { + importCategories(false); + } else if (newCatDialog.clickedButton() == &cancelBtn) { + m_CategoryFactory.reset(); + } else if (newCatDialog.clickedButton() == &defaultBtn) { + m_CategoryFactory.loadCategories(); + } + m_CategoryFactory.saveCategories(); + + m_OrganizerCore.settings().setFirstStart(false); + } else { + auto& settings = m_OrganizerCore.settings(); + if (m_LastVersion < QVersionNumber(2, 5) && + !GlobalSettings::hideCategoryReminder()) { + QMessageBox migrateCatDialog; + migrateCatDialog.setWindowTitle("Category Migration"); + migrateCatDialog.setText( + tr("This is your first time running version 2.5 or higher with an old MO2 " + "instance. The category system now relies on an updated system to map " + "Nexus categories.\n\n" + "In order to assign Nexus categories automatically, you will need to " + "import the Nexus categories for the currently managed game and map " + "them to your preferred category structure.\n\n" + "You can either manually open the category editor, via the Settings " + "dialog or the category filter sidebar, and set up the mappings as you " + "see fit, or you can automatically import and map the categories as " + "defined on Nexus.\n\n" + "As a final option, you can disable Nexus category mapping altogether, " + "which can be changed at any time in the Settings dialog.")); + QPushButton importBtn(tr("&Import Nexus Categories")); + QPushButton openSettingsBtn(tr("&Open Categories Dialog")); + QPushButton disableBtn(tr("&Disable Nexus Mappings")); + QPushButton closeBtn(tr("&Close")); + QCheckBox dontShow(tr("&Don't show this again")); + if (NexusInterface::instance().getAccessManager()->validated()) { + migrateCatDialog.addButton(&importBtn, QMessageBox::ButtonRole::AcceptRole); + } + migrateCatDialog.addButton(&openSettingsBtn, + QMessageBox::ButtonRole::ActionRole); + migrateCatDialog.addButton(&disableBtn, + QMessageBox::ButtonRole::DestructiveRole); + migrateCatDialog.addButton(&closeBtn, QMessageBox::ButtonRole::RejectRole); + migrateCatDialog.setCheckBox(&dontShow); + migrateCatDialog.exec(); + if (migrateCatDialog.clickedButton() == &importBtn) { + importCategories(dontShow.isChecked()); + } else if (migrateCatDialog.clickedButton() == &openSettingsBtn) { + this->ui->filtersEdit->click(); + } else if (migrateCatDialog.clickedButton() == &disableBtn) { + Settings::instance().nexus().setCategoryMappings(false); + } + if (dontShow.isChecked()) { + GlobalSettings::setHideCategoryReminder(true); + } + } + } + + m_OrganizerCore.settings().widgets().restoreIndex(ui->groupCombo); + + m_OrganizerCore.settings().nexus().registerAsNXMHandler(false); + m_WasVisible = true; + updateProblemsButton(); + + // notify plugins that the MO2 is ready + m_PluginContainer.startPlugins(this); + + // forces a log list refresh to display startup logs + // + // since the log list is not visible until this point, the automatic + // resize of columns seems to break the log list (since Qt 5.15.1 or + // 5.15.2), an make the list empty on startup (in debug the list is not + // empty because some logs are added after the log list becomes visible) + // + // the reset() forces a re-computation of the column size, thus properly + // the logs that are already in the log model + // + ui->logList->reset(); + ui->logList->scrollToBottom(); + } +} + +void MainWindow::paintEvent(QPaintEvent* event) +{ + if (m_FirstPaint) { + allowListResize(); + m_FirstPaint = false; + } + + QMainWindow::paintEvent(event); +} + +void MainWindow::onBeforeClose() +{ + storeSettings(); +} + +void MainWindow::closeEvent(QCloseEvent* event) +{ + if (isVisible()) { + // this is messy + // + // the main problem this is solving is when closing MO, then getting the + // lock overlay because processes are still running, then pressing the X + // again + // + // in this case, closeEvent() is _not_ called for the second event and the + // window is immediately hidden + // + // this always saves the settings here; in the event where a lock overlay + // is then shown, it might save settings multiple times, but it's harmless + onBeforeClose(); + } + + // this happens for two reasons: + // 1) the user requested to close the window, such as clicking the X + // 2) close() is called in runApplication() after application.exec() + // returns, which happens when qApp->exit() is called + // + // the window must never actually close for 1), because settings haven't been + // saved yet: the state of many widgets is saved to the ini, which relies on + // the window still being onscreen (or else everything is considered hidden) + // + // for 2), the settings have been saved and the window can just close + + if (ModOrganizerCanCloseNow()) { + // the user has confirmed if necessary and all settings have been saved, + // just close it + QMainWindow::closeEvent(event); + return; + } + + if (UILocker::instance().locked()) { + // don't bother asking the user to confirm if the ui is already locked + event->ignore(); + ExitModOrganizer(Exit::Force); + return; + } + + if (ModOrganizerExiting()) { + // ignore repeated attempts + event->ignore(); + return; + } + + // never close the window because settings might need to be changed + event->ignore(); + + // start the process of exiting, which may require confirmation by calling + // canExit(), among other things + ExitModOrganizer(); +} + +bool MainWindow::canExit() +{ + if (m_OrganizerCore.downloadManager()->downloadsInProgressNoPause()) { + if (QMessageBox::question( + this, tr("Downloads in progress"), + tr("There are still downloads in progress, do you really want to quit?"), + QMessageBox::Yes | QMessageBox::Cancel) == QMessageBox::Cancel) { + return false; + } else { + m_OrganizerCore.downloadManager()->pauseAll(); + } + } + + const auto r = m_OrganizerCore.waitForAllUSVFSProcesses(); + if (r == ProcessRunner::Cancelled) { + return false; + } + + setCursor(Qt::WaitCursor); + return true; +} + +void MainWindow::cleanup() +{ +#ifdef MO2_WEBENGINE + QWebEngineProfile::defaultProfile()->clearAllVisitedLinks(); +#endif + +#ifdef MO2_WEBENGINE + if (m_IntegratedBrowser) { + m_IntegratedBrowser->close(); + m_IntegratedBrowser = {}; + } +#endif + + m_SaveMetaTimer.stop(); + m_MetaSave.waitForFinished(); +} + +bool MainWindow::eventFilter(QObject* object, QEvent* event) +{ + if (event->type() == QEvent::StatusTip && object != this) { + QMainWindow::event(event); + return true; + } + + return false; +} + +void MainWindow::registerPluginTool(IPluginTool* tool, QString name, QMenu* menu) +{ + if (!menu) { + menu = ui->actionTool->menu(); + } + + if (name.isEmpty()) + name = tool->displayName(); + + QAction* action = new QAction(tool->icon(), name, menu); + action->setToolTip(tool->tooltip()); + tool->setParentWidget(this); + connect( + action, &QAction::triggered, this, + [this, tool]() { + try { + tool->display(); + } catch (const std::exception& e) { + reportError( + tr("Plugin \"%1\" failed: %2").arg(tool->localizedName()).arg(e.what())); + } catch (...) { + reportError(tr("Plugin \"%1\" failed").arg(tool->localizedName())); + } + }, + Qt::QueuedConnection); + + menu->addAction(action); +} + +void MainWindow::updateToolMenu() +{ + // Clear the menu: + ui->actionTool->menu()->clear(); + + std::vector toolPlugins = m_PluginContainer.plugins(); + + // Sort the plugins by display name + std::sort(std::begin(toolPlugins), std::end(toolPlugins), + [](IPluginTool* left, IPluginTool* right) { + return left->displayName().toLower() < right->displayName().toLower(); + }); + + // Remove disabled plugins: + toolPlugins.erase(std::remove_if(std::begin(toolPlugins), std::end(toolPlugins), + [&](auto* tool) { + return !m_PluginContainer.isEnabled(tool); + }), + toolPlugins.end()); + + // Group the plugins into submenus + QMap>> submenuMap; + for (auto toolPlugin : toolPlugins) { + QStringList toolName = toolPlugin->displayName().split("/"); + QString submenu = toolName[0]; + toolName.pop_front(); + submenuMap[submenu].append( + QPair(toolName.join("/"), toolPlugin)); + } + + // Start registering plugins + for (auto submenuKey : submenuMap.keys()) { + if (submenuMap[submenuKey].length() > 1) { + QMenu* submenu = new QMenu(submenuKey, this); + for (auto info : submenuMap[submenuKey]) { + registerPluginTool(info.second, info.first, submenu); + } + ui->actionTool->menu()->addMenu(submenu); + } else { + registerPluginTool(submenuMap[submenuKey].front().second); + } + } +} + +void MainWindow::registerModPage(IPluginModPage* modPage) +{ + QAction* action = new QAction(modPage->icon(), modPage->displayName(), this); + connect( + action, &QAction::triggered, this, + [this, modPage]() { +#ifdef MO2_WEBENGINE + if (modPage->useIntegratedBrowser()) { + + if (!m_IntegratedBrowser) { + m_IntegratedBrowser.reset(new BrowserDialog); + + connect(m_IntegratedBrowser.get(), + SIGNAL(requestDownload(QUrl, QNetworkReply*)), &m_OrganizerCore, + SLOT(requestDownload(QUrl, QNetworkReply*))); + } + + m_IntegratedBrowser->setWindowTitle(modPage->displayName()); + m_IntegratedBrowser->openUrl(modPage->pageURL()); + } else +#endif + { + shell::Open(QUrl(modPage->pageURL())); + } + }, + Qt::QueuedConnection); + + ui->actionModPage->menu()->addAction(action); +} + +bool MainWindow::registerNexusPage(const QString& gameName) +{ + // Get the plugin + IPluginGame* plugin = m_OrganizerCore.getGame(gameName); + if (plugin == nullptr) + return false; + + // Get the gameURL + QString gameURL = NexusInterface::instance().getGameURL(gameName); + if (gameURL.isEmpty()) + return false; + + // Create an action + QAction* action = new QAction(plugin->gameIcon(), + QObject::tr("Visit %1 on Nexus").arg(gameName), this); + + // Bind the action + connect( + action, &QAction::triggered, this, + [this, gameURL]() { + shell::Open(QUrl(gameURL)); + }, + Qt::QueuedConnection); + + // Add the action + ui->actionModPage->menu()->addAction(action); + + return true; +} + +void MainWindow::updateModPageMenu() +{ + // Clear the menu: + ui->actionModPage->menu()->clear(); + + // Determine the loaded mod page plugins + std::vector modPagePlugins = + m_PluginContainer.plugins(); + + // Sort the plugins by display name + std::sort(std::begin(modPagePlugins), std::end(modPagePlugins), + [](IPluginModPage* left, IPluginModPage* right) { + return left->displayName().toLower() < right->displayName().toLower(); + }); + + // Remove disabled plugins + modPagePlugins.erase(std::remove_if(std::begin(modPagePlugins), + std::end(modPagePlugins), + [&](auto* tool) { + return !m_PluginContainer.isEnabled(tool); + }), + modPagePlugins.end()); + + for (auto* modPagePlugin : modPagePlugins) { + registerModPage(modPagePlugin); + } + + QStringList registeredSources; + + // Add the primary game + QString gameShortName = m_OrganizerCore.managedGame()->gameShortName(); + if (registerNexusPage(gameShortName)) + registeredSources << gameShortName; + + // Add the primary sources + for (auto gameName : m_OrganizerCore.managedGame()->primarySources()) { + if (!registeredSources.contains(gameName) && registerNexusPage(gameName)) + registeredSources << gameName; + } + + // Add a separator if needed + if (registeredSources.length() > 0) + ui->actionModPage->menu()->addSeparator(); + + // Add the secondary games (sorted) + QStringList secondaryGames = m_OrganizerCore.managedGame()->validShortNames(); + secondaryGames.sort(Qt::CaseInsensitive); + for (auto gameName : secondaryGames) { + if (!registeredSources.contains(gameName) && registerNexusPage(gameName)) + registeredSources << gameName; + } + + // No mod page plugin and the menu was visible + bool keepOriginalAction = + modPagePlugins.size() == 0 && registeredSources.length() <= 1; + if (keepOriginalAction) { + ui->toolBar->insertAction(ui->actionAdd_Profile, ui->actionNexus); + } else { + ui->toolBar->removeAction(ui->actionNexus); + } + ui->actionModPage->setVisible(!keepOriginalAction); +} + +void MainWindow::startExeAction() +{ + QAction* action = qobject_cast(sender()); + + if (action == nullptr) { + log::error("not an action?"); + return; + } + + const auto& list = *m_OrganizerCore.executablesList(); + + const auto title = action->text(); + auto itor = list.find(title); + + if (itor == list.end()) { + log::warn("startExeAction(): executable '{}' not found", title); + return; + } + + action->setEnabled(false); + Guard g([&] { + action->setEnabled(true); + }); + + if (itor->minimizeToSystemTray()) { + m_SystemTrayManager->minimizeToSystemTray(); + } + + m_OrganizerCore.processRunner() + .setFromExecutable(*itor) + .setWaitForCompletion(ProcessRunner::TriggerRefresh) + .run(); +} + +void MainWindow::activateSelectedProfile() +{ + m_OrganizerCore.setCurrentProfile(ui->profileBox->currentText()); + + m_SavesTab->refreshSaveList(); + m_OrganizerCore.refresh(); + ui->modList->updateModCount(); + ui->espList->updatePluginCount(); + ui->statusBar->updateNormalMessage(m_OrganizerCore); +} + +void MainWindow::on_profileBox_currentIndexChanged(int index) +{ + if (!ui->profileBox->isEnabled()) { + return; + } + + int previousIndex = m_OldProfileIndex; + m_OldProfileIndex = index; + + // select has changed, save stuff + if ((previousIndex != -1) && (m_OrganizerCore.currentProfile() != nullptr) && + m_OrganizerCore.currentProfile()->exists()) { + m_OrganizerCore.saveCurrentLists(); + } + + // Avoid doing any refresh if currentProfile is already set but previous + // index was -1 as it means that this is happening during initialization so + // everything has already been set. + if (previousIndex == -1 && m_OrganizerCore.currentProfile() != nullptr && + m_OrganizerCore.currentProfile()->exists() && + ui->profileBox->currentText() == m_OrganizerCore.currentProfile()->name()) { + return; + } + + // ensure the new index is valid + if (index < 0 || index >= ui->profileBox->count()) { + log::debug("invalid profile index, using last profile"); + ui->profileBox->setCurrentIndex(ui->profileBox->count() - 1); + } + + // handle item + if (ui->profileBox->currentIndex() == 0) { + // remember the profile name that was selected before, previousIndex can't + // be used again because adding/deleting profiles will change the order + // in the list + const QString previousName = ui->profileBox->itemText(previousIndex); + + // show the dialog + ProfilesDialog dlg(previousName, m_OrganizerCore, this); + dlg.exec(); + + // check if the user clicked 'select' to select another profile + std::optional newSelection = dlg.selectedProfile(); + + // refresh the profile box; this loops until there is at least one profile + // available, which shouldn't really happen because the dialog won't allow + // it + // + // the `false` to refreshProfiles() is so it doesn't try to select the + // profile in the list because 1) it's done just below, and 2) it might be + // wrong profile if there's something in newSelection + while (!refreshProfiles(false)) { + ProfilesDialog dlg(previousName, m_OrganizerCore, this); + dlg.exec(); + newSelection = dlg.selectedProfile(); + } + + // note that setCurrentText() is recursive, it will re-execute this function + if (newSelection) { + ui->profileBox->setCurrentText(*newSelection); + } else { + ui->profileBox->setCurrentText(previousName); + } + + // nothing else to do because setCurrentText() is recursive and will + // have re-executed on_profileBox_currentIndexChanged() again, doing all + // the stuff below for the new selection + return; + } + + activateSelectedProfile(); + + auto saveGames = m_OrganizerCore.gameFeatures().gameFeature(); + if (saveGames != nullptr) { + if (saveGames->prepareProfile(m_OrganizerCore.currentProfile().get())) { + m_SavesTab->refreshSaveList(); + } + } + + auto invalidation = m_OrganizerCore.gameFeatures().gameFeature(); + if (invalidation != nullptr) { + if (invalidation->prepareProfile(m_OrganizerCore.currentProfile().get())) { + QTimer::singleShot(5, [this] { + m_OrganizerCore.refresh(); + }); + } + } +} + +bool MainWindow::refreshProfiles(bool selectProfile, QString newProfile) +{ + QComboBox* profileBox = findChild("profileBox"); + + QString currentProfileName = profileBox->currentText(); + + profileBox->blockSignals(true); + profileBox->clear(); + profileBox->addItem(QObject::tr("")); + + QDir profilesDir(Settings::instance().paths().profiles()); + profilesDir.setFilter(QDir::AllDirs | QDir::NoDotAndDotDot); + + QDirIterator profileIter(profilesDir); + + while (profileIter.hasNext()) { + profileIter.next(); + try { + profileBox->addItem(profileIter.fileName()); + } catch (const std::runtime_error& error) { + reportError(QObject::tr("failed to parse profile %1: %2") + .arg(profileIter.fileName()) + .arg(error.what())); + } + } + + // now select one of the profiles, preferably the one that was selected before + profileBox->blockSignals(false); + + if (selectProfile) { + if (profileBox->count() > 1) { + if (newProfile.isEmpty()) { + profileBox->setCurrentText(currentProfileName); + } else { + profileBox->setCurrentText(newProfile); + } + if (profileBox->currentIndex() == 0) { + profileBox->setCurrentIndex(1); + } + } + } + return profileBox->count() > 1; +} + +void MainWindow::refreshExecutablesList() +{ + QAbstractItemModel* model = ui->executablesListBox->model(); + + auto add = [&](const QString& title, const QFileInfo& binary) { + QIcon icon; + if (!binary.fileName().isEmpty()) { + icon = iconForExecutable(binary.filePath()); + } + + ui->executablesListBox->addItem(icon, title); + + const auto i = ui->executablesListBox->count() - 1; + + model->setData(model->index(i, 0), + QSize(0, ui->executablesListBox->iconSize().height() + 4), + Qt::SizeHintRole); + }; + + ui->executablesListBox->setEnabled(false); + ui->executablesListBox->clear(); + + add(tr(""), {}); + + for (const auto& exe : *m_OrganizerCore.executablesList()) { + if (!exe.hide()) { + add(exe.title(), exe.binaryInfo()); + } + } + + if (ui->executablesListBox->count() == 1) { + // all executables are hidden, add an empty one to at least be able to + // switch to edit + add(tr("(no executables)"), QFileInfo(":badfile")); + } + + ui->executablesListBox->setCurrentIndex(1); + ui->executablesListBox->setEnabled(true); +} + +static bool BySortValue(const std::pair& LHS, + const std::pair& RHS) +{ + return LHS.first < RHS.first; +} + +template +static QStringList toStringList(InputIterator current, InputIterator end) +{ + QStringList result; + for (; current != end; ++current) { + result.append(*current); + } + return result; +} + +void MainWindow::updateBSAList(const QStringList& defaultArchives, + const QStringList& activeArchives) +{ + m_DefaultArchives = defaultArchives; + ui->bsaList->clear(); + ui->bsaList->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + std::vector> items; + + auto invalidation = m_OrganizerCore.gameFeatures().gameFeature(); + std::vector files = m_OrganizerCore.directoryStructure()->getFiles(); + + QStringList plugins = + m_OrganizerCore.findFiles("", [](const QString& fileName) -> bool { + return fileName.endsWith(".esp", Qt::CaseInsensitive) || + fileName.endsWith(".esm", Qt::CaseInsensitive) || + fileName.endsWith(".esl", Qt::CaseInsensitive); + }); + + QList> pluginNamePairs; + pluginNamePairs.reserve(plugins.size()); + for (const QString& pluginName : plugins) { + QFileInfo pluginInfo(pluginName); + pluginNamePairs.append( + std::make_pair(pluginInfo.completeBaseName(), pluginInfo.fileName())); + } + + auto hasAssociatedPlugin = [&](const QString& bsaName) -> bool { + for (const auto& [completeBaseName, fileName] : pluginNamePairs) { + if (bsaName.startsWith(completeBaseName, Qt::CaseInsensitive) && + (m_OrganizerCore.pluginList()->state(fileName) == + IPluginList::STATE_ACTIVE)) { + return true; + } + } + return false; + }; + + for (FileEntryPtr current : files) { + QFileInfo fileInfo(ToQString(current->getName().c_str())); + + if (fileInfo.suffix().toLower() == "bsa" || fileInfo.suffix().toLower() == "ba2") { + int index = activeArchives.indexOf(fileInfo.fileName()); + if (index == -1) { + index = 0xFFFF; + } else { + index += 2; + } + + if ((invalidation != nullptr) && + invalidation->isInvalidationBSA(fileInfo.fileName())) { + index = 1; + } + + int originId = current->getOrigin(); + FilesOrigin& origin = + m_OrganizerCore.directoryStructure()->getOriginByID(originId); + + QTreeWidgetItem* newItem = new QTreeWidgetItem( + QStringList() << fileInfo.fileName() << ToQString(origin.getName())); + newItem->setData(0, Qt::UserRole, index); + newItem->setData(1, Qt::UserRole, originId); + newItem->setFlags(newItem->flags() & + ~(Qt::ItemIsDropEnabled | Qt::ItemIsUserCheckable)); + newItem->setCheckState(0, (index != -1) ? Qt::Checked : Qt::Unchecked); + newItem->setData(0, Qt::UserRole, false); + if (m_OrganizerCore.settings().game().forceEnableCoreFiles() && + defaultArchives.contains(fileInfo.fileName())) { + newItem->setCheckState(0, Qt::Checked); + newItem->setDisabled(true); + newItem->setData(0, Qt::UserRole, true); + } else if (fileInfo.fileName().compare("update.bsa", Qt::CaseInsensitive) == 0) { + newItem->setCheckState(0, Qt::Checked); + newItem->setDisabled(true); + } else if (hasAssociatedPlugin(fileInfo.fileName())) { + newItem->setCheckState(0, Qt::Checked); + newItem->setDisabled(true); + } else { + newItem->setCheckState(0, Qt::Unchecked); + newItem->setDisabled(true); + } + if (index < 0) + index = 0; + + UINT32 sortValue = ((origin.getPriority() & 0xFFFF) << 16) | (index & 0xFFFF); + items.push_back(std::make_pair(sortValue, newItem)); + } + } + std::sort(items.begin(), items.end(), BySortValue); + + for (auto iter = items.begin(); iter != items.end(); ++iter) { + int originID = iter->second->data(1, Qt::UserRole).toInt(); + + const FilesOrigin& origin = + m_OrganizerCore.directoryStructure()->getOriginByID(originID); + + QString modName; + const unsigned int modIndex = ModInfo::getIndex(ToQString(origin.getName())); + + if (modIndex == UINT_MAX) { + modName = UnmanagedModName(); + } else { + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); + modName = modInfo->name(); + } + + QList items = + ui->bsaList->findItems(modName, Qt::MatchFixedString); + QTreeWidgetItem* subItem = nullptr; + if (items.length() > 0) { + subItem = items.at(0); + } else { + subItem = new QTreeWidgetItem(QStringList(modName)); + subItem->setFlags(subItem->flags() & ~Qt::ItemIsDragEnabled); + ui->bsaList->addTopLevelItem(subItem); + } + subItem->addChild(iter->second); + subItem->setExpanded(true); + } + checkBSAList(); +} + +void MainWindow::checkBSAList() +{ + auto archives = m_OrganizerCore.gameFeatures().gameFeature(); + + if (archives != nullptr) { + ui->bsaList->blockSignals(true); + ON_BLOCK_EXIT([&]() { + ui->bsaList->blockSignals(false); + }); + + QStringList defaultArchives = + archives->archives(m_OrganizerCore.currentProfile().get()); + + bool warning = false; + + for (int i = 0; i < ui->bsaList->topLevelItemCount(); ++i) { + bool modWarning = false; + QTreeWidgetItem* tlItem = ui->bsaList->topLevelItem(i); + for (int j = 0; j < tlItem->childCount(); ++j) { + QTreeWidgetItem* item = tlItem->child(j); + QString filename = item->text(0); + item->setIcon(0, QIcon()); + item->setToolTip(0, QString()); + + if (item->checkState(0) == Qt::Unchecked) { + if (defaultArchives.contains(filename)) { + item->setIcon(0, QIcon(":/MO/gui/warning")); + item->setToolTip( + 0, tr("This bsa is enabled in the ini file so it may be required!")); + modWarning = true; + } + } + } + if (modWarning) { + ui->bsaList->expandItem(ui->bsaList->topLevelItem(i)); + warning = true; + } + } + if (warning) { + ui->tabWidget->setTabIcon(1, QIcon(":/MO/gui/warning")); + } else { + ui->tabWidget->setTabIcon(1, QIcon()); + } + } +} + +void MainWindow::saveModMetas() +{ + if (m_MetaSave.isFinished()) { + m_MetaSave = QtConcurrent::run([this]() { + for (unsigned int i = 0; i < ModInfo::getNumMods(); ++i) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(i); + modInfo->saveMeta(); + } + }); + } +} + +void MainWindow::fixCategories() +{ + for (unsigned int i = 0; i < ModInfo::getNumMods(); ++i) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(i); + std::set categories = modInfo->getCategories(); + for (std::set::iterator iter = categories.begin(); iter != categories.end(); + ++iter) { + if (!m_CategoryFactory.categoryExists(*iter)) { + modInfo->setCategory(*iter, false); + } + } + } +} + +void MainWindow::setupNetworkProxy(bool activate) +{ + QNetworkProxyFactory::setUseSystemConfiguration(activate); +} + +void MainWindow::activateProxy(bool activate) +{ + QProgressDialog busyDialog(tr("Activating Network Proxy"), QString(), 0, 0, + parentWidget()); + busyDialog.setWindowFlags(busyDialog.windowFlags() & + ~Qt::WindowContextHelpButtonHint); + busyDialog.setWindowModality(Qt::WindowModal); + busyDialog.show(); + + QFutureWatcher futureWatcher; + QEventLoop loop; + connect(&futureWatcher, &QFutureWatcher::finished, &loop, &QEventLoop::quit, + Qt::QueuedConnection); + + futureWatcher.setFuture(QtConcurrent::run(MainWindow::setupNetworkProxy, activate)); + + // wait for setupNetworkProxy while keeping ui responsive + loop.exec(); + + busyDialog.hide(); +} + +void MainWindow::readSettings() +{ + const auto& s = m_OrganizerCore.settings(); + + if (!s.geometry().restoreGeometry(this)) { + resize(1300, 800); + } + + s.geometry().restoreState(this); + s.geometry().restoreDocks(this); + s.geometry().restoreToolbars(this); + s.geometry().restoreState(ui->splitter); + s.geometry().restoreState(ui->categoriesSplitter); + s.geometry().restoreVisibility(ui->menuBar); + s.geometry().restoreVisibility(ui->statusBar); + + FilterWidget::setOptions(s.interface().filterOptions()); + + { + // special case in case someone puts 0 in the INI + auto v = s.widgets().index(ui->executablesListBox); + if (!v || v == 0) { + v = 1; + } + + ui->executablesListBox->setCurrentIndex(*v); + } + + s.widgets().restoreIndex(ui->tabWidget); + + ui->modList->restoreState(s); + + { + s.geometry().restoreVisibility(ui->categoriesGroup, false); + const auto v = ui->categoriesGroup->isVisible(); + setCategoryListVisible(v); + ui->displayCategoriesBtn->setChecked(v); + } + + if (s.network().useProxy()) { + activateProxy(true); + } +} + +void MainWindow::processUpdates() +{ + auto& settings = m_OrganizerCore.settings(); + const auto earliest = QVersionNumber::fromString("2.1.2").normalized(); + + const auto lastVersion = settings.version().value_or(earliest); + const auto currentVersion = + QVersionNumber::fromString(m_OrganizerCore.getVersion().string()).normalized(); + + m_LastVersion = lastVersion; + + settings.processUpdates(currentVersion, lastVersion); + + if (!settings.firstStart()) { + if (lastVersion < QVersionNumber(2, 1, 6)) { + ui->modList->header()->setSectionHidden(ModList::COL_NOTES, true); + } + + if (lastVersion < QVersionNumber(2, 2, 1)) { + // hide new columns by default + for (int i = DownloadList::COL_MODNAME; i < DownloadList::COL_COUNT; ++i) { + ui->downloadView->header()->hideSection(i); + } + } + + if (lastVersion < QVersionNumber(2, 3)) { + for (int i = 1; i < ui->dataTree->header()->count(); ++i) + ui->dataTree->setColumnWidth(i, 150); + } + } + + if (currentVersion < lastVersion) { + const auto text = + tr("Notice: Your current MO version (%1) is lower than the previously used one " + "(%2). " + "The GUI may not downgrade gracefully, so you may experience oddities. " + "However, there should be no serious issues.") + .arg(currentVersion.toString()) + .arg(lastVersion.toString()); + + log::warn("{}", text); + } +} + +void MainWindow::storeSettings() +{ + auto& s = m_OrganizerCore.settings(); + + s.geometry().saveState(this); + s.geometry().saveGeometry(this); + s.geometry().saveDocks(this); + + s.geometry().saveVisibility(ui->menuBar); + s.geometry().saveVisibility(ui->statusBar); + s.geometry().saveToolbars(this); + s.geometry().saveState(ui->splitter); + s.geometry().saveState(ui->categoriesSplitter); + s.geometry().saveMainWindowMonitor(this); + s.geometry().saveVisibility(ui->categoriesGroup); + + s.geometry().saveState(ui->espList->header()); + s.geometry().saveState(ui->downloadView->header()); + s.geometry().saveState(ui->savegameList->header()); + + s.widgets().saveIndex(ui->executablesListBox); + s.widgets().saveIndex(ui->tabWidget); + + m_DataTab->saveState(s); + ui->modList->saveState(s); + + s.interface().setFilterOptions(FilterWidget::options()); +} + +QMainWindow* MainWindow::mainWindow() +{ + return this; +} + +void MainWindow::on_tabWidget_currentChanged(int index) +{ + QWidget* currentWidget = ui->tabWidget->widget(index); + if (currentWidget == ui->espTab) { + m_OrganizerCore.refreshESPList(); + ui->espList->activated(); + } else if (currentWidget == ui->bsaTab) { + m_OrganizerCore.refreshBSAList(); + } else if (currentWidget == ui->dataTab) { + m_DataTab->activated(); + } else if (currentWidget == ui->savesTab) { + m_SavesTab->refreshSaveList(); + } +} + +void MainWindow::on_startButton_clicked() +{ + const Executable* selectedExecutable = getSelectedExecutable(); + if (!selectedExecutable) { + return; + } + + ui->startButton->setEnabled(false); + Guard g([&] { + ui->startButton->setEnabled(true); + }); + + if (selectedExecutable->minimizeToSystemTray()) { + m_SystemTrayManager->minimizeToSystemTray(); + } + + m_OrganizerCore.processRunner() + .setFromExecutable(*selectedExecutable) + .setWaitForCompletion(ProcessRunner::TriggerRefresh) + .run(); +} + +bool MainWindow::modifyExecutablesDialog(int selection) +{ + bool result = false; + + try { + EditExecutablesDialog dialog(m_OrganizerCore, selection, this); + + result = (dialog.exec() == QDialog::Accepted); + + refreshExecutablesList(); + updatePinnedExecutables(); + } catch (const std::exception& e) { + reportError(e.what()); + } + + return result; +} + +void MainWindow::on_executablesListBox_currentIndexChanged(int index) +{ + if (!ui->executablesListBox->isEnabled()) { + return; + } + + const int previousIndex = (m_OldExecutableIndex > 0 ? m_OldExecutableIndex : 1); + + m_OldExecutableIndex = index; + + if (index == 0) { + modifyExecutablesDialog(previousIndex - 1); + const auto newCount = ui->executablesListBox->count(); + + if (previousIndex >= 0 && previousIndex < newCount) { + ui->executablesListBox->setCurrentIndex(previousIndex); + } else { + ui->executablesListBox->setCurrentIndex(newCount - 1); + } + } +} + +void MainWindow::helpTriggered() +{ + QWhatsThis::enterWhatsThisMode(); +} + +void MainWindow::wikiTriggered() +{ + shell::Open(QUrl("https://modorganizer2.github.io/")); +} + +void MainWindow::gameSupportTriggered() +{ + shell::Open(QUrl(m_OrganizerCore.managedGame()->getSupportURL())); +} + +void MainWindow::discordTriggered() +{ + shell::Open(QUrl("https://discord.gg/ewUVAqyrQX")); +} + +void MainWindow::issueTriggered() +{ + shell::Open(QUrl("https://github.com/Modorganizer2/modorganizer/issues")); +} + +void MainWindow::tutorialTriggered() +{ + QAction* tutorialAction = qobject_cast(sender()); + if (tutorialAction != nullptr) { + TutorialManager::instance().activateTutorial("MainWindow", + tutorialAction->data().toString()); + } +} + +void MainWindow::on_actionInstallMod_triggered() +{ + ui->modList->actions().installMod(); +} + +void MainWindow::on_action_Refresh_triggered() +{ + refreshProfile_activated(); +} + +void MainWindow::on_actionAdd_Profile_triggered() +{ + for (;;) { + ProfilesDialog profilesDialog(m_OrganizerCore.currentProfile()->name(), + m_OrganizerCore, this); + + // workaround: need to disable monitoring of the saves directory, otherwise the + // active profile directory is locked + m_SavesTab->stopMonitorSaves(); + profilesDialog.exec(); + m_SavesTab->refreshSaveList(); // since the save list may now be outdated we have + // to refresh it completely + + if (refreshProfiles(true, profilesDialog.selectedProfile().value_or("")) && + !profilesDialog.failed()) { + break; + } + } + + auto saveGames = m_OrganizerCore.gameFeatures().gameFeature(); + if (saveGames != nullptr) { + if (saveGames->prepareProfile(m_OrganizerCore.currentProfile().get())) { + m_SavesTab->refreshSaveList(); + } + } + + auto invalidation = m_OrganizerCore.gameFeatures().gameFeature(); + if (invalidation != nullptr) { + if (invalidation->prepareProfile(m_OrganizerCore.currentProfile().get())) { + QTimer::singleShot(5, [this] { + m_OrganizerCore.refresh(); + }); + } + } +} + +void MainWindow::on_actionModify_Executables_triggered() +{ + const auto sel = (m_OldExecutableIndex > 0 ? m_OldExecutableIndex - 1 : 0); + + if (modifyExecutablesDialog(sel)) { + const auto newCount = ui->executablesListBox->count(); + if (m_OldExecutableIndex >= 0 && m_OldExecutableIndex < newCount) { + ui->executablesListBox->setCurrentIndex(m_OldExecutableIndex); + } else { + ui->executablesListBox->setCurrentIndex(newCount - 1); + } + } +} + +void MainWindow::refresherProgress(const DirectoryRefreshProgress* p) +{ + if (p->finished()) { + setEnabled(true); + ui->statusBar->setProgress(100); + } else { + setEnabled(false); + ui->statusBar->setProgress(p->percentDone()); + } +} + +void MainWindow::onDirectoryStructureChanged() +{ + // some problem-reports may rely on the virtual directory tree so they need to be + // updated now + scheduleCheckForProblems(); + m_DataTab->updateTree(); +} + +void MainWindow::modInstalled(const QString& modName) +{ + if (!m_OrganizerCore.settings().interface().checkUpdateAfterInstallation()) { + return; + } + + unsigned int index = ModInfo::getIndex(modName); + + if (index == UINT_MAX) { + return; + } + + // force an update to happen + ui->modList->actions().checkModsForUpdates( + {m_OrganizerCore.modList()->index(index, 0)}); +} + +void MainWindow::importCategories(bool) +{ + NexusInterface& nexus = NexusInterface::instance(); + nexus.setPluginContainer(&m_OrganizerCore.pluginContainer()); + nexus.requestGameInfo(Settings::instance().game().plugin()->gameShortName(), this, + QVariant(), QString()); +} + +void MainWindow::showMessage(const QString& message) +{ + MessageDialog::showMessage(message, this); +} + +void MainWindow::showError(const QString& message) +{ + reportError(message); +} + +void MainWindow::modRenamed(const QString& oldName, const QString& newName) +{ + Profile::renameModInAllProfiles(oldName, newName); + + // immediately refresh the active profile because the data in memory is invalid + m_OrganizerCore.currentProfile()->refreshModStatus(); + + // also fix the directory structure + try { + if (m_OrganizerCore.directoryStructure()->originExists(ToWString(oldName))) { + FilesOrigin& origin = + m_OrganizerCore.directoryStructure()->getOriginByName(ToWString(oldName)); + origin.setName(ToWString(newName)); + } else { + } + } catch (const std::exception& e) { + reportError(tr("failed to change origin name: %1").arg(e.what())); + } +} + +void MainWindow::fileMoved(const QString& filePath, const QString& oldOriginName, + const QString& newOriginName) +{ + const FileEntryPtr filePtr = + m_OrganizerCore.directoryStructure()->findFile(ToWString(filePath)); + if (filePtr.get() != nullptr) { + try { + if (m_OrganizerCore.directoryStructure()->originExists( + ToWString(newOriginName))) { + FilesOrigin& newOrigin = m_OrganizerCore.directoryStructure()->getOriginByName( + ToWString(newOriginName)); + + QString fullNewPath = ToQString(newOrigin.getPath()) + "/" + filePath; +#ifdef _WIN32 + WIN32_FIND_DATAW findData; + HANDLE hFind; + hFind = ::FindFirstFileW(ToWString(fullNewPath).c_str(), &findData); + filePtr->addOrigin(newOrigin.getID(), findData.ftCreationTime, L"", -1); + FindClose(hFind); +#else + // On Linux, use a default FILETIME for the origin + FILETIME ft = {}; + QFileInfo fi(fullNewPath); + if (fi.exists()) { + // Convert QDateTime to Windows FILETIME (100-ns intervals since 1601) + qint64 msecs = fi.birthTime().toMSecsSinceEpoch(); + uint64_t ticks = (static_cast(msecs) * 10000ULL) + 116444736000000000ULL; + ft.dwLowDateTime = static_cast(ticks & 0xFFFFFFFF); + ft.dwHighDateTime = static_cast(ticks >> 32); + } + filePtr->addOrigin(newOrigin.getID(), ft, L"", -1); +#endif + } + if (m_OrganizerCore.directoryStructure()->originExists( + ToWString(oldOriginName))) { + FilesOrigin& oldOrigin = m_OrganizerCore.directoryStructure()->getOriginByName( + ToWString(oldOriginName)); + filePtr->removeOrigin(oldOrigin.getID()); + } + } catch (const std::exception& e) { + reportError(tr("failed to move \"%1\" from mod \"%2\" to \"%3\": %4") + .arg(filePath) + .arg(oldOriginName) + .arg(newOriginName) + .arg(e.what())); + } + } else { + // this is probably not an error, the specified path is likely a directory + } +} + +void MainWindow::modRemoved(const QString& fileName) +{ + if (!fileName.isEmpty() && !QFileInfo(fileName).isAbsolute()) { + m_OrganizerCore.downloadManager()->markUninstalled(fileName); + } +} + +void MainWindow::windowTutorialFinished(const QString& windowName) +{ + m_OrganizerCore.settings().interface().setTutorialCompleted(windowName); +} + +void MainWindow::displayModInformation(ModInfo::Ptr modInfo, unsigned int modIndex, + ModInfoTabIDs tabID) +{ + ui->modList->actions().displayModInformation(modInfo, modIndex, tabID); +} + +bool MainWindow::closeWindow() +{ + return close(); +} + +void MainWindow::setWindowEnabled(bool enabled) +{ + setEnabled(enabled); +} + +void MainWindow::refreshProfile_activated() +{ + m_OrganizerCore.refresh(); +} + +void MainWindow::saveArchiveList() +{ + if (m_OrganizerCore.isArchivesInit()) { + SafeWriteFile archiveFile(m_OrganizerCore.currentProfile()->getArchivesFileName()); + for (int i = 0; i < ui->bsaList->topLevelItemCount(); ++i) { + QTreeWidgetItem* tlItem = ui->bsaList->topLevelItem(i); + for (int j = 0; j < tlItem->childCount(); ++j) { + QTreeWidgetItem* item = tlItem->child(j); + if (item->checkState(0) == Qt::Checked) { + archiveFile->write(item->text(0).toUtf8().append("\r\n")); + } + } + } + archiveFile->commit(); + } else { + log::debug("archive list not initialised"); + } +} + +void MainWindow::openInstanceFolder() +{ + QString dataPath = qApp->property("dataPath").toString(); + shell::Explore(dataPath); +} + +void MainWindow::openInstallFolder() +{ + shell::Explore(AppConfig::basePath()); +} + +void MainWindow::openPluginsFolder() +{ + QString pluginsPath = + AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath()); + shell::Explore(pluginsPath); +} + +void MainWindow::openStylesheetsFolder() +{ + QString ssPath = AppConfig::basePath() + "/" + + ToQString(AppConfig::stylesheetsPath()); + shell::Explore(ssPath); +} + +void MainWindow::openProfileFolder() +{ + shell::Explore(m_OrganizerCore.currentProfile()->absolutePath()); +} + +void MainWindow::openIniFolder() +{ + if (m_OrganizerCore.currentProfile()->localSettingsEnabled()) { + shell::Explore(m_OrganizerCore.currentProfile()->absolutePath()); + } else { + shell::Explore(m_OrganizerCore.managedGame()->documentsDirectory()); + } +} + +void MainWindow::openDownloadsFolder() +{ + shell::Explore(m_OrganizerCore.settings().paths().downloads()); +} + +void MainWindow::openModsFolder() +{ + shell::Explore(m_OrganizerCore.settings().paths().mods()); +} + +void MainWindow::openGameFolder() +{ + shell::Explore(m_OrganizerCore.managedGame()->gameDirectory()); +} + +void MainWindow::openMyGamesFolder() +{ + shell::Explore(m_OrganizerCore.managedGame()->documentsDirectory()); +} + +QMenu* MainWindow::openFolderMenu() +{ + QMenu* FolderMenu = new QMenu(this); + + // game folders that are not necessarily MO-specific + FolderMenu->addAction(tr("Open Game folder"), this, SLOT(openGameFolder())); + FolderMenu->addAction(tr("Open MyGames folder"), this, SLOT(openMyGamesFolder())); + FolderMenu->addAction(tr("Open INIs folder"), this, SLOT(openIniFolder())); + + FolderMenu->addSeparator(); + + // MO-specific folders that are related to modding the game + FolderMenu->addAction(tr("Open Instance folder"), this, SLOT(openInstanceFolder())); + FolderMenu->addAction(tr("Open Mods folder"), this, SLOT(openModsFolder())); + FolderMenu->addAction(tr("Open Profile folder"), this, SLOT(openProfileFolder())); + FolderMenu->addAction(tr("Open Downloads folder"), this, SLOT(openDownloadsFolder())); + + FolderMenu->addSeparator(); + + // MO-specific folders that are not directly related to modding and are either + // in the installation folder or the instance + FolderMenu->addAction(tr("Open MO2 Install folder"), this, SLOT(openInstallFolder())); + FolderMenu->addAction(tr("Open MO2 Plugins folder"), this, SLOT(openPluginsFolder())); + FolderMenu->addAction(tr("Open MO2 Stylesheets folder"), this, + SLOT(openStylesheetsFolder())); + FolderMenu->addAction(tr("Open MO2 Logs folder"), [=] { + ui->logList->openLogsFolder(); + }); + + return FolderMenu; +} + +void MainWindow::linkToolbar() +{ + Executable* exe = getSelectedExecutable(); + if (!exe) { + return; + } + + exe->setShownOnToolbar(!exe->isShownOnToolbar()); + updatePinnedExecutables(); +} + +void MainWindow::linkDesktop() +{ + if (auto* exe = getSelectedExecutable()) { + env::Shortcut(*exe).toggle(env::Shortcut::Desktop); + } +} + +void MainWindow::linkMenu() +{ + if (auto* exe = getSelectedExecutable()) { + env::Shortcut(*exe).toggle(env::Shortcut::StartMenu); + } +} + +void MainWindow::on_linkButton_pressed() +{ + const Executable* exe = getSelectedExecutable(); + if (!exe) { + return; + } + + const QIcon addIcon(":/MO/gui/link"); + const QIcon removeIcon(":/MO/gui/remove"); + + env::Shortcut shortcut(*exe); + + m_LinkToolbar->setIcon(exe->isShownOnToolbar() ? removeIcon : addIcon); + + m_LinkDesktop->setIcon(shortcut.exists(env::Shortcut::Desktop) ? removeIcon + : addIcon); + + m_LinkStartMenu->setIcon(shortcut.exists(env::Shortcut::StartMenu) ? removeIcon + : addIcon); +} + +void MainWindow::on_actionSettings_triggered() +{ + Settings& settings = m_OrganizerCore.settings(); + + QString oldModDirectory(settings.paths().mods()); + QString oldCacheDirectory(settings.paths().cache()); + QString oldProfilesDirectory(settings.paths().profiles()); + QString oldManagedGameDirectory(settings.game().directory().value_or("")); + bool oldDisplayForeign(settings.interface().displayForeign()); + bool oldArchiveParsing(settings.archiveParsing()); + bool proxy = settings.network().useProxy(); + DownloadManager* dlManager = m_OrganizerCore.downloadManager(); + const bool oldCheckForUpdates = settings.checkForUpdates(); + const int oldMaxDumps = settings.diagnostics().maxCoreDumps(); + + SettingsDialog dialog(&m_PluginContainer, settings, this); + dialog.exec(); + + auto e = dialog.exitNeeded(); + + if (oldManagedGameDirectory != settings.game().directory()) { + e |= Exit::Restart; + } + + if (e.testFlag(Exit::Restart)) { + const auto r = + MOBase::TaskDialog(this) + .title(tr("Restart Mod Organizer")) + .main("Restart Mod Organizer") + .content(tr("Mod Organizer must restart to finish configuration changes")) + .icon(QMessageBox::Question) + .button({tr("Restart"), QMessageBox::Yes}) + .button( + {tr("Continue"), tr("Some things might be weird."), QMessageBox::No}) + .exec(); + + if (r == QMessageBox::Yes) { + ExitModOrganizer(e); + } + } + + InstallationManager* instManager = m_OrganizerCore.installationManager(); + instManager->setModsDirectory(settings.paths().mods()); + instManager->setDownloadDirectory(settings.paths().downloads()); + + // Schedule a problem check since diagnose plugins may have been enabled / disabled. + scheduleCheckForProblems(); + + fixCategories(); + ui->modList->refreshFilters(); + ui->modList->refresh(); + + m_OrganizerCore.refreshLists(); + + updateSortButton(); + + if (settings.paths().profiles() != oldProfilesDirectory) { + refreshProfiles(); + } + + if (dlManager->getOutputDirectory() != settings.paths().downloads()) { + if (dlManager->downloadsInProgress()) { + MessageDialog::showMessage(tr("Can't change download directory while " + "downloads are in progress!"), + this); + } else { + dlManager->setOutputDirectory(settings.paths().downloads()); + } + } + + if ((settings.paths().mods() != oldModDirectory) || + (settings.interface().displayForeign() != oldDisplayForeign)) { + m_OrganizerCore.refresh(); + } + + const auto state = settings.archiveParsing(); + if (state != oldArchiveParsing) { + if (!state) { + ui->dataTabShowFromArchives->setCheckState(Qt::Unchecked); + ui->dataTabShowFromArchives->setEnabled(false); + } else { + ui->dataTabShowFromArchives->setCheckState(Qt::Checked); + ui->dataTabShowFromArchives->setEnabled(true); + } + m_OrganizerCore.refresh(); + } + + if (settings.paths().cache() != oldCacheDirectory) { + NexusInterface::instance().setCacheDirectory(settings.paths().cache()); + } + + if (proxy != settings.network().useProxy()) { + activateProxy(settings.network().useProxy()); + } + + ui->statusBar->checkSettings(m_OrganizerCore.settings()); + m_DownloadsTab->update(); + + m_OrganizerCore.setLogLevel(settings.diagnostics().logLevel()); + + if (settings.diagnostics().maxCoreDumps() != oldMaxDumps) { + m_OrganizerCore.cycleDiagnostics(); + } + + toggleMO2EndorseState(); + + if (oldCheckForUpdates != settings.checkForUpdates()) { + toggleUpdateAction(); + + if (settings.checkForUpdates()) { + m_OrganizerCore.checkForUpdates(); + } + } +} + +void MainWindow::onPluginRegistrationChanged() +{ + updateModPageMenu(); + scheduleCheckForProblems(); + m_DownloadsTab->update(); +} + +void MainWindow::refreshNexusCategories(CategoriesDialog* dialog) +{ + NexusInterface& nexus = NexusInterface::instance(); + nexus.setPluginContainer(&m_PluginContainer); + if (!Settings::instance().game().plugin()->primarySources().isEmpty()) { + nexus.requestGameInfo( + Settings::instance().game().plugin()->primarySources().first(), dialog, + QVariant(), QString()); + } else { + nexus.requestGameInfo(Settings::instance().game().plugin()->gameShortName(), dialog, + QVariant(), QString()); + } +} + +void MainWindow::categoriesSaved() +{ + for (auto modName : m_OrganizerCore.modList()->allMods()) { + auto mod = ModInfo::getByName(modName); + for (auto category : mod->getCategories()) { + if (!m_CategoryFactory.categoryExists(category)) + mod->setCategory(category, false); + } + } +} + +void MainWindow::on_actionNexus_triggered() +{ + const IPluginGame* game = m_OrganizerCore.managedGame(); + QString gameName = game->gameShortName(); + if (game->gameNexusName().isEmpty() && game->primarySources().count()) + gameName = game->primarySources()[0]; + shell::Open(QUrl(NexusInterface::instance().getGameURL(gameName))); +} + +void MainWindow::installTranslator(const QString& name) +{ + QTranslator* translator = new QTranslator(this); + QString fileName = name + "_" + m_CurrentLanguage; + if (!translator->load(fileName, qApp->applicationDirPath() + "/translations")) { + if (m_CurrentLanguage.contains(QRegularExpression("^.*_(EN|en)(-.*)?$"))) { + log::debug("localization file %s not found", fileName); + } // we don't actually expect localization files for English (en, en-us, en-uk, and + // any variation thereof) + } + + qApp->installTranslator(translator); + m_Translators.push_back(translator); +} + +void MainWindow::languageChange(const QString& newLanguage) +{ + for (QTranslator* trans : m_Translators) { + qApp->removeTranslator(trans); + } + m_Translators.clear(); + + m_CurrentLanguage = newLanguage; + + installTranslator("qt"); + installTranslator("qtbase"); + installTranslator(ToQString(AppConfig::translationPrefix())); + installTranslator("uibase"); + + // TODO: this will probably be changed once extension come out + installTranslator("game_gamebryo"); + installTranslator("game_creation"); + + for (const QString& fileName : m_PluginContainer.pluginFileNames()) { + installTranslator(QFileInfo(fileName).baseName()); + } + ui->retranslateUi(this); + log::debug("loaded language {}", newLanguage); + + ui->profileBox->setItemText(0, QObject::tr("")); + + createHelpMenu(); + + if (m_DownloadsTab) { + m_DownloadsTab->update(); + } + + ui->listOptionsBtn->setMenu( + new ModListGlobalContextMenu(m_OrganizerCore, ui->modList, this)); + ui->openFolderMenu->setMenu(openFolderMenu()); +} + +void MainWindow::originModified(int originID) +{ + FilesOrigin& origin = m_OrganizerCore.directoryStructure()->getOriginByID(originID); + origin.enable(false); + + DirectoryStats dummy; + QString path = QString::fromStdWString(origin.getPath()); + QString modDataDir = m_OrganizerCore.managedGame()->modDataDirectory(); + path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; + m_OrganizerCore.directoryStructure()->addFromOrigin( + origin.getName(), path.toStdWString(), origin.getPriority(), dummy); + + DirectoryRefresher::cleanStructure(m_OrganizerCore.directoryStructure()); +} + +void MainWindow::updateAvailable() +{ + ui->actionUpdate->setEnabled(true); + ui->actionUpdate->setToolTip(tr("Update available")); + ui->statusBar->setUpdateAvailable(true); +} + +void MainWindow::motdReceived(const QString& motd) +{ + // don't show motd after 5 seconds, may be annoying. Hopefully the user's + // internet connection is faster next time + if (m_StartTime.secsTo(QTime::currentTime()) < 5) { + uint hash = qHash(motd); + if (hash != m_OrganizerCore.settings().motdHash()) { + MotDDialog dialog(motd); + dialog.exec(); + m_OrganizerCore.settings().setMotdHash(hash); + } + } +} + +void MainWindow::on_actionUpdate_triggered() +{ + m_OrganizerCore.startMOUpdate(); +} + +void MainWindow::on_actionExit_triggered() +{ + ExitModOrganizer(); +} + +void MainWindow::actionEndorseMO() +{ + // Normally this would be the managed game but MO2 is only uploaded to the Skyrim SE + // site right now + IPluginGame* game = m_OrganizerCore.getGame("skyrimse"); + if (!game) + return; + + if (QMessageBox::question( + this, tr("Endorse Mod Organizer"), + tr("Do you want to endorse Mod Organizer on %1 now?") + .arg(NexusInterface::instance().getGameURL(game->gameShortName())), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + NexusInterface::instance().requestToggleEndorsement( + game->gameShortName(), game->nexusModOrganizerID(), + m_OrganizerCore.getVersion().string(), true, this, QVariant(), QString()); + } +} + +void MainWindow::actionWontEndorseMO() +{ + // Normally this would be the managed game but MO2 is only uploaded to the Skyrim SE + // site right now + IPluginGame* game = m_OrganizerCore.getGame("skyrimse"); + if (!game) + return; + + if (QMessageBox::question( + this, tr("Abstain from Endorsing Mod Organizer"), + tr("Are you sure you want to abstain from endorsing Mod Organizer 2?\n" + "You will have to visit the mod page on the %1 Nexus site to change your " + "mind.") + .arg(NexusInterface::instance().getGameURL(game->gameShortName())), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + NexusInterface::instance().requestToggleEndorsement( + game->gameShortName(), game->nexusModOrganizerID(), + m_OrganizerCore.getVersion().string(), false, this, QVariant(), QString()); + } +} + +void MainWindow::toggleMO2EndorseState() +{ + const auto& s = m_OrganizerCore.settings(); + + if (!s.nexus().endorsementIntegration()) { + ui->actionEndorseMO->setVisible(false); + return; + } + + ui->actionEndorseMO->setVisible(true); + + bool enabled = false; + QString text; + + switch (s.nexus().endorsementState()) { + case EndorsementState::Accepted: { + text = tr("Thank you for endorsing MO2! :)"); + break; + } + + case EndorsementState::Refused: { + text = tr("Please reconsider endorsing MO2 on Nexus!"); + break; + } + + case EndorsementState::NoDecision: { + enabled = true; + break; + } + } + + ui->actionEndorseMO->menu()->setEnabled(enabled); + ui->actionEndorseMO->setToolTip(text); + ui->actionEndorseMO->setStatusTip(text); +} + +void MainWindow::toggleUpdateAction() +{ + const auto& s = m_OrganizerCore.settings(); + ui->actionUpdate->setVisible(s.checkForUpdates()); +} + +void MainWindow::updateSortButton() +{ + if (m_OrganizerCore.managedGame()->sortMechanism() != + IPluginGame::SortMechanism::NONE) { + ui->sortButton->setEnabled(true); + ui->sortButton->setToolTip(tr("Sort the plugins using LOOT.")); + } else { + ui->sortButton->setDisabled(true); + ui->sortButton->setToolTip(tr("There is no supported sort mechanism for this game. " + "You will probably have to use a third-party tool.")); + } +} + +void MainWindow::nxmEndorsementsAvailable(QVariant userData, QVariant resultData, int) +{ + QVariantList data = resultData.toList(); + std::multimap> sorted; + QStringList games = m_OrganizerCore.managedGame()->validShortNames(); + games += m_OrganizerCore.managedGame()->gameShortName(); + bool searchedMO2NexusGame = false; + for (auto endorsementData : data) { + QVariantMap endorsement = endorsementData.toMap(); + std::pair data = std::make_pair( + endorsement["mod_id"].toInt(), endorsement["status"].toString()); + sorted.insert(std::pair>( + endorsement["domain_name"].toString(), data)); + } + for (auto game : games) { + IPluginGame* gamePlugin = m_OrganizerCore.getGame(game); + if (gamePlugin != nullptr && + gamePlugin->gameShortName().compare("SkyrimSE", Qt::CaseInsensitive) == 0) + searchedMO2NexusGame = true; + auto iter = sorted.equal_range(gamePlugin->gameNexusName()); + for (auto result = iter.first; result != iter.second; ++result) { + std::vector modsList = + ModInfo::getByModID(result->first, result->second.first); + + for (auto mod : modsList) { + if (mod->endorsedState() != EndorsedState::ENDORSED_NEVER) { + if (result->second.second == "Endorsed") + mod->setIsEndorsed(true); + else if (result->second.second == "Abstained") + mod->setNeverEndorse(); + else + mod->setIsEndorsed(false); + } + } + + if (Settings::instance().nexus().endorsementIntegration()) { + if (result->first == "skyrimspecialedition" && + result->second.first == gamePlugin->nexusModOrganizerID()) { + m_OrganizerCore.settings().nexus().setEndorsementState( + endorsementStateFromString(result->second.second)); + + toggleMO2EndorseState(); + } + } + } + } + + if (!searchedMO2NexusGame && Settings::instance().nexus().endorsementIntegration()) { + auto gamePlugin = m_OrganizerCore.getGame("SkyrimSE"); + if (gamePlugin) { + auto iter = sorted.equal_range(gamePlugin->gameNexusName()); + for (auto result = iter.first; result != iter.second; ++result) { + if (result->second.first == gamePlugin->nexusModOrganizerID()) { + m_OrganizerCore.settings().nexus().setEndorsementState( + endorsementStateFromString(result->second.second)); + + toggleMO2EndorseState(); + break; + } + } + } + } +} + +void MainWindow::nxmUpdateInfoAvailable(QString gameName, QVariant userData, + QVariant resultData, int) +{ + QString gameNameReal; + for (IPluginGame* game : m_PluginContainer.plugins()) { + if (game->gameNexusName() == gameName) { + gameNameReal = game->gameShortName(); + break; + } + } + QVariantList resultList = resultData.toList(); + + auto* watcher = new QFutureWatcher(); + QObject::connect(watcher, &QFutureWatcher::finished, + [this, watcher]() { + finishUpdateInfo(watcher->result()); + watcher->deleteLater(); + }); + auto future = QtConcurrent::run([=]() { + return NxmUpdateInfoData{ + gameNameReal, + ModInfo::filteredMods(gameNameReal, resultList, userData.toBool(), true)}; + }); + watcher->setFuture(future); + ui->modList->invalidateFilter(); +} + +void MainWindow::finishUpdateInfo(const NxmUpdateInfoData& data) +{ + if (data.finalMods.empty()) { + log::info("{}", tr("None of your %1 mods appear to have had recent file updates.") + .arg(data.game)); + } + + std::set> organizedGames; + for (auto& mod : data.finalMods) { + if (mod->canBeUpdated()) { + organizedGames.insert( + std::make_pair(mod->gameName().toLower(), mod->nexusId())); + } + } + + if (!data.finalMods.empty() && organizedGames.empty()) + log::warn("{}", tr("All of your mods have been checked recently. We restrict " + "update checks to help preserve your available API requests.")); + + for (const auto& game : organizedGames) { + NexusInterface::instance().requestUpdates(game.second, this, QVariant(), game.first, + QString()); + } +} + +void MainWindow::nxmUpdatesAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID) +{ + QVariantMap resultInfo = resultData.toMap(); + QList files = resultInfo["files"].toList(); + QList fileUpdates = resultInfo["file_updates"].toList(); + QString gameNameReal; + + for (IPluginGame* game : m_PluginContainer.plugins()) { + if (game->gameNexusName() == gameName) { + gameNameReal = game->gameShortName(); + break; + } + } + + std::vector modsList = ModInfo::getByModID(gameNameReal, modID); + + bool requiresInfo = false; + + for (auto mod : modsList) { + QString validNewVersion; + int newModStatus = -1; + QString installedFile = QFileInfo(mod->installationFile()).fileName(); + + if (!installedFile.isEmpty()) { + QVariantMap foundFileData; + + // update the file status + for (auto& file : files) { + QVariantMap fileData = file.toMap(); + + if (fileData["file_name"].toString().compare(installedFile, + Qt::CaseInsensitive) == 0) { + foundFileData = fileData; + newModStatus = foundFileData["category_id"].toInt(); + + if (newModStatus != NexusInterface::FileStatus::OLD_VERSION && + newModStatus != NexusInterface::FileStatus::REMOVED && + newModStatus != NexusInterface::FileStatus::ARCHIVED) { + + // since the file is still active if there are no updates for it, use this + // as current version + validNewVersion = foundFileData["version"].toString(); + } + break; + } + } + + if (foundFileData.isEmpty()) { + // The file was not listed, the file is likely archived and archived files are + // being hidden on the mod + newModStatus = NexusInterface::FileStatus::ARCHIVED_HIDDEN; + } + + // look for updates of the file + int currentUpdateId = -1; + + // find installed file ID from the updates list since old filenames are not + // guaranteed to be unique + for (auto& updateEntry : fileUpdates) { + const QVariantMap& updateData = updateEntry.toMap(); + + if (installedFile.compare(updateData["old_file_name"].toString(), + Qt::CaseInsensitive) == 0) { + currentUpdateId = updateData["old_file_id"].toInt(); + break; + } + } + + bool foundActiveUpdate = false; + + // there is at least one update + if (currentUpdateId > 0) { + bool lookForMoreUpdates = true; + + // follow the update chain until there are no more updates + while (lookForMoreUpdates) { + lookForMoreUpdates = false; + + for (auto& updateEntry : fileUpdates) { + const QVariantMap& updateData = updateEntry.toMap(); + + if (currentUpdateId == updateData["old_file_id"].toInt()) { + currentUpdateId = updateData["new_file_id"].toInt(); + + // check if the new file is still active + for (auto& file : files) { + const QVariantMap& fileData = file.toMap(); + + if (currentUpdateId == fileData["file_id"].toInt()) { + int updateStatus = fileData["category_id"].toInt(); + + if (updateStatus != NexusInterface::FileStatus::OLD_VERSION && + updateStatus != NexusInterface::FileStatus::REMOVED && + updateStatus != NexusInterface::FileStatus::ARCHIVED) { + + // new version is active, so record it + validNewVersion = fileData["version"].toString(); + foundActiveUpdate = true; + } + break; + } + } + + lookForMoreUpdates = true; + break; + } + } + } + } + + // if there were no active direct file updates for the installedFile + if (!foundActiveUpdate) { + // get the global mod version in case the file isn't an optional + if (newModStatus != NexusInterface::FileStatus::OPTIONAL_FILE && + newModStatus != NexusInterface::FileStatus::MISCELLANEOUS) { + requiresInfo = true; + } + } + } else { + // No installedFile means we don't know what to look at for a version so + // just get the global mod version + requiresInfo = true; + } + + if (newModStatus > 0) { + mod->setNexusFileStatus(newModStatus); + } + + if (!validNewVersion.isEmpty()) { + mod->setNewestVersion(validNewVersion); + mod->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); + } + } + + // invalidate the filter to display mods with an update + ui->modList->invalidateFilter(); + + if (requiresInfo) { + NexusInterface::instance().requestModInfo(gameNameReal, modID, this, QVariant(), + QString()); + } +} + +void MainWindow::nxmModInfoAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID) +{ + QVariantMap result = resultData.toMap(); + QString gameNameReal; + bool foundUpdate = false; + + for (IPluginGame* game : m_PluginContainer.plugins()) { + if (game->gameNexusName() == gameName) { + gameNameReal = game->gameShortName(); + break; + } + } + + std::vector modsList = ModInfo::getByModID(gameNameReal, modID); + + for (auto mod : modsList) { + QDateTime now = QDateTime::currentDateTimeUtc(); + QDateTime updateTarget = mod->getExpires(); + + // if file is still listed as optional or miscellaneous don't update the version as + // often optional files are left with an older version than the main mod version. + if (!result["version"].toString().isEmpty() && + mod->getNexusFileStatus() != NexusInterface::FileStatus::OPTIONAL_FILE && + mod->getNexusFileStatus() != NexusInterface::FileStatus::MISCELLANEOUS) { + + mod->setNewestVersion(result["version"].toString()); + foundUpdate = true; + } + + // update the LastNexusUpdate time in any case since we did perform the check. + mod->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); + + mod->setNexusDescription(result["description"].toString()); + + mod->setNexusCategory(result["category_id"].toInt()); + + mod->setAuthor(result["author"].toString()); + + mod->setUploader(result["uploaded_by"].toString()); + + mod->setUploaderUrl(result["uploaded_users_profile_url"].toString()); + + if ((mod->endorsedState() != EndorsedState::ENDORSED_NEVER) && + (result.contains("endorsement"))) { + QVariantMap endorsement = result["endorsement"].toMap(); + QString endorsementStatus = endorsement["endorse_status"].toString(); + + if (endorsementStatus.compare("Endorsed") == 00) + mod->setIsEndorsed(true); + else if (endorsementStatus.compare("Abstained") == 00) + mod->setNeverEndorse(); + else + mod->setIsEndorsed(false); + } + + mod->setLastNexusQuery(QDateTime::currentDateTimeUtc()); + mod->setNexusLastModified( + QDateTime::fromSecsSinceEpoch(result["updated_timestamp"].toInt(), Qt::UTC)); + + m_OrganizerCore.modList()->notifyChange(ModInfo::getIndex(mod->name())); + } + + if (foundUpdate) { + // invalidate the filter to display mods with an update + ui->modList->invalidateFilter(); + } +} + +void MainWindow::nxmEndorsementToggled(QString, int, QVariant, QVariant resultData, int) +{ + const QMap results = resultData.toMap(); + + auto itor = results.find("status"); + if (itor == results.end()) { + log::error("endorsement response has no status"); + return; + } + + const auto s = endorsementStateFromString(itor->toString()); + + switch (s) { + case EndorsementState::Accepted: { + QMessageBox::information(this, tr("Thank you!"), + tr("Thank you for your endorsement!")); + break; + } + + case EndorsementState::Refused: { + // don't spam message boxes if the user doesn't want to endorse + log::info( + "Mod Organizer will not be endorsed and will no longer ask you to endorse."); + break; + } + + case EndorsementState::NoDecision: { + log::error("bad status '{}' in endorsement response", itor->toString()); + return; + } + } + + m_OrganizerCore.settings().nexus().setEndorsementState(s); + toggleMO2EndorseState(); + + if (!disconnect(sender(), + SIGNAL(nxmEndorsementToggled(QString, int, QVariant, QVariant, int)), + this, + SLOT(nxmEndorsementToggled(QString, int, QVariant, QVariant, int)))) { + log::error("failed to disconnect endorsement slot"); + } +} + +void MainWindow::nxmTrackedModsAvailable(QVariant userData, QVariant resultData, int) +{ + QMap gameNames; + for (auto game : m_PluginContainer.plugins()) { + gameNames[game->gameNexusName()] = game->gameShortName(); + } + + for (unsigned int i = 0; i < ModInfo::getNumMods(); i++) { + auto modInfo = ModInfo::getByIndex(i); + if (modInfo->nexusId() <= 0) + continue; + + bool found = false; + auto resultsList = resultData.toList(); + for (auto item : resultsList) { + auto results = item.toMap(); + if ((gameNames[results["domain_name"].toString()].compare( + modInfo->gameName(), Qt::CaseInsensitive) == 0) && + (results["mod_id"].toInt() == modInfo->nexusId())) { + found = true; + break; + } + } + + modInfo->setIsTracked(found); + modInfo->saveMeta(); + } +} + +void MainWindow::nxmDownloadURLs(QString, int, int, QVariant, QVariant resultData, int) +{ + auto servers = m_OrganizerCore.settings().network().servers(); + + for (const QVariant& var : resultData.toList()) { + const QVariantMap map = var.toMap(); + + const auto name = map["short_name"].toString(); + const auto isPremium = + map["name"].toString().contains("Premium", Qt::CaseInsensitive); + const auto isCDN = + map["short_name"].toString().contains("CDN", Qt::CaseInsensitive); + + bool found = false; + + for (auto& server : servers) { + if (server.name() == name) { + // already exists, update + server.setPremium(isPremium); + server.updateLastSeen(); + found = true; + break; + } + } + + if (!found) { + // new server + ServerInfo server(name, isPremium, QDate::currentDate(), isCDN ? 1 : 0, {}); + servers.add(std::move(server)); + } + } + + m_OrganizerCore.settings().network().updateServers(servers); +} + +void MainWindow::nxmGameInfoAvailable(QString gameName, QVariant, QVariant resultData, + int) +{ + QVariantMap result = resultData.toMap(); + QVariantList categories = result["categories"].toList(); + CategoryFactory& catFactory = CategoryFactory::instance(); + catFactory.reset(); + for (auto category : categories) { + auto catMap = category.toMap(); + std::vector nexusCat; + nexusCat.push_back(CategoryFactory::NexusCategory(catMap["name"].toString(), + catMap["category_id"].toInt())); + catFactory.addCategory(catMap["name"].toString(), nexusCat, 0); + } +} + +void MainWindow::nxmRequestFailed(QString gameName, int modID, int, QVariant, int, + int errorCode, const QString& errorString) +{ + if (errorCode == QNetworkReply::ContentAccessDenied || + errorCode == QNetworkReply::ContentNotFoundError) { + log::debug("{}", + tr("Mod ID %1 no longer seems to be available on Nexus.").arg(modID)); + + // update last checked timestamp on orphaned mods as well to avoid repeating + // requests + QString gameNameReal; + for (IPluginGame* game : m_PluginContainer.plugins()) { + if (game->gameNexusName() == gameName) { + gameNameReal = game->gameShortName(); + break; + } + } + auto orphanedMods = ModInfo::getByModID(gameNameReal, modID); + for (auto mod : orphanedMods) { + mod->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); + mod->setLastNexusQuery(QDateTime::currentDateTimeUtc()); + } + } else { + MessageDialog::showMessage( + tr("Error %1: Request to Nexus failed: %2").arg(errorCode).arg(errorString), + this); + } +} + +BSA::EErrorCode MainWindow::extractBSA(BSA::Archive& archive, BSA::Folder::Ptr folder, + const QString& destination, + QProgressDialog& progress) +{ + QDir().mkdir(destination); + BSA::EErrorCode result = BSA::ERROR_NONE; + QString errorFile; + + for (unsigned int i = 0; i < folder->getNumFiles(); ++i) { + BSA::File::Ptr file = folder->getFile(i); + BSA::EErrorCode res = archive.extract(file, qUtf8Printable(destination)); + if (res != BSA::ERROR_NONE) { + reportError(tr("failed to read %1: %2").arg(file->getName().c_str()).arg(res)); + result = res; + } + progress.setLabelText(file->getName().c_str()); + progress.setValue(progress.value() + 1); + QCoreApplication::processEvents(); + if (progress.wasCanceled()) { + result = BSA::ERROR_CANCELED; + } + } + + if (result != BSA::ERROR_NONE) { + if (QMessageBox::critical( + this, tr("Error"), + tr("failed to extract %1 (errorcode %2)").arg(errorFile).arg(result), + QMessageBox::Ok | QMessageBox::Cancel) == QMessageBox::Cancel) { + return result; + } + } + + for (unsigned int i = 0; i < folder->getNumSubFolders(); ++i) { + BSA::Folder::Ptr subFolder = folder->getSubFolder(i); + BSA::EErrorCode res = extractBSA( + archive, subFolder, + destination.mid(0).append("/").append(subFolder->getName().c_str()), progress); + if (res != BSA::ERROR_NONE) { + return res; + } + } + return BSA::ERROR_NONE; +} + +bool MainWindow::extractProgress(QProgressDialog& progress, int percentage, + std::string fileName) +{ + progress.setLabelText(fileName.c_str()); + progress.setValue(percentage); + QCoreApplication::processEvents(); + return !progress.wasCanceled(); +} + +void MainWindow::extractBSATriggered(QTreeWidgetItem* item) +{ + using namespace boost::placeholders; + + QString origin; + + QString targetFolder = + FileDialogMemory::getExistingDirectory("extractBSA", this, tr("Extract BSA")); + QStringList archives = {}; + if (!targetFolder.isEmpty()) { + if (!item->parent()) { + for (int i = 0; i < item->childCount(); ++i) { + archives.append(item->child(i)->text(0)); + } + origin = QDir::fromNativeSeparators( + ToQString(m_OrganizerCore.directoryStructure() + ->getOriginByName(ToWString(item->text(0))) + .getPath())); + } else { + origin = QDir::fromNativeSeparators( + ToQString(m_OrganizerCore.directoryStructure() + ->getOriginByName(ToWString(item->text(1))) + .getPath())); + archives = QStringList({item->text(0)}); + } + + for (auto archiveName : archives) { + BSA::Archive archive; + QString archivePath = QDir(origin).filePath(archiveName); + BSA::EErrorCode result = + archive.read(archivePath.toLocal8Bit().constData(), true); + if ((result != BSA::ERROR_NONE) && (result != BSA::ERROR_INVALIDHASHES)) { + reportError(tr("failed to read %1: %2").arg(archivePath).arg(result)); + return; + } + + QProgressDialog progress(this); + progress.setMaximum(100); + progress.setValue(0); + progress.show(); + archive.extractAll( + QDir::toNativeSeparators(targetFolder).toLocal8Bit().constData(), + boost::bind(&MainWindow::extractProgress, this, boost::ref(progress), _1, + _2)); + if (result == BSA::ERROR_INVALIDHASHES) { + reportError( + tr("This archive contains invalid hashes. Some files may be broken.")); + } + archive.close(); + } + } +} + +void MainWindow::on_bsaList_customContextMenuRequested(const QPoint& pos) +{ + QMenu menu; + menu.addAction(tr("Extract..."), [=, item = ui->bsaList->itemAt(pos)]() { + extractBSATriggered(item); + }); + + menu.exec(ui->bsaList->viewport()->mapToGlobal(pos)); +} + +void MainWindow::on_bsaList_itemChanged(QTreeWidgetItem*, int) +{ + m_ArchiveListWriter.write(); + m_CheckBSATimer.start(500); +} + +void MainWindow::on_actionNotifications_triggered() +{ + auto future = checkForProblemsAsync(); + + future.waitForFinished(); + + ProblemsDialog problems(m_PluginContainer, this); + problems.exec(); + + scheduleCheckForProblems(); +} + +void MainWindow::on_actionChange_Game_triggered() +{ + InstanceManagerDialog dlg(m_PluginContainer, this); + dlg.exec(); +} + +void MainWindow::setCategoryListVisible(bool visible) +{ + if (visible) { + ui->categoriesGroup->show(); + ui->displayCategoriesBtn->setText(ToQString(L"\u00ab")); + } else { + ui->categoriesGroup->hide(); + ui->displayCategoriesBtn->setText(ToQString(L"\u00bb")); + } +} + +void MainWindow::on_displayCategoriesBtn_toggled(bool checked) +{ + setCategoryListVisible(checked); +} + +void MainWindow::removeFromToolbar(QAction* action) +{ + const auto& title = action->text(); + auto& list = *m_OrganizerCore.executablesList(); + + auto itor = list.find(title); + if (itor == list.end()) { + log::warn("removeFromToolbar(): executable '{}' not found", title); + return; + } + + itor->setShownOnToolbar(false); + updatePinnedExecutables(); +} + +void MainWindow::toolBar_customContextMenuRequested(const QPoint& point) +{ + QAction* action = ui->toolBar->actionAt(point); + + if (action != nullptr) { + if (action->objectName().startsWith("custom_")) { + QMenu menu; + menu.addAction(tr("Remove '%1' from the toolbar").arg(action->text()), + [&, action]() { + removeFromToolbar(action); + }); + menu.exec(ui->toolBar->mapToGlobal(point)); + return; + } + } + + // did not click a link button, show the default context menu + auto* m = createPopupMenu(); + m->exec(ui->toolBar->mapToGlobal(point)); +} + +Executable* MainWindow::getSelectedExecutable() +{ + const QString name = + ui->executablesListBox->itemText(ui->executablesListBox->currentIndex()); + + try { + return &m_OrganizerCore.executablesList()->get(name); + } catch (std::runtime_error&) { + return nullptr; + } +} + +void MainWindow::on_showHiddenBox_toggled(bool checked) +{ + m_OrganizerCore.downloadManager()->setShowHidden(checked); +} + +const char* MainWindow::PATTERN_BACKUP_GLOB = ".????_??_??_??_??_??"; +const char* MainWindow::PATTERN_BACKUP_REGEX = + "\\.(\\d\\d\\d\\d_\\d\\d_\\d\\d_\\d\\d_\\d\\d_\\d\\d)"; +const char* MainWindow::PATTERN_BACKUP_DATE = "yyyy_MM_dd_hh_mm_ss"; + +bool MainWindow::createBackup(const QString& filePath, const QDateTime& time) +{ + QString outPath = filePath + "." + time.toString(PATTERN_BACKUP_DATE); + if (shellCopy(QStringList(filePath), QStringList(outPath), this)) { + QFileInfo fileInfo(filePath); + removeOldFiles(fileInfo.absolutePath(), fileInfo.fileName() + PATTERN_BACKUP_GLOB, + 10, QDir::Name); + return true; + } else { + return false; + } +} + +void MainWindow::on_saveButton_clicked() +{ + m_OrganizerCore.savePluginList(); + QDateTime now = QDateTime::currentDateTime(); + if (createBackup(m_OrganizerCore.currentProfile()->getPluginsFileName(), now) && + createBackup(m_OrganizerCore.currentProfile()->getLoadOrderFileName(), now) && + createBackup(m_OrganizerCore.currentProfile()->getLockedOrderFileName(), now)) { + MessageDialog::showMessage(tr("Backup of load order created"), this); + } +} + +QString MainWindow::queryRestore(const QString& filePath) +{ + QFileInfo pluginFileInfo(filePath); + QString pattern = pluginFileInfo.fileName() + ".*"; + QFileInfoList files = pluginFileInfo.absoluteDir().entryInfoList( + QStringList(pattern), QDir::Files, QDir::Name); + + SelectionDialog dialog(tr("Choose backup to restore"), this); + QRegularExpression exp(QRegularExpression::anchoredPattern(pluginFileInfo.fileName() + + PATTERN_BACKUP_REGEX)); + // match orphaned SafeWriteFile temporaries + QRegularExpression exp2(QRegularExpression::anchoredPattern( + pluginFileInfo.fileName() + "\\.([A-Za-z]{6})")); + QRegularExpression exp3( + QRegularExpression::anchoredPattern(pluginFileInfo.fileName() + "\\.(.*)")); + for (const QFileInfo& info : boost::adaptors::reverse(files)) { + auto match = exp.match(info.fileName()); + auto match2 = exp2.match(info.fileName()); + auto match3 = exp3.match(info.fileName()); + if (match.hasMatch()) { + QDateTime time = QDateTime::fromString(match.captured(1), PATTERN_BACKUP_DATE); + dialog.addChoice(time.toString(), "", match.captured(1)); + } else if (match2.hasMatch()) { + dialog.addChoice(match2.captured(1), + tr("This file might be left over following a crash or power " + "loss event. Check its contents before restoring."), + match2.captured(1)); + } else if (match3.hasMatch()) { + dialog.addChoice(match3.captured(1), "", match3.captured(1)); + } + } + + if (dialog.numChoices() == 0) { + QMessageBox::information(this, tr("No Backups"), + tr("There are no backups to restore")); + return QString(); + } + + if (dialog.exec() == QDialog::Accepted) { + return dialog.getChoiceData().toString(); + } else { + return QString(); + } +} + +void MainWindow::on_restoreButton_clicked() +{ + QString pluginName = m_OrganizerCore.currentProfile()->getPluginsFileName(); + QString choice = queryRestore(pluginName); + if (!choice.isEmpty()) { + QString loadOrderName = m_OrganizerCore.currentProfile()->getLoadOrderFileName(); + QString lockedName = m_OrganizerCore.currentProfile()->getLockedOrderFileName(); + if (!shellCopy(pluginName + "." + choice, pluginName, true, this) || + !shellCopy(loadOrderName + "." + choice, loadOrderName, true, this) || + !shellCopy(lockedName + "." + choice, lockedName, true, this)) { + + const auto e = GetLastError(); + + QMessageBox::critical(this, tr("Restore failed"), + tr("Failed to restore the backup. Errorcode: %1") + .arg(QString::fromStdWString(formatSystemMessage(e)))); + } + m_OrganizerCore.refreshESPList(true); + } +} + +void MainWindow::on_saveModsButton_clicked() +{ + m_OrganizerCore.currentProfile()->writeModlistNow(true); + QDateTime now = QDateTime::currentDateTime(); + if (createBackup(m_OrganizerCore.currentProfile()->getModlistFileName(), now)) { + MessageDialog::showMessage(tr("Backup of mod list created"), this); + } +} + +void MainWindow::on_restoreModsButton_clicked() +{ + QString modlistName = m_OrganizerCore.currentProfile()->getModlistFileName(); + QString choice = queryRestore(modlistName); + if (!choice.isEmpty()) { + if (!shellCopy(modlistName + "." + choice, modlistName, true, this)) { + const auto e = GetLastError(); + QMessageBox::critical(this, tr("Restore failed"), + tr("Failed to restore the backup. Errorcode: %1") + .arg(QString::fromStdWString(formatSystemMessage(e)))); + } + m_OrganizerCore.refresh(false); + } +} + +void MainWindow::on_managedArchiveLabel_linkHovered(const QString&) +{ + QToolTip::showText(QCursor::pos(), ui->managedArchiveLabel->toolTip()); +} + +void MainWindow::dragEnterEvent(QDragEnterEvent* event) +{ + // Accept copy or move drags to the download window. Link drags are not + // meaningful (Well, they are - we could drop a link in the download folder, + // but you need privileges to do that). + if (ui->downloadTab->isVisible() && + (event->proposedAction() == Qt::CopyAction || + event->proposedAction() == Qt::MoveAction) && + event->answerRect().intersects(ui->downloadTab->rect())) { + + // If I read the documentation right, this won't work under a motif windows + // manager and the check needs to be done at the drop. However, that means + // the user might be allowed to drop things which we can't sanely process + QMimeData const* data = event->mimeData(); + + if (data->hasUrls()) { + QStringList extensions = + m_OrganizerCore.installationManager()->getSupportedExtensions(); + + // This is probably OK - scan to see if these are moderately sane archive + // types + QList urls = data->urls(); + bool ok = true; + for (const QUrl& url : urls) { + if (url.isLocalFile()) { + QString local = url.toLocalFile(); + bool fok = false; + for (auto ext : extensions) { + if (local.endsWith(ext, Qt::CaseInsensitive)) { + fok = true; + break; + } + } + if (!fok) { + ok = false; + break; + } + } + } + if (ok) { + event->accept(); + } + } + } +} + +void MainWindow::dropLocalFile(const QUrl& url, const QString& outputDir, bool move) +{ + QFileInfo file(url.toLocalFile()); + if (!file.exists()) { + log::warn("invalid source file: {}", file.absoluteFilePath()); + return; + } + QString target = outputDir + "/" + file.fileName(); + if (QFile::exists(target)) { + QMessageBox box(QMessageBox::Question, file.fileName(), + tr("A file with the same name has already been downloaded. " + "What would you like to do?")); + box.addButton(tr("Overwrite"), QMessageBox::ActionRole); + box.addButton(tr("Rename new file"), QMessageBox::YesRole); + box.addButton(tr("Ignore file"), QMessageBox::RejectRole); + + box.exec(); + switch (box.buttonRole(box.clickedButton())) { + case QMessageBox::RejectRole: + return; + case QMessageBox::ActionRole: + break; + default: + case QMessageBox::YesRole: + target = m_OrganizerCore.downloadManager()->getDownloadFileName(file.fileName()); + break; + } + } + + bool success = false; + if (move) { + success = shellMove(file.absoluteFilePath(), target, true, this); + } else { + success = shellCopy(file.absoluteFilePath(), target, true, this); + } + if (!success) { + const auto e = GetLastError(); + log::error("file operation failed: {}", formatSystemMessage(e)); + } +} + +void MainWindow::dropEvent(QDropEvent* event) +{ + Qt::DropAction action = event->proposedAction(); + QString outputDir = m_OrganizerCore.downloadManager()->getOutputDirectory(); + if (action == Qt::MoveAction) { + // Tell windows I'm taking control and will delete the source of a move. + event->setDropAction(Qt::TargetMoveAction); + } + for (const QUrl& url : event->mimeData()->urls()) { + if (url.isLocalFile()) { + dropLocalFile(url, outputDir, action == Qt::MoveAction); + } else { + m_OrganizerCore.downloadManager()->startDownloadURLs(QStringList() << url.url()); + } + } + event->accept(); +} + +void MainWindow::keyReleaseEvent(QKeyEvent* event) +{ + // if the ui is locked, ignore the ALT key event + // alt-tabbing out of a game triggers this + auto& uilocker = UILocker::instance(); + auto& settings = Settings::instance(); + if (!uilocker.locked()) { + // if the menubar is hidden and showMenuBarOnAlt is true, + // pressing Alt will make it visible + if (event->key() == Qt::Key_Alt) { + bool showMenubarOnAlt = settings.interface().showMenubarOnAlt(); + if (showMenubarOnAlt && !ui->menuBar->isVisible()) { + ui->menuBar->show(); + } + } + } + + QMainWindow::keyReleaseEvent(event); +} diff --git a/src/src/mainwindow.h b/src/src/mainwindow.h new file mode 100644 index 0000000..b931c35 --- /dev/null +++ b/src/src/mainwindow.h @@ -0,0 +1,495 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef MAINWINDOW_H +#define MAINWINDOW_H + +#include +#include +#include +#include +#include +#include + +#include "delayedfilewriter.h" +#include "iuserinterface.h" +#include "modinfo.h" +#include "modlistbypriorityproxy.h" +#include "modlistsortproxy.h" +#include "plugincontainer.h" +#include "shared/fileregisterfwd.h" +#include "systemtraymanager.h" + +class Executable; +class CategoryFactory; +class OrganizerCore; +class FilterList; +class DataTab; +class DownloadsTab; +class SavesTab; +#ifdef MO2_WEBENGINE +class BrowserDialog; +#endif + +class PluginListSortProxy; +namespace BSA +{ +class Archive; +} + +namespace MOBase +{ +class IPluginModPage; +} +namespace MOBase +{ +class IPluginTool; +} + +namespace MOShared +{ +class DirectoryEntry; +} + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QAction; +class QAbstractItemModel; +class QDateTime; +class QEvent; +class QFile; +class QListWidgetItem; +class QMenu; +class QModelIndex; +class QPoint; +class QProgressDialog; +class QTranslator; +class QTreeWidgetItem; +class QUrl; +class QWidget; + +#ifndef Q_MOC_RUN +#include +#endif + +#ifdef _WIN32 +// Sigh - just for HANDLE +#define WIN32_LEAN_AND_MEAN +#include +#endif + +#include +#include +#include +#include + +namespace Ui +{ +class MainWindow; +} + +class Settings; + +class MainWindow : public QMainWindow, public IUserInterface +{ + Q_OBJECT + + friend class OrganizerProxy; + +public: + explicit MainWindow(Settings& settings, OrganizerCore& organizerCore, + PluginContainer& pluginContainer, QWidget* parent = 0); + ~MainWindow(); + + void processUpdates(); + + QMainWindow* mainWindow() override; + + bool addProfile(); + void updateBSAList(const QStringList& defaultArchives, + const QStringList& activeArchives); + + void saveArchiveList(); + + void installTranslator(const QString& name); + + void displayModInformation(ModInfo::Ptr modInfo, unsigned int modIndex, + ModInfoTabIDs tabID) override; + + bool canExit(); + void onBeforeClose(); + + virtual bool closeWindow(); + virtual void setWindowEnabled(bool enabled); + + virtual MOBase::DelayedFileWriterBase& archivesWriter() override + { + return m_ArchiveListWriter; + } + +public slots: + void refresherProgress(const DirectoryRefreshProgress* p); + +signals: + // emitted after the information dialog has been closed, used by tutorials + // + void modInfoDisplayed(); + + /** + * @brief emitted when the selected style changes + */ + void styleChanged(const QString& styleFile); + + void checkForProblemsDone(); + +protected: + void showEvent(QShowEvent* event) override; + void paintEvent(QPaintEvent* event) override; + void closeEvent(QCloseEvent* event) override; + bool eventFilter(QObject* obj, QEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + void dragEnterEvent(QDragEnterEvent* event) override; + void dropEvent(QDropEvent* event) override; + void keyReleaseEvent(QKeyEvent* event) override; + +private slots: + void on_actionChange_Game_triggered(); + +private: + // update data tab and schedule a problem check after a directory + // structure update + // + void onDirectoryStructureChanged(); + + void cleanup(); + + void setupToolbar(); + void setupActionMenu(QAction* a); + void createHelpMenu(); + void createEndorseMenu(); + + void updatePinnedExecutables(); + void setToolbarSize(const QSize& s); + void setToolbarButtonStyle(Qt::ToolButtonStyle s); + + void registerModPage(MOBase::IPluginModPage* modPage); + bool registerNexusPage(const QString& gameName); + void registerPluginTool(MOBase::IPluginTool* tool, QString name = QString(), + QMenu* menu = nullptr); + + void updateToolbarMenu(); + void updateToolMenu(); + void updateModPageMenu(); + void updateViewMenu(); + + QMenu* createPopupMenu() override; + void activateSelectedProfile(); + + bool refreshProfiles(bool selectProfile = true, QString newProfile = QString()); + void refreshExecutablesList(); + + bool modifyExecutablesDialog(int selection); + + // remove invalid category-references from mods + void fixCategories(); + + bool extractProgress(QProgressDialog& extractProgress, int percentage, + std::string fileName); + + // Performs checks, sets the m_NumberOfProblems and signals checkForProblemsDone(). + void checkForProblemsImpl(); + + void setCategoryListVisible(bool visible); + + bool errorReported(QString& logFile); + + static void setupNetworkProxy(bool activate); + void activateProxy(bool activate); + + bool createBackup(const QString& filePath, const QDateTime& time); + QString queryRestore(const QString& filePath); + + QMenu* openFolderMenu(); + + void dropLocalFile(const QUrl& url, const QString& outputDir, bool move); + + void toggleMO2EndorseState(); + void toggleUpdateAction(); + + void updateSortButton(); + + // update info + struct NxmUpdateInfoData + { + QString game; + std::set finalMods; + }; + void finishUpdateInfo(const NxmUpdateInfoData& data); + +private: + static const char* PATTERN_BACKUP_GLOB; + static const char* PATTERN_BACKUP_REGEX; + static const char* PATTERN_BACKUP_DATE; + +private: + Ui::MainWindow* ui; + + bool m_WasVisible; + bool m_FirstPaint; + + // last separator on the toolbar, used to add spacer for right-alignment and + // as an insert point for executables + QAction* m_linksSeparator; + + MOBase::TutorialControl m_Tutorial; + + std::unique_ptr m_DataTab; + std::unique_ptr m_DownloadsTab; + std::unique_ptr m_SavesTab; + + int m_OldProfileIndex; + + std::vector + m_ModNameList; // the mod-list to go with the directory structure + + QStringList m_DefaultArchives; + + int m_OldExecutableIndex; + + QAction* m_ContextAction; + + CategoryFactory& m_CategoryFactory; + + QTimer m_CheckBSATimer; + QTimer m_SaveMetaTimer; + QTimer m_UpdateProblemsTimer; + + QFuture m_MetaSave; + + QTime m_StartTime; + + OrganizerCore& m_OrganizerCore; + PluginContainer& m_PluginContainer; + + QString m_CurrentLanguage; + std::vector m_Translators; + +#ifdef MO2_WEBENGINE + std::unique_ptr m_IntegratedBrowser; +#endif + + MOBase::DelayedFileWriter m_ArchiveListWriter; + + QAction* m_LinkToolbar; + QAction* m_LinkDesktop; + QAction* m_LinkStartMenu; + + SystemTrayManager* m_SystemTrayManager; + + // icon set by the stylesheet, used to remember its original appearance + // when painting the count + QIcon m_originalNotificationIcon; + + std::atomic m_NumberOfProblems; + std::atomic m_ProblemsCheckRequired; + std::mutex m_CheckForProblemsMutex; + + QVersionNumber m_LastVersion; + + Executable* getSelectedExecutable(); + +private slots: + void updateWindowTitle(const APIUserAccount& user); + void showMessage(const QString& message); + void showError(const QString& message); + + // main window actions + void helpTriggered(); + void issueTriggered(); + void wikiTriggered(); + void gameSupportTriggered(); + void discordTriggered(); + void tutorialTriggered(); + void extractBSATriggered(QTreeWidgetItem* item); + + void refreshProfile_activated(); + + void linkToolbar(); + void linkDesktop(); + void linkMenu(); + + void languageChange(const QString& newLanguage); + + void windowTutorialFinished(const QString& windowName); + + BSA::EErrorCode extractBSA(BSA::Archive& archive, BSA::Folder::Ptr folder, + const QString& destination, + QProgressDialog& extractProgress); + + // nexus related + void updateAvailable(); + + void actionEndorseMO(); + void actionWontEndorseMO(); + + void motdReceived(const QString& motd); + + void originModified(int originID); + + void modInstalled(const QString& modName); + + void importCategories(bool); + + void refreshNexusCategories(CategoriesDialog* dialog); + void categoriesSaved(); + + // update info + void nxmUpdateInfoAvailable(QString gameName, QVariant userData, QVariant resultData, + int requestID); + + void nxmEndorsementsAvailable(QVariant userData, QVariant resultData, int); + void nxmUpdatesAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID); + void nxmModInfoAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID); + void nxmEndorsementToggled(QString, int, QVariant, QVariant resultData, int); + void nxmTrackedModsAvailable(QVariant userData, QVariant resultData, int); + void nxmDownloadURLs(QString, int modID, int fileID, QVariant userData, + QVariant resultData, int requestID); + void nxmGameInfoAvailable(QString gameName, QVariant, QVariant resultData, int); + void nxmRequestFailed(QString gameName, int modID, int fileID, QVariant userData, + int requestID, int errorCode, const QString& errorString); + + void modRenamed(const QString& oldName, const QString& newName); + void modRemoved(const QString& fileName); + + void hookUpWindowTutorials(); + bool shouldStartTutorial() const; + + void openInstanceFolder(); + void openInstallFolder(); + void openPluginsFolder(); + void openStylesheetsFolder(); + void openDownloadsFolder(); + void openModsFolder(); + void openProfileFolder(); + void openIniFolder(); + void openGameFolder(); + void openMyGamesFolder(); + void startExeAction(); + + void checkBSAList(); + + // Only visually update the problems icon. + void updateProblemsButton(); + + // Queue a problem check to allow collapsing of multiple requests in short amount of + // time. + void scheduleCheckForProblems(); + + // Perform the actual problem check in another thread. + QFuture checkForProblemsAsync(); + + void saveModMetas(); + + void updateStyle(const QString& style); + + void resizeLists(bool pluginListCustom); + + void fileMoved(const QString& filePath, const QString& oldOriginName, + const QString& newOriginName); + + /** + * @brief allow columns in mod list and plugin list to be resized + */ + void allowListResize(); + + void toolBar_customContextMenuRequested(const QPoint& point); + void removeFromToolbar(QAction* action); + + void about(); + + void resetActionIcons(); + +private slots: // ui slots + // actions + void on_actionAdd_Profile_triggered(); + void on_actionInstallMod_triggered(); + void on_action_Refresh_triggered(); + void on_actionModify_Executables_triggered(); + void on_actionNexus_triggered(); + void on_actionNotifications_triggered(); + void on_actionSettings_triggered(); + void on_actionUpdate_triggered(); + void on_actionExit_triggered(); + void on_actionMainMenuToggle_triggered(); + void on_actionToolBarMainToggle_triggered(); + void on_actionStatusBarToggle_triggered(); + void on_actionToolBarSmallIcons_triggered(); + void on_actionToolBarMediumIcons_triggered(); + void on_actionToolBarLargeIcons_triggered(); + void on_actionToolBarIconsOnly_triggered(); + void on_actionToolBarTextOnly_triggered(); + void on_actionToolBarIconsAndText_triggered(); + void on_actionViewLog_triggered(); + + void on_centralWidget_customContextMenuRequested(const QPoint& pos); + void on_bsaList_customContextMenuRequested(const QPoint& pos); + void on_executablesListBox_currentIndexChanged(int index); + void on_profileBox_currentIndexChanged(int index); + void on_startButton_clicked(); + void on_tabWidget_currentChanged(int index); + + void on_displayCategoriesBtn_toggled(bool checked); + void on_linkButton_pressed(); + void on_showHiddenBox_toggled(bool checked); + void on_bsaList_itemChanged(QTreeWidgetItem* item, int column); + + void on_saveButton_clicked(); + void on_restoreButton_clicked(); + void on_restoreModsButton_clicked(); + void on_saveModsButton_clicked(); + void on_managedArchiveLabel_linkHovered(const QString& link); + + void onPluginRegistrationChanged(); + + void storeSettings(); + void readSettings(); + + void setupModList(); +}; + +#endif // MAINWINDOW_H diff --git a/src/src/mainwindow.ui b/src/src/mainwindow.ui new file mode 100644 index 0000000..0e8e2bf --- /dev/null +++ b/src/src/mainwindow.ui @@ -0,0 +1,2054 @@ + + + MainWindow + + + + 0 + 0 + 1009 + 710 + + + + true + + + Mod Organizer + + + + mo_icon.icomo_icon.ico + + + false + + + + + 0 + 0 + + + + Qt::CustomContextMenu + + + + + + Qt::Horizontal + + + false + + + + + 500 + 16777215 + + + + Filters + + + + 0 + + + 3 + + + 15 + + + 3 + + + 0 + + + + + + 120 + 0 + + + + Qt::NoContextMenu + + + true + + + QAbstractItemView::NoSelection + + + 0 + + + false + + + true + + + true + + + false + + + + + + + State of the filter, can either be Disabled, Enabled or Inverted. + + + + + Filter + + + Filter to apply to the modlist. + + + + + + + + + 0 + + + 2 + + + 0 + + + 0 + + + + + Clear + + + + + + + Edit... + + + + + + + + + + + 0 + 0 + + + + + 1 + + + 2 + + + 1 + + + 2 + + + + + Display mods that match all selected categories. + + + And + + + true + + + + + + + Display mods that match at least one of the selected categories + + + Or + + + + + + + + 0 + 0 + + + + + Filter: only show the separators that match the current filters + Show: always show separators + Hide: never show separators + + + + + + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + + + 2 + + + + + + + + 0 + 0 + + + + Profile + + + profileBox + + + + + + + Pick a module collection + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + <html><head><meta name="qrichtext" content="1" /><style type="text/css"> + p, li { white-space: pre-wrap; } + </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Create profiles here. Each profile contains its own list of active mods and esps. This way you can quickly switch between setups for different playthroughs.</span></p> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Please note that right now your esp load order is not kept separate for different profiles.</span></p></body></html> + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 16777215 + 16777215 + + + + Open list options... + + + Refresh list. This is usually not necessary unless you modified data outside the program. + + + + + + + :/MO/gui/settings:/MO/gui/settings + + + + 16 + 16 + + + + + + + + Show Open Folders menu... + + + + + + + :/MO/gui/open_folder:/MO/gui/open_folder + + + + + + + Restore Backup... + + + + + + + :/MO/gui/restore:/MO/gui/restore + + + + + + + Create Backup + + + + + + + :/MO/gui/backup:/MO/gui/backup + + + + + + + Active: + + + + + + + + 0 + 26 + + + + This provides statistics about the mod list. The total number of active mod is normally displayed. Other statistics may be accessed with the tooltip of this counter. + + + QFrame::Sunken + + + 5 + + + QLCDNumber::Flat + + + + + + + + + + 330 + 400 + + + + Qt::CustomContextMenu + + + This is a list of installed mods. Use the checkboxes to activate/deactivate mods and drag & drop mods to change their "installation" orders. + + + + + + QAbstractItemView::EditKeyPressed|QAbstractItemView::SelectedClicked + + + true + + + true + + + QAbstractItemView::DragDrop + + + Qt::MoveAction + + + true + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + 20 + + + true + + + true + + + true + + + false + + + 39 + + + true + + + false + + + + + + + + + + 20 + 16777215 + + + + x + + + + 20 + 20 + + + + true + + + + + + + + 0 + 0 + + + + Filter + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + 0 + 22 + + + + + 95 + 0 + + + + false + + + Qt::RightToLeft + + + border:1px solid #ff0000; + + + Clear all Filters + + + + :/MO/gui/edit_clear:/MO/gui/edit_clear + + + + 12 + 12 + + + + + + + + + 220 + 0 + + + + Qt::ClickFocus + + + + No groups + + + + + Categories + + + + + Nexus IDs + + + + + + + + + 220 + 0 + + + + Filter + + + + + + + + + + + 2 + + + + + + 4 + + + + + + 0 + 0 + + + + + 0 + 40 + + + + + 9 + true + + + + Pick a program to run. + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + <html><head><meta name="qrichtext" content="1" /><style type="text/css"> + p, li { white-space: pre-wrap; } + </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Choose the program to run. Once you start using ModOrganizer, you should always run your game and tools from here or through shortcuts created here, otherwise mods installed through MO will not be visible.</span></p> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">You can add new Tools to this list, but I can't promise tools I haven't tested will work.</span></p></body></html> + + + + + 32 + 32 + + + + false + + + + + + + + + + 0 + 0 + + + + + 120 + 0 + + + + + 16777215 + 16777215 + + + + + 10 + true + + + + Run program + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + <html><head><meta name="qrichtext" content="1" /><style type="text/css"> + p, li { white-space: pre-wrap; } + </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Run the selected program with ModOrganizer enabled.</span></p></body></html> + + + + + + + Run + + + + :/MO/gui/run:/MO/gui/run + + + + 36 + 36 + + + + + + + + + 0 + 0 + + + + + 140 + 0 + + + + + 16777215 + 16777215 + + + + + 0 + 0 + + + + Create a shortcut in your start menu or on the desktop to the specified program + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + <html><head><meta name="qrichtext" content="1" /><style type="text/css"> + p, li { white-space: pre-wrap; } + </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">This creates a start menu shortcut that directly starts the selected program with the MO active.</span></p></body></html> + + + + Shortcut + + + + :/MO/gui/link:/MO/gui/link + + + + + + + + + + + + + 340 + 250 + + + + + 16777215 + 16777215 + + + + Qt::NoContextMenu + + + QTabWidget::Rounded + + + 0 + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + Plugins + + + + 2 + + + 6 + + + 0 + + + + + + + Sort the plugins using LOOT. + + + Sort the plugins using LOOT. + + + Sort + + + + :/MO/gui/sort:/MO/gui/sort + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Restore a backup. + + + Restore a backup. + + + + + + + :/MO/gui/restore:/MO/gui/restore + + + + 16 + 16 + + + + + + + + Create a backup. + + + Create a backup. + + + + + + + :/MO/gui/backup:/MO/gui/backup + + + + + + + Active: + + + + + + + + 0 + 26 + + + + <html><head/><body><p>This provides statistics about the plugin list. The total number of active plugins is normally displayed. Other statistics may be accessed with the tooltip of this counter.</p></body></html> + + + QFrame::Sunken + + + 4 + + + QLCDNumber::Flat + + + + + + + + + + 250 + 250 + + + + Qt::CustomContextMenu + + + List of available esp/esm files. + + + List of available esp/esm files. + + + QAbstractItemView::EditKeyPressed|QAbstractItemView::SelectedClicked + + + true + + + false + + + QAbstractItemView::InternalMove + + + Qt::MoveAction + + + true + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + 0 + + + true + + + false + + + true + + + false + + + false + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Filter the list of plugins. + + + Filter the list of plugins. + + + + + + Filter + + + + + + + + + + false + + + Archives + + + + 2 + + + 6 + + + 0 + + + + + + + <html><head/><body><p>BSAs / BA2s are bundles of game assets (textures, scripts, etc.). By default, the engine loads these bundles in a separate step from loose files. <p>Their load order is specified by the priority of the corresponding plugin (right pane, plugins tab).</p><p>If there is a matching plugin, the game will load them no matter what.</p></body></html> + + + <html><head/><body><p>Currently detected archives. (<a href="#">What is an archive?</a>)</p></body></html> + + + true + + + + + + + + + Qt::CustomContextMenu + + + List of available BS Archives. Archives not checked here are not managed by MO and ignore installation order. + + + + BSA files are archives (comparable to .zip files) that contain data assets (meshes, textures, ...) to be used by the game. As such they "compete" with loose files in your data directory over which is loaded. + By default, BSAs that share their base name with an enabled ESP (i.e. plugin.esp and plugin.bsa) are automatically loaded and will have precedence over all loose files, the installation order you set up to the left is then ignored! + + BSAs checked here are loaded in such a way that your installation order is obeyed properly. + + + + false + + + false + + + false + + + 20 + + + true + + + 1 + + + + + + + + Data + + + + 2 + + + 6 + + + 0 + + + + + + + Refresh the data structure. + + + Refresh the data structure. + + + Refresh + + + + :/MO/gui/resources/view-refresh.png:/MO/gui/resources/view-refresh.png + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Qt::CustomContextMenu + + + This is an overview of your data directory as visible to the game (and tools). + + + true + + + QAbstractItemView::ExtendedSelection + + + true + + + true + + + + + + + + + + + Filter the list so that only conflicts are displayed. + + + Filter the list so that only conflicts are displayed. + + + Conflicts only + + + + + + + Filter the list so that files from archives are shown. + + + + + + Filter the list so that files from archives are shown. + + + Archives + + + + + + + Filter the list so that hidden files are shown. + + + + + + Filter the list so that hidden files are shown. + + + Hidden files + + + true + + + + + + + Filter the Data tree. + + + Filter the Data tree. + + + + + + + + + + Saves + + + + 2 + + + 6 + + + 0 + + + + + Qt::CustomContextMenu + + + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + <html><head><meta name="qrichtext" content="1" /><style type="text/css"> + p, li { white-space: pre-wrap; } + </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">This is a list of all save games for this game. Hover over a list entry to get detailed information about the save including a list of esps/esms that were used at the time this save was created but aren't active now.</span></p> + <p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8pt;"></p> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">If you click &quot;Fix Mods...&quot; in the context menu, MO will try to activate all mods and esps to fix those missing esps. It will not disable anything!</span></p></body></html> + + + + QAbstractScrollArea::AdjustIgnored + + + true + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + 0 + + + false + + + true + + + false + + + 2 + + + false + + + true + + + + Name + + + AlignLeading|AlignVCenter + + + + + File + + + AlignLeading|AlignVCenter + + + + + + + + + Downloads + + + + 2 + + + 6 + + + 0 + + + + + + + Refresh the downloads. + + + Refresh the downloads. + + + Refresh + + + + :/MO/gui/resources/view-refresh.png:/MO/gui/resources/view-refresh.png + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + Qt::ContextMenuPolicy::DefaultContextMenu + + + Query Metadata + + + + :/MO/gui/resources/system-search.png:/MO/gui/resources/system-search.png + + + + + + + + + + + + 320 + 0 + + + + Qt::CustomContextMenu + + + true + + + + + + This is a list of mods you downloaded from Nexus. Double click one to install it. You can also drag an archive into here. + + + true + + + QAbstractItemView::DragDrop + + + Qt::MoveAction + + + true + + + QAbstractItemView::ScrollPerPixel + + + 0 + + + true + + + false + + + true + + + + + + + + + + + Show downloads marked as hidden. + + + Show downloads marked as hidden. + + + Hidden files + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Filter the list of downloads. + + + Filter the list of downloads. + + + Filter + + + + + + + + + + + + + + + + + + + Qt::CustomContextMenu + + + Main ToolBar + + + false + + + + 42 + 36 + + + + TopToolBarArea + + + false + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1009 + 22 + + + + + &File + + + + + + + + + + &Tools + + + + + + + + + + + &Help + + + + + + + + + &View + + + + &Toolbars + + + + + + + + + + + + + + + + + + + + + + &Run + + + + + + + + + + + + 82 + 44 + + + + Log + + + 8 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 30 + 20 + + + + Qt::CustomContextMenu + + + true + + + QAbstractItemView::ExtendedSelection + + + false + + + true + + + false + + + + + + + + + + :/MO/gui/resources/system-installer.png:/MO/gui/resources/system-installer.png + + + Install &Mod... + + + Install &Mod + + + Install a new mod from an archive + + + Install a new mod from an archive + + + Ctrl+M + + + + + + :/MO/gui/profiles:/MO/gui/profiles + + + &Profiles... + + + &Profiles + + + Configure profiles + + + Configure profiles + + + Ctrl+P + + + + + + :/MO/gui/icon_executable:/MO/gui/icon_executable + + + &Executables... + + + &Executables + + + Configure the executables that can be started through Mod Organizer + + + Configure the executables that can be started through Mod Organizer + + + Ctrl+E + + + + + + :/MO/gui/plugins:/MO/gui/plugins + + + &Tool Plugins + + + &Tools + + + Tools + + + Ctrl+I + + + + + + :/MO/gui/settings:/MO/gui/settings + + + &Settings... + + + &Settings + + + Configure settings and workarounds + + + Configure settings and workarounds + + + Ctrl+S + + + + + + :/MO/gui/resources/internet-web-browser.png:/MO/gui/resources/internet-web-browser.png + + + Visit &Nexus + + + Visit &Nexus + + + Visit the Nexus website in your browser for more mods + + + Visit the Nexus website in your browser for more mods + + + Ctrl+N + + + + + + :/MO/gui/resources/internet-web-browser.png:/MO/gui/resources/internet-web-browser.png + + + Browse Mod Page + + + Browse Mod Page + + + Browse Mod Page + + + Browse Mod Page + + + false + + + + + false + + + + :/MO/gui/update:/MO/gui/update + + + &Update Mod Organizer + + + &Update Mod Organizer + + + Mod Organizer is up-to-date + + + Mod Organizer is up-to-date + + + + + + :/MO/gui/warning:/MO/gui/warning + + + &Notifications... + + + Open the notifications dialog + + + Open the notifications dialog + + + This button will be highlighted on the toolbar if MO discovered potential problems in your setup and provide tips on how to fix them. + + + + + + :/MO/gui/help:/MO/gui/help + + + &Help + + + &Help + + + Show help options + + + Show help options + + + Ctrl+H + + + + + + :/MO/gui/icon_favorite:/MO/gui/icon_favorite + + + &Endorse ModOrganizer + + + &Endorse ModOrganizer + + + Endorse Mod Organizer + + + Endorse Mod Organizer + + + + + + :/MO/gui/instance_switch:/MO/gui/instance_switch + + + Manage Instan&ces... + + + Manage Instan&ces... + + + Open the instance manager window to manage a different instance + + + Open the instance manager window to manage a different instance + + + + + E&xit + + + E&xit + + + Exits Mod Organizer + + + Exits Mod Organizer + + + + + true + + + M&ain Toolbar + + + + + true + + + &Small Icons + + + + + true + + + Lar&ge Icons + + + + + true + + + &Icons Only + + + + + true + + + &Text Only + + + + + true + + + I&cons and Text + + + + + true + + + M&edium Icons + + + + + true + + + &Menu + + + + + true + + + Status &bar + + + + + true + + + Log + + + + + + :/MO/gui/resources/view-refresh.png:/MO/gui/resources/view-refresh.png + + + &Refresh + + + F5 + + + + + + + LinkLabel + QLabel +
    linklabel.h
    +
    + + MOBase::LineEditClear + QLineEdit +
    lineeditclear.h
    +
    + + ModListView + QTreeView +
    modlistview.h
    +
    + + PluginListView + QTreeView +
    pluginlistview.h
    +
    + + LCDNumber + QLCDNumber +
    lcdnumber.h
    +
    + + MOBase::SortableTreeWidget + QWidget +
    sortabletreewidget.h
    +
    + + LogList + QTreeView +
    loglist.h
    +
    + + StatusBar + QStatusBar +
    statusbar.h
    +
    + + DownloadListView + QTreeView +
    downloadlistview.h
    +
    +
    + + + + +
    diff --git a/src/src/messagedialog.cpp b/src/src/messagedialog.cpp new file mode 100644 index 0000000..1ff4f9d --- /dev/null +++ b/src/src/messagedialog.cpp @@ -0,0 +1,112 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "messagedialog.h" +#include "ui_messagedialog.h" +#include +#include +#ifdef _WIN32 +#include +#endif +#include + +using namespace MOBase; + +MessageDialog::MessageDialog(const QString& text, QWidget* reference) + : QDialog(reference), ui(new Ui::MessageDialog) +{ + ui->setupUi(this); + + // very crude way to ensure no single word in the test is wider than the message + // window. ellide in the center if necessary + QFontMetrics metrics(ui->message->font()); + QString restrictedText; + QStringList lines = text.split("\n"); + foreach (const QString& line, lines) { + QString newLine; + QStringList words = line.split(" "); + foreach (const QString& word, words) { + if (word.length() > 10) { + newLine += + "" + + metrics.elidedText(word, Qt::ElideMiddle, ui->message->maximumWidth()) + + ""; + } else { + newLine += word; + } + newLine += " "; + } + restrictedText += newLine + "\n"; + } + + ui->message->setText(restrictedText); + this->setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); + this->setFocusPolicy(Qt::NoFocus); + this->setAttribute(Qt::WA_ShowWithoutActivating); + QTimer::singleShot(1000 + (text.length() * 40), this, SLOT(hide())); + if (reference != nullptr) { + QPoint position = + reference->mapToGlobal(QPoint(reference->width() / 2, reference->height())); + position.rx() -= this->width() / 2; + position.ry() -= this->height() + 5; + move(position); + } +} + +MessageDialog::~MessageDialog() +{ + delete ui; +} + +void MessageDialog::resizeEvent(QResizeEvent* event) +{ + QWidget* par = parentWidget(); + if (par != nullptr) { + QPoint position = par->mapToGlobal(QPoint(par->width() / 2, par->height())); + position.rx() -= event->size().width() / 2; + position.ry() -= event->size().height() + 5; + move(position); + } +} + +void MessageDialog::showMessage(const QString& text, QWidget* reference, + bool bringToFront) +{ + log::debug("{}", text); + + if (!reference) { + for (QWidget* w : qApp->topLevelWidgets()) { + if (dynamic_cast(w)) { + reference = w; + break; + } + } + } + + if (!reference) { + return; + } + + MessageDialog* dialog = new MessageDialog(text, reference); + dialog->show(); + + if (bringToFront) { + reference->activateWindow(); + } +} diff --git a/src/src/messagedialog.h b/src/src/messagedialog.h new file mode 100644 index 0000000..7c102f8 --- /dev/null +++ b/src/src/messagedialog.h @@ -0,0 +1,73 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef MESSAGEDIALOG_H +#define MESSAGEDIALOG_H + +#include + +namespace Ui +{ +class MessageDialog; +} + +/** + * borderless dialog used to display short messages that will automatically + * vanish after a moment + **/ +class MessageDialog : public QDialog +{ + Q_OBJECT + +public: + /** + * @brief constructor + * + * @param text the message to display + * @param reference parent widget. This will also be used to position the message at + *the bottom center of the dialog + **/ + + explicit MessageDialog(const QString& text, QWidget* reference); + + ~MessageDialog(); + + /** + * factory function for message dialogs. This can be used as a fire-and-forget. The + *message will automatically positioned to the reference dialog and get a reasonable + *view time + * + * @param text the text to display. The length of this text is used to determine how + *long the dialog is to be shown + * @param reference the reference widget on top of which the message should be + *displayed + * @param true if the message should bring MO to front to ensure this message is + *visible + **/ + static void showMessage(const QString& text, QWidget* reference, + bool bringToFront = true); + +protected: + virtual void resizeEvent(QResizeEvent* event); + +private: + Ui::MessageDialog* ui; +}; + +#endif // MESSAGEDIALOG_H diff --git a/src/src/messagedialog.ui b/src/src/messagedialog.ui new file mode 100644 index 0000000..86879ab --- /dev/null +++ b/src/src/messagedialog.ui @@ -0,0 +1,94 @@ + + + MessageDialog + + + + 0 + 0 + 106 + 35 + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + + 0 + 0 + + + + Placeholder + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 300 + 16777215 + + + + + 0 + 0 + + + + QFrame::Box + + + Placeholder + + + Qt::RichText + + + Qt::AlignCenter + + + true + + + 2 + + + Qt::NoTextInteraction + + + + + + + + diff --git a/src/src/mo_icon.ico b/src/src/mo_icon.ico new file mode 100644 index 0000000..ba54d4d Binary files /dev/null and b/src/src/mo_icon.ico differ diff --git a/src/src/moapplication.cpp b/src/src/moapplication.cpp new file mode 100644 index 0000000..68f6510 --- /dev/null +++ b/src/src/moapplication.cpp @@ -0,0 +1,868 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "moapplication.h" +#include "commandline.h" +#include "instancemanager.h" +#include "loglist.h" +#include "mainwindow.h" +#include "messagedialog.h" +#include "multiprocess.h" +#include "nexusinterface.h" +#include "nxmaccessmanager.h" +#include "organizercore.h" +#include "sanitychecks.h" +#include "settings.h" +#ifndef _WIN32 +#include "fuseconnector.h" +#include +#endif +#include +#include "shared/appconfig.h" +#include "shared/util.h" +#include "thread_utils.h" +#include "tutorialmanager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +// see addDllsToPath() below +#pragma comment(linker, "/manifestDependency:\"" \ + "name='dlls' " \ + "processorArchitecture='x86' " \ + "version='1.0.0.0' " \ + "type='win32' \"") +#endif + +using namespace MOBase; +using namespace MOShared; + +// style proxy that changes the appearance of drop indicators +// +class ProxyStyle : public QProxyStyle +{ +public: + ProxyStyle(QStyle* baseStyle = 0) : QProxyStyle(baseStyle) {} + + void drawPrimitive(PrimitiveElement element, const QStyleOption* option, + QPainter* painter, const QWidget* widget) const override + { + if (element == QStyle::PE_IndicatorItemViewItemDrop) { + + // 0. Fix a bug that made the drop indicator sometimes appear on top + // of the mod list when selecting a mod. + if (option->rect.height() == 0 && option->rect.bottomRight() == QPoint(-1, -1)) { + return; + } + + // 1. full-width drop indicator + QRect rect(option->rect); + if (auto* view = qobject_cast(widget)) { + rect.setLeft(view->indentation()); + rect.setRight(widget->width()); + } + + // 2. stylish drop indicator + painter->setRenderHint(QPainter::Antialiasing, true); + + QColor col(option->palette.windowText().color()); + QPen pen(col); + pen.setWidth(2); + col.setAlpha(50); + + painter->setPen(pen); + painter->setBrush(QBrush(col)); + if (rect.height() == 0) { + QPoint tri[3] = {rect.topLeft(), rect.topLeft() + QPoint(-5, 5), + rect.topLeft() + QPoint(-5, -5)}; + painter->drawPolygon(tri, 3); + painter->drawLine(rect.topLeft(), rect.topRight()); + } else { + painter->drawRoundedRect(rect, 5, 5); + } + } else { + QProxyStyle::drawPrimitive(element, option, painter, widget); + } + } +}; + +// This adds the `dlls` directory to the path so the dlls can be found. How +// MO is able to find dlls in there is a bit convoluted: +// +// Dependencies on DLLs can be baked into an executable by passing a +// `manifestdependency` option to the linker. This can be done on the command +// line or with a pragma. Typically, the dependency will not be a hardcoded +// filename, but an assembly name, such as Microsoft.Windows.Common-Controls. +// +// When Windows loads the exe, it will look for this assembly in a variety of +// places, such as in the WinSxS folder, but also in the program's folder. It +// will look for `assemblyname.dll` or `assemblyname/assemblyname.dll` and try +// to load that. +// +// If these files don't exist, then the loader gets creative and looks for +// `assemblyname.manifest` and `assemblyname/assemblyname.manifest`. A manifest +// file is just an XML file that can contain a list of DLLs to load for this +// assembly. +// +// In MO's case, there's a `pragma` at the beginning of this file which adds +// `dlls` as an "assembly" dependency. This is a bit of a hack to just force +// the loader to eventually find `dlls/dlls.manifest`, which contains the list +// of all the DLLs MO requires to load. +// +// This file was handwritten in `modorganizer/src/dlls.manifest.qt5` and +// is copied and renamed in CMakeLists.txt into `bin/dlls/dlls.manifest`. Note +// that the useless and incorrect .qt5 extension is removed. +// +void addDllsToPath() +{ + const auto dllsPath = + QDir::toNativeSeparators(QCoreApplication::applicationDirPath() + "/dlls"); + + QCoreApplication::setLibraryPaths(QStringList(dllsPath) + + QCoreApplication::libraryPaths()); + + env::prependToPath(dllsPath); +} + +#ifdef MO2_WEBENGINE +void configureQtWebEngineProcessPath() +{ + const QString appDir = QCoreApplication::applicationDirPath(); + + if (qEnvironmentVariableIsSet("QTWEBENGINEPROCESS_PATH")) { + // keep user override + } else { + const QString candidates[] = { + appDir + "/QtWebEngineProcess", + appDir + "/../libexec/QtWebEngineProcess", + appDir + "/../lib/QtWebEngineProcess", + "/usr/lib/qt6/QtWebEngineProcess", + "/usr/lib/qt6/libexec/QtWebEngineProcess", + "/usr/lib64/qt6/QtWebEngineProcess", + "/usr/lib64/qt6/libexec/QtWebEngineProcess", + }; + + for (const auto& candidate : candidates) { + if (QFileInfo::exists(candidate)) { + qputenv("QTWEBENGINEPROCESS_PATH", candidate.toUtf8()); + break; + } + } + + if (!qEnvironmentVariableIsSet("QTWEBENGINEPROCESS_PATH")) { + const QString fromPath = QStandardPaths::findExecutable("QtWebEngineProcess"); + if (!fromPath.isEmpty()) { + qputenv("QTWEBENGINEPROCESS_PATH", fromPath.toUtf8()); + } + } + } + + if (!qEnvironmentVariableIsSet("QTWEBENGINE_RESOURCES_PATH")) { + const QString resourceDirs[] = { + appDir + "/resources", + appDir + "/../resources", + "/usr/share/qt6/resources", + "/usr/lib/qt6/resources", + "/usr/lib64/qt6/resources", + }; + for (const auto& dir : resourceDirs) { + if (QFileInfo::exists(dir + "/qtwebengine_resources.pak")) { + qputenv("QTWEBENGINE_RESOURCES_PATH", dir.toUtf8()); + break; + } + } + } + + if (!qEnvironmentVariableIsSet("QTWEBENGINE_LOCALES_PATH")) { + const QString localeDirs[] = { + appDir + "/translations/qtwebengine_locales", + appDir + "/../translations/qtwebengine_locales", + "/usr/share/qt6/translations/qtwebengine_locales", + "/usr/lib/qt6/translations/qtwebengine_locales", + "/usr/lib64/qt6/translations/qtwebengine_locales", + }; + for (const auto& dir : localeDirs) { + if (QFileInfo::exists(dir)) { + qputenv("QTWEBENGINE_LOCALES_PATH", dir.toUtf8()); + break; + } + } + } +} +#endif + +MOApplication::MOApplication(int& argc, char** argv) : QApplication(argc, argv) +{ + TimeThis tt("MOApplication()"); + + // Ensure the app name is always "ModOrganizer" regardless of the binary + // filename (e.g. "ModOrganizer.bin" inside an AppImage). + setApplicationName("ModOrganizer"); + + qputenv("QML_DISABLE_DISK_CACHE", "true"); + + connect(&m_styleWatcher, &QFileSystemWatcher::fileChanged, [&](auto&& file) { + log::debug("style file '{}' changed, reloading", file); + updateStyle(file); + }); + + m_defaultStyle = "windowsvista"; +#ifndef _WIN32 + const auto availableStyles = QStyleFactory::keys(); + if (availableStyles.contains("Fusion")) { + m_defaultStyle = "Fusion"; + } else if (!availableStyles.isEmpty()) { + m_defaultStyle = availableStyles.first(); + } +#endif + // Start with "None" style setting and only apply custom styles from settings + // later during setup(). + setStyleFile(""); + addDllsToPath(); +#ifdef MO2_WEBENGINE + configureQtWebEngineProcessPath(); +#endif +} + +OrganizerCore& MOApplication::core() +{ + return *m_core; +} + +void MOApplication::firstTimeSetup(MOMultiProcess& multiProcess) +{ + connect( + &multiProcess, &MOMultiProcess::messageSent, this, + [this](auto&& s) { + externalMessage(s); + }, + Qt::QueuedConnection); +} + +int MOApplication::setup(MOMultiProcess& multiProcess, bool forceSelect) +{ + TimeThis tt("MOApplication setup()"); + + // makes plugin data path available to plugins, see + // IOrganizer::getPluginDataPath() + MOBase::details::setPluginDataPath(OrganizerCore::pluginDataPath()); + + // figuring out the current instance + m_instance = getCurrentInstance(forceSelect); + if (!m_instance) { + return 1; + } + + // first time the data path is available, set the global property and log + // directory, then log a bunch of debug stuff + const QString dataPath = m_instance->directory(); + setProperty("dataPath", dataPath); + + if (!setLogDirectory(dataPath)) { + reportError(tr("Failed to create log folder.")); + InstanceManager::singleton().clearCurrentInstance(); + return 1; + } + +#ifdef _WIN32 + log::debug("command line: '{}'", QString::fromWCharArray(GetCommandLineW())); +#else + log::debug("command line: '{}'", QCoreApplication::arguments().join(' ')); +#endif + +#ifndef GITID +#define GITID "unknown" +#endif + log::info("starting Mod Organizer version {} revision {} in {}, usvfs: {}", + createVersionInfo().string(), GITID, QCoreApplication::applicationDirPath(), + MOShared::getUsvfsVersionString()); + + if (multiProcess.secondary()) { + log::debug("another instance of MO is running but --multiple was given"); + } + + log::info("data path: {}", m_instance->directory()); + log::info("working directory: {}", QDir::currentPath()); + + tt.start("MOApplication::doOneRun() settings"); + + // deleting old files, only for the main instance + if (!multiProcess.secondary()) { + purgeOldFiles(); + } + + // loading settings + m_settings.reset(new Settings(m_instance->iniPath(), true)); + log::getDefault().setLevel(m_settings->diagnostics().logLevel()); + log::debug("using ini at '{}'", m_settings->filename()); + + OrganizerCore::setGlobalCoreDumpType(m_settings->diagnostics().coreDumpType()); + + tt.start("MOApplication::doOneRun() log and checks"); + + // logging and checking + env::Environment env; + env.dump(*m_settings); + m_settings->dump(); + sanity::checkEnvironment(env); + + m_modules = std::move(env.onModuleLoaded(qApp, [](auto&& m) { + if (m.interesting()) { + log::debug("loaded module {}", m.toString()); + } + + sanity::checkIncompatibleModule(m); + })); + + auto sslBuildVersion = QSslSocket::sslLibraryBuildVersionString(); + auto sslVersion = QSslSocket::sslLibraryVersionString(); + log::debug("SSL Build Version: {}, SSL Runtime Version {}", sslBuildVersion, + sslVersion); + + // nexus interface + tt.start("MOApplication::doOneRun() NexusInterface"); + log::debug("initializing nexus interface"); + m_nexus.reset(new NexusInterface(m_settings.get())); + + // organizer core + tt.start("MOApplication::doOneRun() OrganizerCore"); + log::debug("initializing core"); + + m_core.reset(new OrganizerCore(*m_settings)); + if (!m_core->bootstrap()) { + reportError(tr("Failed to set up data paths.")); + InstanceManager::singleton().clearCurrentInstance(); + return 1; + } + + // plugins + tt.start("MOApplication::doOneRun() plugins"); + log::debug("initializing plugins"); + + m_plugins = std::make_unique(m_core.get()); + m_plugins->loadPlugins(); + log::debug("all plugins loaded"); + + // instance + log::debug("entering setupInstanceLoop..."); + if (auto r = setupInstanceLoop(*m_instance, *m_plugins)) { + log::debug("setupInstanceLoop returned {}", *r); + return *r; + } + log::debug("setupInstanceLoop done"); + + if (m_instance->isPortable()) { + log::debug("this is a portable instance"); + } + + tt.start("MOApplication::doOneRun() OrganizerCore setup"); + + sanity::checkPaths(*m_instance->gamePlugin(), *m_settings); + + // setting up organizer core + m_core->setManagedGame(m_instance->gamePlugin()); + +#ifndef _WIN32 + // Clean up stale FUSE mounts from a previous crash BEFORE any game + // directory access (profile init, BSA invalidation, etc.). + { + const auto dataDir = m_instance->gamePlugin()->dataDirectory().absolutePath(); + log::info("checking for stale FUSE mount on '{}'", dataDir); + FuseConnector::tryCleanupStaleMount(dataDir); + } +#endif + + m_core->createDefaultProfile(); + m_core->createOverwriteDirectories(); + + log::info("using game plugin '{}' ('{}', variant {}, steam id '{}') at {}", + m_instance->gamePlugin()->gameName(), + m_instance->gamePlugin()->gameShortName(), + (m_settings->game().edition().value_or("").isEmpty() + ? "(none)" + : *m_settings->game().edition()), + m_instance->gamePlugin()->steamAPPId(), + m_instance->gamePlugin()->gameDirectory().absolutePath()); + + CategoryFactory::instance().loadCategories(); + m_core->updateExecutablesList(); + m_core->updateModInfoFromDisc(); + m_core->setCurrentProfile(m_instance->profileName()); + + return 0; +} + +int MOApplication::run(MOMultiProcess& multiProcess) +{ + log::debug("MOApplication::run() entered"); + // checking command line + TimeThis tt("MOApplication::run()"); + + // show splash + tt.start("MOApplication::doOneRun() splash"); + + MOSplash splash(*m_settings, m_instance->directory(), m_instance->gamePlugin()); + + tt.start("MOApplication::doOneRun() finishing"); + + // start an api check + QString apiKey; + if (GlobalSettings::nexusApiKey(apiKey)) { + m_nexus->getAccessManager()->apiCheck(apiKey); + } + + // tutorials + log::debug("initializing tutorials"); + TutorialManager::init(qApp->applicationDirPath() + "/" + + QString::fromStdWString(AppConfig::tutorialsPath()) + "/", + m_core.get()); + + // styling + if (!setStyleFile(m_settings->interface().styleName().value_or(""))) { + // disable invalid stylesheet + m_settings->interface().setStyleName(""); + } + + int res = 1; + + { + tt.start("MOApplication::doOneRun() MainWindow setup"); + log::debug("creating MainWindow..."); + MainWindow mainWindow(*m_settings, *m_core, *m_plugins); + log::debug("MainWindow created, showing..."); + + // the nexus interface can show dialogs, make sure they're parented to the + // main window + m_nexus->getAccessManager()->setTopLevelWidget(&mainWindow); + + connect( + &mainWindow, &MainWindow::styleChanged, this, + [this](auto&& file) { + setStyleFile(file); + }, + Qt::QueuedConnection); + + log::debug("displaying main window"); + mainWindow.show(); + mainWindow.activateWindow(); + splash.close(); + + tt.stop(); + + res = exec(); + mainWindow.close(); + + // main window is about to be destroyed + m_nexus->getAccessManager()->setTopLevelWidget(nullptr); + } + + // reset geometry if the flag was set from the settings dialog + m_settings->geometry().resetIfNeeded(); + + return res; +} + +void MOApplication::externalMessage(const QString& message) +{ + log::debug("received external message '{}'", message); + + MOShortcut moshortcut(message); + + if (moshortcut.isValid()) { + if (moshortcut.hasExecutable()) { + try { + m_core->processRunner() + .setFromShortcut(moshortcut) + .setWaitForCompletion(ProcessRunner::TriggerRefresh) + .run(); + } catch (std::exception&) { + // user was already warned + } + } + } else if (isNxmLink(message)) { + if (m_core == nullptr) { + // This can happen if MO2 is started with the --pick option and no instance has + // been selected yet. + reportError(tr("You need to select an instance before trying to download mods.")); + } else { + MessageDialog::showMessage(tr("Download started"), qApp->activeWindow(), false); + m_core->downloadRequestedNXM(message); + } + } else { + cl::CommandLine cl; + + if (auto r = cl.process(message.toStdWString())) { + log::debug("while processing external message, command line wants to " + "exit; ignoring"); + + return; + } + + if (auto i = cl.instance()) { + const auto ci = InstanceManager::singleton().currentInstance(); + + if (*i != ci->displayName()) { + reportError( + tr("This shortcut or command line is for instance '%1', but the current " + "instance is '%2'.") + .arg(*i) + .arg(ci->displayName())); + + return; + } + } + + if (auto p = cl.profile()) { + if (*p != m_core->profileName()) { + reportError( + tr("This shortcut or command line is for profile '%1', but the current " + "profile is '%2'.") + .arg(*p) + .arg(m_core->profileName())); + + return; + } + } + + cl.runPostOrganizer(*m_core); + } +} + +std::unique_ptr MOApplication::getCurrentInstance(bool forceSelect) +{ + auto& m = InstanceManager::singleton(); + auto currentInstance = m.currentInstance(); + + if (forceSelect || !currentInstance) { + // clear any overrides that might have been given on the command line + m.clearOverrides(); + currentInstance = selectInstance(); + } else { + if (!QDir(currentInstance->directory()).exists()) { + // the previously used instance doesn't exist anymore + + // clear any overrides that might have been given on the command line + m.clearOverrides(); + + if (m.hasAnyInstances()) { + reportError(QObject::tr("Instance at '%1' not found. Select another instance.") + .arg(currentInstance->directory())); + } else { + reportError( + QObject::tr("Instance at '%1' not found. You must create a new instance") + .arg(currentInstance->directory())); + } + + currentInstance = selectInstance(); + } + } + + return currentInstance; +} + +std::optional MOApplication::setupInstanceLoop(Instance& currentInstance, + PluginContainer& pc) +{ + for (;;) { + const auto setupResult = setupInstance(currentInstance, pc); + + if (setupResult == SetupInstanceResults::Okay) { + return {}; + } else if (setupResult == SetupInstanceResults::TryAgain) { + continue; + } else if (setupResult == SetupInstanceResults::SelectAnother) { + InstanceManager::singleton().clearCurrentInstance(); + return ReselectExitCode; + } else { + return 1; + } + } +} + +void MOApplication::purgeOldFiles() +{ + // remove the temporary backup directory in case we're restarting after an + // update + QString backupDirectory = qApp->applicationDirPath() + "/update_backup"; + if (QDir(backupDirectory).exists()) { + shellDelete(QStringList(backupDirectory)); + } + + // cycle log file + removeOldFiles(qApp->property("dataPath").toString() + "/" + + QString::fromStdWString(AppConfig::logPath()), + "usvfs*.log", 5, QDir::Name); +} + +void MOApplication::resetForRestart() +{ + LogModel::instance().clear(); + ResetExitFlag(); + + // make sure the log file isn't locked in case MO was restarted and + // the previous instance gets deleted + log::getDefault().setFile({}); + + // clear instance and profile overrides + InstanceManager::singleton().clearOverrides(); + + m_core = {}; + m_plugins = {}; + m_nexus = {}; + m_settings = {}; + m_instance = {}; +} + +bool MOApplication::setStyleFile(const QString& styleName) +{ + // remove all files from watch + QStringList currentWatch = m_styleWatcher.files(); + if (currentWatch.count() != 0) { + m_styleWatcher.removePaths(currentWatch); + } + // set new stylesheet or clear it + if (styleName.length() != 0) { + QString styleSheetName = applicationDirPath() + "/" + + MOBase::ToQString(AppConfig::stylesheetsPath()) + "/" + + styleName; + if (QFile::exists(styleSheetName)) { + m_styleWatcher.addPath(styleSheetName); + updateStyle(styleSheetName); + } else { + updateStyle(styleName); + } + } else { + setStyle(new ProxyStyle(QStyleFactory::create(m_defaultStyle))); + setStyleSheet(""); + } + return true; +} + +bool MOApplication::notify(QObject* receiver, QEvent* event) +{ + try { + return QApplication::notify(receiver, event); + } catch (const std::filesystem::filesystem_error& fe) { + log::error("uncaught filesystem exception in handler (object {}, eventtype {}): {}", + receiver->objectName(), event->type(), fe.what()); + +#ifndef _WIN32 + // ENOTCONN = stale FUSE mount. Attempt recovery so MO2 can continue. + if (fe.code().value() == ENOTCONN) { + const auto& p1 = fe.path1(); + if (!p1.empty()) { + log::warn("ENOTCONN on '{}' — attempting stale mount cleanup", p1.string()); + FuseConnector::tryCleanupStaleMount(QString::fromStdString(p1.string())); + } + const auto& p2 = fe.path2(); + if (!p2.empty()) { + log::warn("ENOTCONN on '{}' — attempting stale mount cleanup", p2.string()); + FuseConnector::tryCleanupStaleMount(QString::fromStdString(p2.string())); + } + } +#endif + + reportError(tr("an error occurred: %1").arg(fe.what())); + return false; + } catch (const std::exception& e) { + log::error("uncaught exception in handler (object {}, eventtype {}): {}", + receiver->objectName(), event->type(), e.what()); + reportError(tr("an error occurred: %1").arg(e.what())); + return false; + } catch (...) { + log::error("uncaught non-std exception in handler (object {}, eventtype {})", + receiver->objectName(), event->type()); + reportError(tr("an error occurred")); + return false; + } +} + +namespace +{ +QStringList extractTopStyleSheetComments(QFile& stylesheet) +{ + if (!stylesheet.open(QFile::ReadOnly)) { + log::error("failed to open stylesheet file {}", stylesheet.fileName()); + return {}; + } + ON_BLOCK_EXIT([&stylesheet]() { + stylesheet.close(); + }); + + QStringList topComments; + + while (true) { + const auto byteLine = stylesheet.readLine(); + if (byteLine.isNull()) { + break; + } + + const auto line = QString(byteLine).trimmed(); + + // skip empty lines + if (line.isEmpty()) { + continue; + } + + // only handle single line comments + if (!line.startsWith("/*")) { + break; + } + + topComments.push_back(line.mid(2, line.size() - 4).trimmed()); + } + + return topComments; +} + +QString extractBaseStyleFromStyleSheet(QFile& stylesheet, const QString& defaultStyle) +{ + // read the first line of the files that are either empty or comments + // + const auto topLines = extractTopStyleSheetComments(stylesheet); + + const auto factoryStyles = QStyleFactory::keys(); + + QString style = defaultStyle; + + for (const auto& line : topLines) { + if (!line.startsWith("mo2-base-style")) { + continue; + } + + const auto parts = line.split(":"); + if (parts.size() != 2) { + log::warn("found invalid top-comment for mo2 in {}: {}", stylesheet.fileName(), + line); + continue; + } + + const auto tmpStyle = parts[1].trimmed(); + const auto index = factoryStyles.indexOf(tmpStyle, 0, Qt::CaseInsensitive); + if (index == -1) { + log::warn("base style '{}' from style '{}' not found", tmpStyle, + stylesheet.fileName(), line); + continue; + } + + style = factoryStyles[index]; + log::info("found base style '{}' for style '{}'", style, stylesheet.fileName()); + break; + } + + return style; +} + +} // namespace + +void MOApplication::updateStyle(const QString& fileName) +{ + if (QStyleFactory::keys().contains(fileName)) { + setStyleSheet(""); + setStyle(new ProxyStyle(QStyleFactory::create(fileName))); + } else { + QFile stylesheet(fileName); + if (stylesheet.exists()) { + setStyle(new ProxyStyle(QStyleFactory::create( + extractBaseStyleFromStyleSheet(stylesheet, m_defaultStyle)))); + setStyleSheet(QString("file:///%1").arg(fileName)); + } else { + log::warn("invalid stylesheet: {}", fileName); + } + } +} + +MOSplash::MOSplash(const Settings& settings, const QString& dataPath, + const MOBase::IPluginGame* game) +{ + const auto splashPath = getSplashPath(settings, dataPath, game); + if (splashPath.isEmpty()) { + return; + } + + QPixmap image(splashPath); + if (image.isNull()) { + log::error("failed to load splash from {}", splashPath); + return; + } + + ss_.reset(new QSplashScreen(image)); + settings.geometry().centerOnMainWindowMonitor(ss_.get()); + + ss_->show(); + ss_->activateWindow(); +} + +void MOSplash::close() +{ + if (ss_) { + // don't pass mainwindow as it just waits half a second for it + // instead of proceding + ss_->finish(nullptr); + } +} + +QString MOSplash::getSplashPath(const Settings& settings, const QString& dataPath, + const MOBase::IPluginGame* game) const +{ + if (!settings.useSplash()) { + return {}; + } + + // try splash from instance directory + const QString splashPath = dataPath + "/splash.png"; + if (QFile::exists(dataPath + "/splash.png")) { + QImage image(splashPath); + if (!image.isNull()) { + return splashPath; + } + } + + // try splash from plugin + QString pluginSplash = QString(":/%1/splash").arg(game->gameShortName()); + if (QFile::exists(pluginSplash)) { + QImage image(pluginSplash); + if (!image.isNull()) { + image.save(splashPath); + return pluginSplash; + } + } + + // try default splash from resource + QString defaultSplash = ":/MO/gui/splash"; + if (QFile::exists(defaultSplash)) { + QImage image(defaultSplash); + if (!image.isNull()) { + return defaultSplash; + } + } + + return splashPath; +} diff --git a/src/src/moapplication.h b/src/src/moapplication.h new file mode 100644 index 0000000..ebe13d8 --- /dev/null +++ b/src/src/moapplication.h @@ -0,0 +1,111 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef MOAPPLICATION_H +#define MOAPPLICATION_H + +#include "env.h" +#include +#include + +class Settings; +class MOMultiProcess; +class Instance; +class PluginContainer; +class OrganizerCore; +class NexusInterface; + +namespace MOBase +{ +class IPluginGame; +} + +class MOApplication : public QApplication +{ + Q_OBJECT + +public: + MOApplication(int& argc, char** argv); + + // called from main() only once for stuff that persists across "restarts" + // + void firstTimeSetup(MOMultiProcess& multiProcess); + + // called from main() each time MO "restarts", loads settings, plugins, + // OrganizerCore and the current instance + // + int setup(MOMultiProcess& multiProcess, bool forceSelect); + + // shows splash, starts an api check, shows the main window and blocks until + // MO exits + // + int run(MOMultiProcess& multiProcess); + + // called from main() when MO "restarts", must clean up everything so setup() + // starts fresh + // + void resetForRestart(); + + // undefined if setup() wasn't called + // + OrganizerCore& core(); + + // wraps QApplication::notify() in a catch, reports errors and ignores them + // + bool notify(QObject* receiver, QEvent* event) override; + +public slots: + bool setStyleFile(const QString& style); + +private slots: + void updateStyle(const QString& fileName); + +private: + QFileSystemWatcher m_styleWatcher; + QString m_defaultStyle; + std::unique_ptr m_modules; + + std::unique_ptr m_instance; + std::unique_ptr m_settings; + std::unique_ptr m_nexus; + std::unique_ptr m_plugins; + std::unique_ptr m_core; + + void externalMessage(const QString& message); + std::unique_ptr getCurrentInstance(bool forceSelect); + std::optional setupInstanceLoop(Instance& currentInstance, PluginContainer& pc); + void purgeOldFiles(); +}; + +class MOSplash +{ +public: + MOSplash(const Settings& settings, const QString& dataPath, + const MOBase::IPluginGame* game); + + void close(); + +private: + std::unique_ptr ss_; + + QString getSplashPath(const Settings& settings, const QString& dataPath, + const MOBase::IPluginGame* game) const; +}; + +#endif // MOAPPLICATION_H diff --git a/src/src/modconflicticondelegate.cpp b/src/src/modconflicticondelegate.cpp new file mode 100644 index 0000000..1aaa06b --- /dev/null +++ b/src/src/modconflicticondelegate.cpp @@ -0,0 +1,148 @@ +#include "modconflicticondelegate.h" +#include "modlist.h" +#include "modlistview.h" +#include +#include + +using namespace MOBase; + +ModConflictIconDelegate::ModConflictIconDelegate(ModListView* view, int logicalIndex, + int compactSize) + : IconDelegate(view, logicalIndex, compactSize), m_view(view) +{} + +QList +ModConflictIconDelegate::getIconsForFlags(std::vector flags, + bool compact) +{ + QList result; + + // Don't do flags for overwrite + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_OVERWRITE_CONFLICT) != + flags.end()) + return result; + + // insert conflict icons to provide nicer alignment + { // insert loose file conflicts first + auto iter = std::find_first_of(flags.begin(), flags.end(), s_ConflictFlags.begin(), + s_ConflictFlags.end()); + if (iter != flags.end()) { + result.append(getFlagIcon(*iter)); + flags.erase(iter); + } else if (!compact) { + result.append(QString()); + } + } + + { // insert loose vs archive overwrite second + auto iter = std::find(flags.begin(), flags.end(), + ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE); + if (iter != flags.end()) { + result.append(getFlagIcon(*iter)); + flags.erase(iter); + } else if (!compact) { + result.append(QString()); + } + } + + { // insert loose vs archive overwritten third + auto iter = std::find(flags.begin(), flags.end(), + ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN); + if (iter != flags.end()) { + result.append(getFlagIcon(*iter)); + flags.erase(iter); + } else if (!compact) { + result.append(QString()); + } + } + + { // insert archive conflicts last + auto iter = + std::find_first_of(flags.begin(), flags.end(), s_ArchiveConflictFlags.begin(), + s_ArchiveConflictFlags.end()); + if (iter != flags.end()) { + result.append(getFlagIcon(*iter)); + flags.erase(iter); + } else if (!compact) { + result.append(QString()); + } + } + + for (auto iter = flags.begin(); iter != flags.end(); ++iter) { + auto iconPath = getFlagIcon(*iter); + if (!iconPath.isEmpty()) + result.append(iconPath); + } + + return result; +} + +QList ModConflictIconDelegate::getIcons(const QModelIndex& index) const +{ + QVariant modIndex = index.data(ModList::IndexRole); + + if (!modIndex.isValid()) { + return {}; + } + + bool compact; + auto flags = m_view->conflictFlags(index, &compact); + return getIconsForFlags(flags, compact || this->compact()); +} + +QString ModConflictIconDelegate::getFlagIcon(ModInfo::EConflictFlag flag) +{ + switch (flag) { + case ModInfo::FLAG_CONFLICT_MIXED: + return QStringLiteral(":/MO/gui/emblem_conflict_mixed"); + case ModInfo::FLAG_CONFLICT_OVERWRITE: + return QStringLiteral(":/MO/gui/emblem_conflict_overwrite"); + case ModInfo::FLAG_CONFLICT_OVERWRITTEN: + return QStringLiteral(":/MO/gui/emblem_conflict_overwritten"); + case ModInfo::FLAG_CONFLICT_REDUNDANT: + return QStringLiteral(":/MO/gui/emblem_conflict_redundant"); + case ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE: + return QStringLiteral(":/MO/gui/archive_loose_conflict_overwrite"); + case ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN: + return QStringLiteral(":/MO/gui/archive_loose_conflict_overwritten"); + case ModInfo::FLAG_ARCHIVE_CONFLICT_MIXED: + return QStringLiteral(":/MO/gui/archive_conflict_mixed"); + case ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITE: + return QStringLiteral(":/MO/gui/archive_conflict_winner"); + case ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITTEN: + return QStringLiteral(":/MO/gui/archive_conflict_loser"); + case ModInfo::FLAG_OVERWRITE_CONFLICT: + return QString(); + default: + log::warn("ModInfo flag {} has no defined icon", flag); + return QString(); + } +} + +size_t ModConflictIconDelegate::getNumIcons(const QModelIndex& index) const +{ + QVariant modIndex = index.data(ModList::IndexRole); + + if (!modIndex.isValid()) { + return 0; + } + + return m_view->conflictFlags(index, nullptr).size(); +} + +QSize ModConflictIconDelegate::sizeHint(const QStyleOptionViewItem& option, + const QModelIndex& modelIndex) const +{ + size_t count = getNumIcons(modelIndex); + unsigned int index = modelIndex.data(ModList::IndexRole).toInt(); + QSize result; + if (index < ModInfo::getNumMods()) { + result = QSize(static_cast(count) * 40, 20); + } else { + result = QSize(1, 20); + } + if (option.rect.width() > 0) { + result.setWidth(std::min(option.rect.width(), result.width())); + } + return result; +} diff --git a/src/src/modconflicticondelegate.h b/src/src/modconflicticondelegate.h new file mode 100644 index 0000000..f0b1e1f --- /dev/null +++ b/src/src/modconflicticondelegate.h @@ -0,0 +1,47 @@ +#ifndef MODCONFLICTICONDELEGATE_H +#define MODCONFLICTICONDELEGATE_H + +#include + +#include "icondelegate.h" +#include "modinfo.h" + +class ModListView; + +class ModConflictIconDelegate : public IconDelegate +{ + Q_OBJECT; + +public: + explicit ModConflictIconDelegate(ModListView* view, int logicalIndex = -1, + int compactSize = 80); + QSize sizeHint(const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + +protected: + static QList getIconsForFlags(std::vector flags, + bool compact); + static QString getFlagIcon(ModInfo::EConflictFlag flag); + + QList getIcons(const QModelIndex& index) const override; + size_t getNumIcons(const QModelIndex& index) const override; + + // constructor for color table + // + ModConflictIconDelegate() : ModConflictIconDelegate(nullptr) {} + +private: + static constexpr std::array s_ConflictFlags{ + ModInfo::FLAG_CONFLICT_MIXED, ModInfo::FLAG_CONFLICT_OVERWRITE, + ModInfo::FLAG_CONFLICT_OVERWRITTEN, ModInfo::FLAG_CONFLICT_REDUNDANT}; + static constexpr std::array s_ArchiveLooseConflictFlags{ + ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE, + ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN}; + static constexpr std::array s_ArchiveConflictFlags{ + ModInfo::FLAG_ARCHIVE_CONFLICT_MIXED, ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITE, + ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITTEN}; + + ModListView* m_view; +}; + +#endif // MODCONFLICTICONDELEGATE_H diff --git a/src/src/modcontenticondelegate.cpp b/src/src/modcontenticondelegate.cpp new file mode 100644 index 0000000..f329762 --- /dev/null +++ b/src/src/modcontenticondelegate.cpp @@ -0,0 +1,60 @@ +#include "modcontenticondelegate.h" + +#include "modlistview.h" + +ModContentIconDelegate::ModContentIconDelegate(ModListView* view, int column, + int compactSize) + : IconDelegate(view, column, compactSize), m_view(view) +{} + +QList ModContentIconDelegate::getIcons(const QModelIndex& index) const +{ + QVariant modIndex = index.data(ModList::IndexRole); + + if (!modIndex.isValid()) { + return {}; + } + + bool compact; + auto icons = m_view->contentsIcons(index, &compact); + + if (compact || this->compact()) { + icons.removeAll(QString()); + } + + return icons; +} + +size_t ModContentIconDelegate::getNumIcons(const QModelIndex& index) const +{ + return getIcons(index).size(); +} + +bool ModContentIconDelegate::helpEvent(QHelpEvent* event, QAbstractItemView* view, + const QStyleOptionViewItem& option, + const QModelIndex& index) +{ + if (!event || !view) { + return false; + } + if (event->type() == QEvent::ToolTip) { + // this code is from QAbstractItemDelegate::helpEvent, only the the way + // text is retrieved has been changed + QHelpEvent* he = static_cast(event); + const int precision = inherits("QItemDelegate") + ? 10 + : 6; // keep in sync with DBL_DIG in qitemdelegate.cpp + const QString tooltip = + index.isValid() ? m_view->contentsTooltip(index) : QString(); + QRect rect; + if (index.isValid()) { + const QRect r = view->visualRect(index); + rect = QRect(view->mapToGlobal(r.topLeft()), r.size()); + } + QToolTip::showText(he->globalPos(), tooltip, view, rect); + event->setAccepted(!tooltip.isEmpty()); + return true; + } else { + return IconDelegate::helpEvent(event, view, option, index); + } +} diff --git a/src/src/modcontenticondelegate.h b/src/src/modcontenticondelegate.h new file mode 100644 index 0000000..0e9c481 --- /dev/null +++ b/src/src/modcontenticondelegate.h @@ -0,0 +1,30 @@ +#ifndef MODCONTENTICONDELEGATE_H +#define MODCONTENTICONDELEGATE_H + +#include "icondelegate.h" + +class ModListView; + +class ModContentIconDelegate : public IconDelegate +{ + Q_OBJECT +public: + explicit ModContentIconDelegate(ModListView* view, int column = -1, + int compactSize = 150); + + bool helpEvent(QHelpEvent* event, QAbstractItemView* view, + const QStyleOptionViewItem& option, const QModelIndex& index) override; + +protected: + QList getIcons(const QModelIndex& index) const override; + size_t getNumIcons(const QModelIndex& index) const override; + + // constructor for color table + // + ModContentIconDelegate() : ModContentIconDelegate(nullptr) {} + +private: + ModListView* m_view; +}; + +#endif // GENERICICONDELEGATE_H diff --git a/src/src/modelutils.cpp b/src/src/modelutils.cpp new file mode 100644 index 0000000..fa87eb0 --- /dev/null +++ b/src/src/modelutils.cpp @@ -0,0 +1,95 @@ +#include "modelutils.h" + +#include + +namespace MOShared +{ + +QModelIndexList flatIndex(const QAbstractItemModel* model, int column, + const QModelIndex& parent) +{ + QModelIndexList index; + for (std::size_t i = 0; i < model->rowCount(parent); ++i) { + index.append(model->index(i, column, parent)); + index.append(flatIndex(model, column, index.back())); + } + return index; +} + +static QModelIndexList visibleIndexImpl(QTreeView* view, int column, + const QModelIndex& parent) +{ + if (parent.isValid() && !view->isExpanded(parent)) { + return {}; + } + + auto* model = view->model(); + QModelIndexList index; + for (std::size_t i = 0; i < model->rowCount(parent); ++i) { + index.append(model->index(i, column, parent)); + index.append(visibleIndexImpl(view, column, index.back())); + } + return index; +} + +QModelIndexList visibleIndex(QTreeView* view, int column) +{ + return visibleIndexImpl(view, column, QModelIndex()); +} + +QModelIndex indexModelToView(const QModelIndex& index, const QAbstractItemView* view) +{ + // we need to stack the proxy + std::vector proxies; + { + auto* currentModel = view->model(); + while (auto* proxy = qobject_cast(currentModel)) { + proxies.push_back(proxy); + currentModel = proxy->sourceModel(); + } + } + + if (proxies.empty() || proxies.back()->sourceModel() != index.model()) { + return QModelIndex(); + } + + auto qindex = index; + for (auto rit = proxies.rbegin(); rit != proxies.rend(); ++rit) { + qindex = (*rit)->mapFromSource(qindex); + } + + return qindex; +} + +QModelIndexList indexModelToView(const QModelIndexList& index, + const QAbstractItemView* view) +{ + QModelIndexList result; + for (auto& idx : index) { + result.append(indexModelToView(idx, view)); + } + return result; +} + +QModelIndex indexViewToModel(const QModelIndex& index, const QAbstractItemModel* model) +{ + if (index.model() == model) { + return index; + } else if (auto* proxy = qobject_cast(index.model())) { + return indexViewToModel(proxy->mapToSource(index), model); + } else { + return QModelIndex(); + } +} + +QModelIndexList indexViewToModel(const QModelIndexList& index, + const QAbstractItemModel* model) +{ + QModelIndexList result; + for (auto& idx : index) { + result.append(indexViewToModel(idx, model)); + } + return result; +} + +} // namespace MOShared diff --git a/src/src/modelutils.h b/src/src/modelutils.h new file mode 100644 index 0000000..cb40a17 --- /dev/null +++ b/src/src/modelutils.h @@ -0,0 +1,28 @@ +#ifndef MODELUTILS_H +#define MODELUTILS_H + +#include +#include +#include + +namespace MOShared +{ + +// retrieve all the row index under the given parent +QModelIndexList flatIndex(const QAbstractItemModel* model, int column = 0, + const QModelIndex& parent = QModelIndex()); + +// retrieve all the visible index in the given view +QModelIndexList visibleIndex(QTreeView* view, int column = 0); + +// convert back-and-forth through model proxies +QModelIndex indexModelToView(const QModelIndex& index, const QAbstractItemView* view); +QModelIndexList indexModelToView(const QModelIndexList& index, + const QAbstractItemView* view); +QModelIndex indexViewToModel(const QModelIndex& index, const QAbstractItemModel* model); +QModelIndexList indexViewToModel(const QModelIndexList& index, + const QAbstractItemModel* model); + +} // namespace MOShared + +#endif diff --git a/src/src/modflagicondelegate.cpp b/src/src/modflagicondelegate.cpp new file mode 100644 index 0000000..06601a9 --- /dev/null +++ b/src/src/modflagicondelegate.cpp @@ -0,0 +1,102 @@ +#include "modflagicondelegate.h" +#include "modlist.h" +#include "modlistview.h" +#include +#include + +using namespace MOBase; + +ModFlagIconDelegate::ModFlagIconDelegate(ModListView* view, int column, int compactSize) + : IconDelegate(view, column, compactSize), m_view(view) +{} + +QList ModFlagIconDelegate::getIconsForFlags(std::vector flags, + bool compact) +{ + QList result; + + // Don't do flags for overwrite + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_OVERWRITE) != flags.end()) + return result; + + for (auto iter = flags.begin(); iter != flags.end(); ++iter) { + auto iconPath = getFlagIcon(*iter); + if (!iconPath.isEmpty()) + result.append(iconPath); + } + + return result; +} + +QList ModFlagIconDelegate::getIcons(const QModelIndex& index) const +{ + QVariant modid = index.data(ModList::IndexRole); + + if (modid.isValid()) { + bool compact; + auto flags = m_view->modFlags(index, &compact); + return getIconsForFlags(flags, compact || this->compact()); + } + + return {}; +} + +QString ModFlagIconDelegate::getFlagIcon(ModInfo::EFlag flag) +{ + switch (flag) { + case ModInfo::FLAG_BACKUP: + return QStringLiteral(":/MO/gui/emblem_backup"); + case ModInfo::FLAG_INVALID: + return QStringLiteral(":/MO/gui/problem"); + case ModInfo::FLAG_NOTENDORSED: + return QStringLiteral(":/MO/gui/emblem_notendorsed"); + case ModInfo::FLAG_NOTES: + return QStringLiteral(":/MO/gui/emblem_notes"); + case ModInfo::FLAG_HIDDEN_FILES: + return QStringLiteral(":/MO/gui/emblem_hidden_files"); + case ModInfo::FLAG_ALTERNATE_GAME: + return QStringLiteral(":/MO/gui/alternate_game"); + case ModInfo::FLAG_FOREIGN: + return QString(); + case ModInfo::FLAG_SEPARATOR: + return QString(); + case ModInfo::FLAG_OVERWRITE: + return QString(); + case ModInfo::FLAG_PLUGIN_SELECTED: + return QString(); + case ModInfo::FLAG_TRACKED: + return QStringLiteral(":/MO/gui/tracked"); + default: + log::warn("ModInfo flag {} has no defined icon", flag); + return QString(); + } +} + +size_t ModFlagIconDelegate::getNumIcons(const QModelIndex& index) const +{ + unsigned int modIdx = index.data(ModList::IndexRole).toInt(); + if (modIdx < ModInfo::getNumMods()) { + ModInfo::Ptr info = ModInfo::getByIndex(modIdx); + std::vector flags = info->getFlags(); + return flags.size(); + } else { + return 0; + } +} + +QSize ModFlagIconDelegate::sizeHint(const QStyleOptionViewItem& option, + const QModelIndex& modelIndex) const +{ + size_t count = getNumIcons(modelIndex); + unsigned int index = modelIndex.data(ModList::IndexRole).toInt(); + QSize result; + if (index < ModInfo::getNumMods()) { + result = QSize(static_cast(count) * 40, 20); + } else { + result = QSize(1, 20); + } + if (option.rect.width() > 0) { + result.setWidth(std::min(option.rect.width(), result.width())); + } + return result; +} diff --git a/src/src/modflagicondelegate.h b/src/src/modflagicondelegate.h new file mode 100644 index 0000000..1c2ff47 --- /dev/null +++ b/src/src/modflagicondelegate.h @@ -0,0 +1,37 @@ +#ifndef MODFLAGICONDELEGATE_H +#define MODFLAGICONDELEGATE_H + +#include + +#include "icondelegate.h" +#include "modinfo.h" + +class ModListView; + +class ModFlagIconDelegate : public IconDelegate +{ + Q_OBJECT; + +public: + explicit ModFlagIconDelegate(ModListView* view, int column = -1, + int compactSize = 120); + QSize sizeHint(const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + +protected: + static QList getIconsForFlags(std::vector flags, + bool compact); + static QString getFlagIcon(ModInfo::EFlag flag); + + QList getIcons(const QModelIndex& index) const override; + size_t getNumIcons(const QModelIndex& index) const override; + + // constructor for color table + // + ModFlagIconDelegate() : ModFlagIconDelegate(nullptr) {} + +private: + ModListView* m_view; +}; + +#endif // MODFLAGICONDELEGATE_H diff --git a/src/src/modidlineedit.cpp b/src/src/modidlineedit.cpp new file mode 100644 index 0000000..e3fa58c --- /dev/null +++ b/src/src/modidlineedit.cpp @@ -0,0 +1,20 @@ +#include "modidlineedit.h" +#include + +#include +#include + +ModIDLineEdit::ModIDLineEdit(QWidget* parent) : QLineEdit(parent) {} +ModIDLineEdit::ModIDLineEdit(const QString& text, QWidget* parent) + : QLineEdit(text, parent) +{} + +bool ModIDLineEdit::event(QEvent* event) +{ + if (event->type() == QEvent::WhatsThisClicked) { + QWhatsThisClickedEvent* wtcEvent = static_cast(event); + emit linkClicked(wtcEvent->href().trimmed()); + } + + return QLineEdit::event(event); +} diff --git a/src/src/modidlineedit.h b/src/src/modidlineedit.h new file mode 100644 index 0000000..cf70659 --- /dev/null +++ b/src/src/modidlineedit.h @@ -0,0 +1,21 @@ +#ifndef MODIDLINEEDIT_H +#define MODIDLINEEDIT_H + +#include + +class ModIDLineEdit : public QLineEdit +{ + Q_OBJECT + +public: + explicit ModIDLineEdit(QWidget* parent = 0); + explicit ModIDLineEdit(const QString& text, QWidget* parent = 0); + +public: + virtual bool event(QEvent* event) override; + +signals: + void linkClicked(QString); +}; + +#endif // MODIDLINEEDIT_H diff --git a/src/src/modinfo.cpp b/src/src/modinfo.cpp new file mode 100644 index 0000000..e89b3a0 --- /dev/null +++ b/src/src/modinfo.cpp @@ -0,0 +1,576 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "modinfo.h" + +#include "modinfobackup.h" +#include "modinfoforeign.h" +#include "modinfooverwrite.h" +#include "modinforegular.h" +#include "modinfoseparator.h" + +#include "categories.h" +#include "modinfodialog.h" +#include "modlist.h" +#include "organizercore.h" +#include "overwriteinfodialog.h" +#include "thread_utils.h" +#include "versioninfo.h" + +#include "shared/appconfig.h" +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace MOBase; +using namespace MOShared; + +const std::set ModInfo::s_EmptySet; +std::vector ModInfo::s_Collection; +ModInfo::Ptr ModInfo::s_Overwrite; +std::map ModInfo::s_ModsByName; +std::map, std::vector> ModInfo::s_ModsByModID; +int ModInfo::s_NextID; +QRecursiveMutex ModInfo::s_Mutex; + +QString ModInfo::s_HiddenExt(".mohidden"); + +bool ModInfo::ByName(const ModInfo::Ptr& LHS, const ModInfo::Ptr& RHS) +{ + return QString::compare(LHS->name(), RHS->name(), Qt::CaseInsensitive) < 0; +} + +bool ModInfo::isSeparatorName(const QString& name) +{ + static QRegularExpression separatorExp( + QRegularExpression::anchoredPattern(".*_separator")); + return separatorExp.match(name).hasMatch(); +} + +bool ModInfo::isBackupName(const QString& name) +{ + static QRegularExpression backupExp( + QRegularExpression::anchoredPattern(".*backup[0-9]*")); + return backupExp.match(name).hasMatch(); +} + +bool ModInfo::isRegularName(const QString& name) +{ + return !isSeparatorName(name) && !isBackupName(name); +} + +ModInfo::Ptr ModInfo::createFrom(const QDir& dir, OrganizerCore& core) +{ + QMutexLocker locker(&s_Mutex); + ModInfo::Ptr result; + + if (isBackupName(dir.dirName())) { + result = ModInfo::Ptr(new ModInfoBackup(dir, core)); + } else if (isSeparatorName(dir.dirName())) { + result = Ptr(new ModInfoSeparator(dir, core)); + } else { + result = ModInfo::Ptr(new ModInfoRegular(dir, core)); + } + result->m_Index = s_Collection.size(); + s_Collection.push_back(result); + return result; +} + +ModInfo::Ptr ModInfo::createFromPlugin(const QString& modName, const QString& espName, + const QStringList& bsaNames, + ModInfo::EModType modType, OrganizerCore& core) +{ + QMutexLocker locker(&s_Mutex); + ModInfo::Ptr result = + ModInfo::Ptr(new ModInfoForeign(modName, espName, bsaNames, modType, core)); + result->m_Index = s_Collection.size(); + s_Collection.push_back(result); + return result; +} + +ModInfo::Ptr ModInfo::createFromOverwrite(OrganizerCore& core) +{ + QMutexLocker locker(&s_Mutex); + ModInfo::Ptr overwrite = ModInfo::Ptr(new ModInfoOverwrite(core)); + overwrite->m_Index = s_Collection.size(); + s_Collection.push_back(overwrite); + return overwrite; +} + +unsigned int ModInfo::getNumMods() +{ + QMutexLocker locker(&s_Mutex); + return static_cast(s_Collection.size()); +} + +ModInfo::Ptr ModInfo::getByIndex(unsigned int index) +{ + QMutexLocker locker(&s_Mutex); + + if (index >= s_Collection.size() && index != ULONG_MAX) { + throw MyException(tr("invalid mod index: %1").arg(index)); + } + if (index == ULONG_MAX) + return s_Collection[ModInfo::getIndex("Overwrite")]; + return s_Collection[index]; +} + +std::vector ModInfo::getByModID(QString game, int modID) +{ + QMutexLocker locker(&s_Mutex); + + std::vector match; + for (auto iter : s_ModsByModID) { + if (iter.first.second == modID) { + if (iter.first.first.compare(game, Qt::CaseInsensitive) == 0) { + match.insert(match.end(), iter.second.begin(), iter.second.end()); + } + } + } + if (match.empty()) { + return std::vector(); + } + + std::vector result; + for (auto iter : match) { + result.push_back(getByIndex(iter)); + } + + return result; +} + +ModInfo::Ptr ModInfo::getByName(const QString& name) +{ + QMutexLocker locker(&s_Mutex); + + return s_Collection[ModInfo::getIndex(name)]; +} + +bool ModInfo::removeMod(unsigned int index) +{ + QMutexLocker locker(&s_Mutex); + + if (index >= s_Collection.size()) { + throw Exception(tr("remove: invalid mod index %1").arg(index)); + } + + ModInfo::Ptr modInfo = s_Collection[index]; + + // remove the actual mod (this is the most likely to fail so we do this first) + if (modInfo->isRegular()) { + if (!shellDelete(QStringList(modInfo->absolutePath()), true)) { + reportError( + tr("remove: failed to delete mod '%1' directory").arg(modInfo->name())); + return false; + } + } + + // update the indices + s_ModsByName.erase(s_ModsByName.find(modInfo->name())); + + auto iter = s_ModsByModID.find( + std::pair(modInfo->gameName(), modInfo->nexusId())); + if (iter != s_ModsByModID.end()) { + std::vector indices = iter->second; + indices.erase(std::remove(indices.begin(), indices.end(), index), indices.end()); + s_ModsByModID[std::pair(modInfo->gameName(), modInfo->nexusId())] = + indices; + } + + // finally, remove the mod from the collection + s_Collection.erase(s_Collection.begin() + index); + + // and update the indices + updateIndices(); + return true; +} + +unsigned int ModInfo::getIndex(const QString& name) +{ + QMutexLocker locker(&s_Mutex); + + std::map::iterator iter = s_ModsByName.find(name); + if (iter == s_ModsByName.end()) { + return UINT_MAX; + } + + return iter->second; +} + +unsigned int ModInfo::findMod(const boost::function& filter) +{ + for (unsigned int i = 0U; i < s_Collection.size(); ++i) { + if (filter(s_Collection[i])) { + return i; + } + } + return UINT_MAX; +} + +void ModInfo::updateFromDisc(const QString& modsDirectory, OrganizerCore& core, + bool displayForeign, std::size_t refreshThreadCount) +{ + TimeThis tt("ModInfo::updateFromDisc()"); + + QMutexLocker lock(&s_Mutex); + s_Collection.clear(); + s_NextID = 0; + s_Overwrite = nullptr; + + { // list all directories in the mod directory and make a mod out of each + QDir mods(QDir::fromNativeSeparators(modsDirectory)); + mods.setFilter(QDir::Dirs | QDir::NoDotAndDotDot); + QDirIterator modIter(mods); + while (modIter.hasNext()) { + createFrom(QDir(modIter.next()), core); + } + } + + auto* game = core.managedGame(); + auto& features = core.pluginContainer().gameFeatures(); + auto unmanaged = features.gameFeature(); + if (unmanaged != nullptr) { + for (const QString& modName : unmanaged->mods(!displayForeign)) { + ModInfo::EModType modType = + game->DLCPlugins().contains(unmanaged->referenceFile(modName).fileName(), + Qt::CaseInsensitive) + ? ModInfo::EModType::MOD_DLC + : (game->CCPlugins().contains( + unmanaged->referenceFile(modName).fileName(), Qt::CaseInsensitive) + ? ModInfo::EModType::MOD_CC + : ModInfo::EModType::MOD_DEFAULT); + + createFromPlugin(unmanaged->displayName(modName), + unmanaged->referenceFile(modName).absoluteFilePath(), + unmanaged->secondaryFiles(modName), modType, core); + } + } + + s_Overwrite = createFromOverwrite(core); + + std::sort(s_Collection.begin(), s_Collection.end(), ModInfo::ByName); + + parallelMap(std::begin(s_Collection), std::end(s_Collection), &ModInfo::prefetch, + refreshThreadCount); + + updateIndices(); +} + +void ModInfo::updateIndices() +{ + s_ModsByName.clear(); + s_ModsByModID.clear(); + + for (unsigned int i = 0; i < s_Collection.size(); ++i) { + QString modName = s_Collection[i]->internalName(); + QString game = s_Collection[i]->gameName(); + int modID = s_Collection[i]->nexusId(); + s_Collection[i]->m_Index = i; + s_ModsByName[modName] = i; + s_ModsByModID[std::pair(game, modID)].push_back(i); + } +} + +ModInfo::ModInfo(OrganizerCore& core) : m_PrimaryCategory(-1), m_Core(core) {} + +bool ModInfo::checkAllForUpdate(PluginContainer* pluginContainer, QObject* receiver) +{ + bool updatesAvailable = true; + + QDateTime earliest = QDateTime::currentDateTimeUtc(); + QDateTime latest = QDateTime::fromMSecsSinceEpoch(0); + std::set games; + for (auto mod : s_Collection) { + if (mod->canBeUpdated()) { + if (mod->getLastNexusUpdate() < earliest) + earliest = mod->getLastNexusUpdate(); + if (mod->getLastNexusUpdate() > latest) + latest = mod->getLastNexusUpdate(); + games.insert(mod->gameName().toLower()); + } + } + + // Detect invalid source games + for (auto itr = games.begin(); itr != games.end();) { + auto gamePlugins = pluginContainer->plugins(); + IPluginGame* gamePlugin = qApp->property("managed_game").value(); + for (auto plugin : gamePlugins) { + if (plugin != nullptr && + plugin->gameShortName().compare(*itr, Qt::CaseInsensitive) == 0) { + gamePlugin = plugin; + break; + } + } + if (gamePlugin != nullptr && gamePlugin->gameNexusName().isEmpty()) { + log::warn("{}", tr("The update check has found a mod with a Nexus ID and source " + "game of %1, but this game is not a valid Nexus source.") + .arg(gamePlugin->gameName())); + itr = games.erase(itr); + } else { + ++itr; + } + } + + if (latest < QDateTime::currentDateTimeUtc().addMonths(-1)) { + std::set> organizedGames; + for (auto mod : s_Collection) { + if (mod->canBeUpdated() && + mod->getLastNexusUpdate() < QDateTime::currentDateTimeUtc().addMonths(-1)) { + organizedGames.insert( + std::make_pair(mod->gameName().toLower(), mod->nexusId())); + } + } + + if (organizedGames.empty()) { + log::warn("{}", + tr("All of your mods have been checked recently. We restrict update " + "checks to help preserve your available API requests.")); + updatesAvailable = false; + } else { + log::info("{}", tr("You have mods that haven't been checked within the last " + "month using the new API. These mods must be checked before " + "we can use the bulk update API. " + "This will consume significantly more API requests than " + "usual. You will need to rerun the update check once complete " + "in order to parse the remaining mods.")); + } + + for (auto game : organizedGames) + NexusInterface::instance().requestUpdates(game.second, receiver, QVariant(), + game.first, QString()); + } else if (earliest < QDateTime::currentDateTimeUtc().addMonths(-1)) { + for (auto gameName : games) + NexusInterface::instance().requestUpdateInfo(gameName, + NexusInterface::UpdatePeriod::MONTH, + receiver, QVariant(true), QString()); + } else if (earliest < QDateTime::currentDateTimeUtc().addDays(-7)) { + for (auto gameName : games) + NexusInterface::instance().requestUpdateInfo( + gameName, NexusInterface::UpdatePeriod::MONTH, receiver, QVariant(false), + QString()); + } else if (earliest < QDateTime::currentDateTimeUtc().addDays(-1)) { + for (auto gameName : games) + NexusInterface::instance().requestUpdateInfo( + gameName, NexusInterface::UpdatePeriod::WEEK, receiver, QVariant(false), + QString()); + } else { + for (auto gameName : games) + NexusInterface::instance().requestUpdateInfo( + gameName, NexusInterface::UpdatePeriod::DAY, receiver, QVariant(false), + QString()); + } + + return updatesAvailable; +} + +std::set> ModInfo::filteredMods(QString gameName, + QVariantList updateData, + bool addOldMods, + bool markUpdated) +{ + std::set> finalMods; + for (QVariant result : updateData) { + QVariantMap update = result.toMap(); + std::copy_if(s_Collection.begin(), s_Collection.end(), + std::inserter(finalMods, finalMods.end()), + [=](QSharedPointer info) -> bool { + if (info->nexusId() == update["mod_id"].toInt() && + info->gameName().compare(gameName, Qt::CaseInsensitive) == 0) + if (info->getLastNexusUpdate().addSecs(-3600) < + QDateTime::fromSecsSinceEpoch( + update["latest_file_update"].toInt(), Qt::UTC)) + return true; + return false; + }); + } + + if (addOldMods) + for (auto mod : s_Collection) + if (mod->getLastNexusUpdate() < QDateTime::currentDateTimeUtc().addMonths(-1) && + mod->gameName().compare(gameName, Qt::CaseInsensitive) == 0) + finalMods.insert(mod); + + if (markUpdated) { + std::set> updates; + std::copy_if(s_Collection.begin(), s_Collection.end(), + std::inserter(updates, updates.end()), + [=](QSharedPointer info) -> bool { + if (info->gameName().compare(gameName, Qt::CaseInsensitive) == 0 && + info->canBeUpdated()) + return true; + return false; + }); + std::set> diff; + std::set_difference(updates.begin(), updates.end(), finalMods.begin(), + finalMods.end(), std::inserter(diff, diff.end())); + for (auto skipped : diff) { + skipped->setLastNexusUpdate(QDateTime::currentDateTimeUtc()); + } + } + return finalMods; +} + +void ModInfo::manualUpdateCheck(QObject* receiver, std::multimap IDs) +{ + std::vector> mods; + std::set> organizedGames; + + for (auto ID : IDs) { + for (auto matchedMod : getByModID(ID.first, ID.second)) { + bool alreadyMatched = false; + for (auto mod : mods) { + if (mod == matchedMod) { + alreadyMatched = true; + break; + } + } + if (!alreadyMatched) + mods.push_back(matchedMod); + } + } + mods.erase(std::remove_if(mods.begin(), mods.end(), + [](ModInfo::Ptr mod) -> bool { + return mod->nexusId() <= 0; + }), + mods.end()); + for (auto mod : mods) { + mod->setLastNexusUpdate(QDateTime()); + } + + std::sort(mods.begin(), mods.end(), + [](QSharedPointer a, QSharedPointer b) -> bool { + return a->getLastNexusUpdate() < b->getLastNexusUpdate(); + }); + + if (mods.size()) { + log::info("Checking updates for {} mods...", mods.size()); + + for (auto mod : mods) { + organizedGames.insert( + std::make_pair(mod->gameName().toLower(), mod->nexusId())); + } + + for (auto game : organizedGames) { + NexusInterface::instance().requestUpdates(game.second, receiver, QVariant(), + game.first, QString()); + } + } else { + log::info("None of the selected mods can be updated."); + } +} + +void ModInfo::setVersion(const VersionInfo& version) +{ + m_Version = version; +} + +void ModInfo::setPluginSelected(const bool& isSelected) +{ + m_PluginSelected = isSelected; +} + +void ModInfo::addCategory(const QString& categoryName) +{ + int id = CategoryFactory::instance().getCategoryID(categoryName); + if (id == -1) { + id = CategoryFactory::instance().addCategory( + categoryName, std::vector(), 0); + } + setCategory(id, true); +} + +bool ModInfo::removeCategory(const QString& categoryName) +{ + int id = CategoryFactory::instance().getCategoryID(categoryName); + if (id == -1) { + return false; + } + if (!categorySet(id)) { + return false; + } + setCategory(id, false); + return true; +} + +QStringList ModInfo::categories() const +{ + QStringList result; + + CategoryFactory& catFac = CategoryFactory::instance(); + for (int id : m_Categories) { + result.append(catFac.getCategoryName(catFac.getCategoryIndex(id))); + } + + return result; +} + +bool ModInfo::hasFlag(ModInfo::EFlag flag) const +{ + std::vector flags = getFlags(); + return std::find(flags.begin(), flags.end(), flag) != flags.end(); +} + +bool ModInfo::hasAnyOfTheseFlags(std::vector flags) const +{ + std::vector modFlags = getFlags(); + for (auto modFlag : modFlags) { + for (auto flag : flags) { + if (modFlag == flag) { + return true; + } + } + } + return false; +} + +bool ModInfo::categorySet(int categoryID) const +{ + for (std::set::const_iterator iter = m_Categories.begin(); + iter != m_Categories.end(); ++iter) { + if ((*iter == categoryID) || + (CategoryFactory::instance().isDescendantOf(*iter, categoryID))) { + return true; + } + } + + return false; +} + +QUrl ModInfo::parseCustomURL() const +{ + if (!hasCustomURL() || url().isEmpty()) { + return {}; + } + + const auto url = QUrl::fromUserInput(this->url()); + + if (!url.isValid()) { + log::error("mod '{}' has an invalid custom url '{}'", name(), this->url()); + return {}; + } + + return url; +} diff --git a/src/src/modinfo.h b/src/src/modinfo.h new file mode 100644 index 0000000..6475523 --- /dev/null +++ b/src/src/modinfo.h @@ -0,0 +1,1061 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef MODINFO_H +#define MODINFO_H + +#include "ifiletree.h" +#include "imodinterface.h" +#include "versioninfo.h" + +class OrganizerCore; +class PluginContainer; +class QDir; +class QDateTime; + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace MOBase +{ +class IPluginGame; +} +namespace MOShared +{ +class DirectoryEntry; +} + +/** + * @brief Represents meta information about a single mod. + * + * Represents meta information about a single mod. The class interface is used + * to manage the mod collection + * + */ +class ModInfo : public QObject, public MOBase::IModInterface +{ + + Q_OBJECT + +public: // Type definitions: + typedef QSharedPointer Ptr; + + static QString s_HiddenExt; + + enum EConflictFlag + { + FLAG_CONFLICT_OVERWRITE, + FLAG_CONFLICT_OVERWRITTEN, + FLAG_CONFLICT_MIXED, + FLAG_CONFLICT_REDUNDANT, + FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE, + FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN, + FLAG_ARCHIVE_CONFLICT_OVERWRITE, + FLAG_ARCHIVE_CONFLICT_OVERWRITTEN, + FLAG_ARCHIVE_CONFLICT_MIXED, + FLAG_OVERWRITE_CONFLICT, + }; + + enum EFlag + { + FLAG_INVALID, + FLAG_BACKUP, + FLAG_SEPARATOR, + FLAG_OVERWRITE, + FLAG_FOREIGN, + FLAG_HIDDEN_FILES, + FLAG_NOTENDORSED, + FLAG_NOTES, + FLAG_PLUGIN_SELECTED, + FLAG_ALTERNATE_GAME, + FLAG_TRACKED, + }; + + enum EHighlight + { + HIGHLIGHT_NONE = 0, + HIGHLIGHT_INVALID = 1, + HIGHLIGHT_CENTER = 2, + HIGHLIGHT_IMPORTANT = 4, + HIGHLIGHT_PLUGIN = 8 + }; + + enum EModType + { + MOD_DEFAULT, + MOD_DLC, + MOD_CC + }; + +public: // Static functions: + /** + * @brief Read the mod directory and Mod ModInfo objects for all subdirectories. + */ + static void updateFromDisc(const QString& modDirectory, OrganizerCore& core, + bool displayForeign, std::size_t refreshThreadCount); + + static void clear() + { + s_Collection.clear(); + s_ModsByName.clear(); + s_ModsByModID.clear(); + } + + /** + * @brief Retrieve the number of mods. + * + * @return the number of mods. + */ + static unsigned int getNumMods(); + + /** + * @brief Retrieve a ModInfo object based on its index. + * + * @param index The index to look up. The maximum value is getNumMods() - 1. + * + * @return a reference counting pointer to the mod info. + * + * @note since the pointer is reference counting, the pointer remains valid even if + * the collection is refreshed in a different thread. + */ + static ModInfo::Ptr getByIndex(unsigned int index); + + /** + * @brief Retrieve ModInfo objects based on mod id. + * + * @param modID The mod id to look up. + * + * @return a vector of reference counting pointer to the mod info objects with the + * given mod ID. + */ + static std::vector getByModID(QString game, int modID); + + /** + * @brief Retrieve a ModInfo object based on its name. + * + * @param name The name to look up. + * + * @return a reference counting pointer to the mod info. + * + * @note Since the pointer is reference counter, the pointer remains valid even if the + * collection is refreshed in a different thread. + */ + static ModInfo::Ptr getByName(const QString& name); + + /** + * @brief Remove a mod by index. + * + * This physically deletes the specified mod from the disc and updates the ModInfo + * collection but not other structures that reference mods. + * + * @param index Index of the mod to delete. + * + * @return true if removal was successful, false otherwise. + */ + static bool removeMod(unsigned int index); + + /** + * @brief Retrieve the mod index by the mod name. + * + * @param name Name of the mod to look up. + * + * @return The index of the mod. If the mod doesn't exist, UINT_MAX is returned. + */ + static unsigned int getIndex(const QString& name); + + /** + * @brief Retrieve the overwrite mod. + */ + static ModInfo::Ptr getOverwrite() { return s_Overwrite; } + + /** + * @brief Find the first mod that fulfills the filter function (after no particular + * order). + * + * @param filter A function to filter mods by. Should return true for a match. + * + * @return index of the matching mod or UINT_MAX if there was no match. + */ + static unsigned int findMod(const boost::function& filter); + + /** + * @brief Run a limited batch of mod update checks for "newest version" information. + * + */ + static void manualUpdateCheck(QObject* receiver, std::multimap IDs); + + /** + * @brief Query nexus information for every mod and update the "newest version" + * information. + * + * @return true if any mods are checked for update. + */ + static bool checkAllForUpdate(PluginContainer* pluginContainer, QObject* receiver); + + /** + * + */ + static std::set filteredMods(QString gameName, QVariantList updateData, + bool addOldMods = false, + bool markUpdated = false); + + /** + * @brief Check wheter a name corresponds to a separator or not, + * + * @return whether the given name is used for separators. + */ + static bool isSeparatorName(const QString& name); + + /** + * @brief Check wheter a name corresponds to a backup or not, + * + * @return whether the given name is used for backups. + */ + static bool isBackupName(const QString& name); + + /** + * @brief Check wheter a name corresponds to a regular mod or not, + * + * @return whether the given name corresponds to a regular mod. + */ + static bool isRegularName(const QString& name); + +public: // IModInterface implementations / Re-declaration + // Note: This section contains default-implementation for some of the virtual methods + // from IModInterface, but also redeclaration of all the pure-virtual methods to + // centralize all of them in a single place. + + /** + * @return the name of the mod. + */ + virtual QString name() const = 0; + + /** + * @return the absolute path to the mod to be used in file system operations. + */ + virtual QString absolutePath() const = 0; + + /** + * @return the comments for this mod, if any. + */ + virtual QString comments() const = 0; + + /** + * @return the notes for this mod, if any. + */ + virtual QString notes() const = 0; + + /** + * @brief Retrieve the short name of the game associated with this mod. This may + * differ from the current game plugin (e.g. you can install a Skyrim LE game in a SSE + * installation). + * + * @return the name of the game associated with this mod. + */ + virtual QString gameName() const = 0; + + /** + * @return the name of the repository from which this mod was installed. + */ + virtual QString repository() const override { return ""; } + + /** + * @return the nexus ID of this mod on the repository. + */ + virtual int nexusId() const = 0; + + /** + * @return the current version of this mod. + */ + virtual MOBase::VersionInfo version() const override { return m_Version; } + + /** + * @return the newest version of thid mod (as known by MO2). If this matches + * version(), then the mod is up-to-date. + */ + virtual MOBase::VersionInfo newestVersion() const = 0; + + /** + * @return the ignored version of this mod (for update), or an invalid version if the + * user did not ignore version for this mod. + */ + virtual MOBase::VersionInfo ignoredVersion() const = 0; + + /** + * @return the absolute path to the file that was used to install this mod. + */ + virtual QString installationFile() const = 0; + + virtual std::set> installedFiles() const = 0; + + /** + * @return true if this mod was marked as converted by the user. + * + * @note When a mod is for a different game, a flag is shown to users to warn them, + * but they can mark mods as converted to remove this flag. + */ + virtual bool converted() const = 0; + + /** + * @return true if th is mod was marked as containing valid game data. + * + * @note MO2 uses ModDataChecker to check the content of mods, but sometimes these + * fail, in which case mods are incorrectly marked as 'not containing valid games + * data'. Users can choose to mark these mods as valid to hide the warning / flag. + */ + virtual bool validated() const = 0; + + /** + * @return the color of the 'Notes' column chosen by the user. + */ + virtual QColor color() const override { return QColor(); } + + /** + * @return the URL of this mod, or an empty QString() if no URL is associated + * with this mod. + */ + virtual QString url() const override { return ""; } + + /** + * @return the ID of the primary category of this mod. + */ + int primaryCategory() const override { return m_PrimaryCategory; } + + /** + * @return the list of categories this mod belongs to. + */ + virtual QStringList categories() const override; + + /** + * @return the author of the mod. + */ + virtual QString author() const = 0; + + /** + * @return the name of the uploader of this mod. + */ + virtual QString uploader() const = 0; + + /** + * @return the URL of the uploader of this mod's profile. + */ + virtual QString uploaderUrl() const = 0; + + /** + * @return the tracked state of this mod. + */ + virtual MOBase::TrackedState trackedState() const override + { + return MOBase::TrackedState::TRACKED_FALSE; + } + + /** + * @return the endorsement state of this mod. + */ + virtual MOBase::EndorsedState endorsedState() const override + { + return MOBase::EndorsedState::ENDORSED_NEVER; + } + + /** + * @brief Retrieve a file tree corresponding to the underlying disk content + * of this mod. + * + * The file tree should not be cached since it is already cached and updated when + * required. + * + * @return a file tree representing the content of this mod. + */ + virtual std::shared_ptr fileTree() const = 0; + + /** + * @return true if this object represents a regular mod. + */ + virtual bool isRegular() const { return false; } + + /** + * @return true if this object represents the overwrite mod. + */ + virtual bool isOverwrite() const { return false; } + + /** + * @return true if this object represents a backup. + */ + virtual bool isBackup() const { return false; } + + /** + * @return true if this object represents a separator. + */ + virtual bool isSeparator() const { return false; } + + /** + * @return true if this object represents a foreign mod. + */ + virtual bool isForeign() const { return false; } + +public: // Mutable operations: + /** + * @brief Sets or changes the version of this mod. + * + * @param version New version of the mod. + */ + virtual void setVersion(const MOBase::VersionInfo& version) override; + + /** + * @brief Sets the installation file for this mod. + * + * @param fileName archive file name. + */ + virtual void setInstallationFile(const QString& fileName) = 0; + + /** + * @brief Sets or changes the latest known version of this mod. + * + * @param version Newest known version of the mod. + */ + virtual void setNewestVersion(const MOBase::VersionInfo& version) = 0; + + /** + * @brief Sets endorsement state of the mod. + * + * @param endorsed New endorsement state. + */ + virtual void setIsEndorsed(bool endorsed) = 0; + + /** + * @brief Sets the mod id on nexus for this mod. + * + * @param nexusID The new Nexus id to set. + */ + virtual void setNexusID(int nexusID) = 0; + + /** + * @brief Sets the category id from a nexus category id. Conversion to MO id happens + * internally. + * + * @param categoryID The nexus category id. + * + * @note If a mapping is not possible, the category is set to the default value. + */ + virtual void addNexusCategory(int categoryID) = 0; + + /** + * @brief Assigns a category to the mod. If the named category does not exist it is + * created. + * + * @param categoryName Name of the new category. + */ + virtual void addCategory(const QString& categoryName) override; + + /** + * @brief Unassigns a category from this mod. + * + * @param categoryName Name of the category to be removed. + * + * @return true if the category was removed successfully, false if no such category + * was assigned. + */ + virtual bool removeCategory(const QString& categoryName) override; + + /** + * @brief Sets or changes the source game of this mod. + * + * @param gameName The source game short name. + */ + virtual void setGameName(const QString& gameName) = 0; + + /** + * @brief Sets the name of this mod. + * + * This will also update the name of the directory that contains this mod. + * + * @param name New name of the mod. + * + * @return true on success, false if the new name can't be used (i.e. because the new + * directory name wouldn't be valid). + */ + virtual bool setName(const QString& name) = 0; + + /** + * @brief Set the author of the mod. + */ + virtual void setAuthor(const QString& author) = 0; + + /** + * @brief Set the name of the uploader of this mod. + */ + virtual void setUploader(const QString& uploader) = 0; + + /** + * @brief Set the URL of the uploader of this mod's profile. + */ + virtual void setUploaderUrl(const QString& uploaderUrl) = 0; + +public: // Methods after this do not come from IModInterface: + /** + * @return true if this mod is empty, false otherwise. + */ + virtual bool isEmpty() const { return false; } + + /** + * @brief Check if there is a newer version of the mod. + * + * Check if there is a newer version of the mod. This does NOT cause information + * to be retrieved from the nexus, it will only test version information already + * available locally. Use checkAllForUpdate() to update this version information. + * + * @return true if there is a newer version, false otherwise. + */ + virtual bool updateAvailable() const = 0; + + /** + * @return true if the update currently available is ignored. + */ + virtual bool updateIgnored() const = 0; + + /** + * @brief Check if the "newest" version of the mod is older than the installed + * version. + * + * Check if there is a newer version of the mod. This does NOT cause information to be + * retrieved from the nexus, it will only test version information already available + * locally. Use checkAllForUpdate() to update this version information. + * + * @return true if the newest version is older than the installed one. + */ + virtual bool downgradeAvailable() const = 0; + + /** + * @brief Request an update of nexus description for this mod. + * + * This requests mod information from the nexus. This is an asynchronous request, + * so there is no immediate effect of this call. Right now, Mod Organizer interprets + * the "newest version" and "description" from the response, though the description is + * only stored in memory. + * + */ + virtual bool updateNXMInfo() = 0; + + /** + * @brief Assigns or unassigns the specified category/ + * + * Every mod can have an arbitrary number of categories assigned to it. + * + * @param categoryID ID of the category to set. + * @param active Determines whether the category is assigned or unassigned. + * + * @note This function does not test whether categoryID actually identifies a valid + * category. + */ + virtual void setCategory(int categoryID, bool active) = 0; + + /** + * @brief Changes the comments (manually set information displayed in the mod list) + * for this mod. + * + * @param comments The new comments. + */ + virtual void setComments(const QString& comments) = 0; + + /** + * @brief Change the notes (manually set information) for this mod. + * + * @param notes The new notes. + */ + virtual void setNotes(const QString& notes) = 0; + + /** + * @brief Controls if mod should be highlighted based on plugin selection. + * + * @param isSelected Whether or not the plugin has a selected mod. + */ + virtual void setPluginSelected(const bool& isSelected); + + /** + * @brief Sets the repository that was used to download the mod. + */ + virtual void setRepository(const QString&) {} + + /** + * @brief Set the mod to "I do not intend to endorse.". The mod will not show as + * unendorsed but can still be endorsed. + */ + virtual void setNeverEndorse() = 0; + + /** + * @brief Updates the tracked state for the mod. This only changes the buffered state, + * it does not sync with Nexus. + * + * @param tracked The new tracked state. + * + * @see track(bool) + */ + virtual void setIsTracked(bool tracked) = 0; + + /** + * @brief Endorses or un-endorses the mod. This will sync with nexus! + * + * @param doEndorse If true, the mod is endorsed, if false, it's un-endorsed. + * + * @note If doEndorse does not differ from the current value, nothing happens. + */ + virtual void endorse(bool doEndorse) = 0; + + /** + * @brief Tracks or untracks the mod. This will sync with nexus! + * + * @param doTrack If true, the mod is tracked, if false, it's untracked. + * + * @note If doTrack does not differ from the current value, nothing happens. + */ + virtual void track(bool doTrack) = 0; + + /** + * @brief Clear all caches held for this mod. + */ + virtual void clearCaches() {} + + /** + * @brief Retrieve the internal name of the mod. This is usually the same as the + * regular name, but with special mod types it might be used to distinguish between + * mods that have the same visible name. + * + * @return the internal mod name. + */ + virtual QString internalName() const { return name(); } + + /** + * @brief Ignores the newest version for updates. + */ + virtual void ignoreUpdate(bool ignore) = 0; + + // check if the priority of this mod is not user-modifiable (i.e. + // computed by MO2 automatically) + // + bool hasAutomaticPriority() const { return isBackup() || isOverwrite(); } + + // check if this mod should always be enabled or disabled + // + virtual bool alwaysEnabled() const { return false; } + virtual bool alwaysDisabled() const { return false; } + + /** + * @return true if the mod can be updated. + */ + virtual bool canBeUpdated() const { return false; } + + /** + * @return the mod update check expiration date. + */ + virtual QDateTime getExpires() const = 0; + + /** + * @return true if the mod can be enabled/disabled. + */ + virtual bool canBeEnabled() const { return false; } + + /** + * @return a list of flags for this mod. + */ + virtual std::vector getFlags() const = 0; + + /** + * @return a list of conflict flags for this mod. + */ + virtual std::vector getConflictFlags() const = 0; + + /** + * @return a list of content types contained in a mod. + * + * @note The IDs of the content are game-dependent. See the ModDataContent game + * feature for more details on this. + */ + virtual const std::set& getContents() const = 0; + + /** + * @brief Check if the specified flag is set for this mod. + * + * @param flag The flag to test. + * + * @return true if the flag is set, false otherwise. + */ + bool hasFlag(EFlag flag) const; + + /** + * @brief Check if any of the provided flags are set for this mod. + * + * @param flags The flags to test. + * + * @return true if any of the flags are set, false otherwise. + */ + bool hasAnyOfTheseFlags(std::vector flags) const; + + /** + * @brief Check if this mod contains the specified content. + * + * @param content ID of the content to test. + * + * @return true if the content is there, false otherwise. + */ + virtual bool hasContent(int content) const = 0; + + /** + * @return an indicator if and how this mod should be highlighted in the UI. + */ + virtual int getHighlight() const { return HIGHLIGHT_NONE; } + + /** + * @return the list of INI tweaks in this mod. + */ + virtual std::vector getIniTweaks() const = 0; + + /** + * @return the description of the mod, to display in the UI. + */ + virtual QString getDescription() const = 0; + + /** + * @return the creation time of this mod. + */ + virtual QDateTime creationTime() const = 0; + + /** + * @return the list of files that, if they exist in the data directory are treated as + * files in THIS mod. + */ + virtual QStringList stealFiles() const { return QStringList(); } + + /** + * @return the list of archives belonging to this mod (as absolute file paths). + */ + virtual QStringList archives(bool checkOnDisk = false) = 0; + + /** + * @brief Set the color of this mod for display. + * + * @param color New color of this mod. + * + * @note Currently, this changes the color of the cell under the "Notes" column. + */ + virtual void setColor([[maybe_unused]] QColor color) {} + + /** + * @brief Adds the information that a file has been installed into this mod. + * + * @param modId ID of the mod installed. + * @param fileId ID of the file installed. + */ + virtual void addInstalledFile(int modId, int fileId) = 0; + + /** + * @brief Check if the mod belongs to the specified category. + * + * @param categoryID ID of the category to test for. + * + * @return true if the mod belongs to the specified category. + * + * @note This does not verify the id actually identifies a category. + */ + bool categorySet(int categoryID) const; + + /** + * @brief Retrieves the whole list of categories (as ids) this mod belongs to. + * + * @return the IDs of categories this mod belongs to. + */ + const std::set& getCategories() const { return m_Categories; } + + /** + * @brief Sets the new primary category of the mod. + * + * @param categoryID ID of the primary category to set. + */ + virtual void setPrimaryCategory(int categoryID) { m_PrimaryCategory = categoryID; } + + /** + * @return true if this mod is considered "valid", that is it contains data used by + * the game. + */ + virtual bool isValid() const = 0; + + /** + * @brief Updates the mod to flag it as converted in order to ignore the alternate + * game warning. + */ + virtual void markConverted(bool) {} + + /** + * @brief Updates the mod to flag it as valid in order to ignore the invalid game data + * flag. + */ + virtual void markValidated(bool) {} + + /** + * @brief Reads meta information from disk. + */ + virtual void readMeta() {} + + /** + * @brief Stores meta information back to disk. + */ + virtual void saveMeta() {} + + /** + * @brief Sets whether this mod uses a custom url. + */ + virtual void setHasCustomURL(bool) {} + + /** + * @brief Check whether this mod uses a custom url. + * + * @return true if this mod has a custom URL, false otherwise. + */ + virtual bool hasCustomURL() const { return false; } + + /** + * @brief Sets the custom url. + */ + virtual void setCustomURL(QString const&) {} + + /** + * @brief Sets the URL for this mod. + * + * In practice, this is a shortcut for setHasCustomURL followed by + * setCustomURL. + * + * @param url The new URL. + */ + void setUrl(QString const& url) override + { + setHasCustomURL(true); + setCustomURL(url); + } + + /** + * If hasCustomURL() is true and getCustomURL() is not empty, tries to parse + * the url using QUrl::fromUserInput() and returns it. Otherwise, returns an + * empty QUrl. + */ + QUrl parseCustomURL() const; + +public: // Nexus stuff + /** + * @brief Changes the nexus description text. + * + * @param description The current description text. + */ + virtual void setNexusDescription(const QString& description) = 0; + + /** + * @return the nexus file status (aka category ID). + */ + virtual int getNexusFileStatus() const = 0; + + /** + * @brief Sets the file status (category ID) from Nexus. + * + * @param status The status id of the installed file. + */ + virtual void setNexusFileStatus(int status) = 0; + + /** + * @return the nexus description of the mod (html). + */ + virtual QString getNexusDescription() const = 0; + + /** + * @brief Get the last time nexus was checked for file updates on this mod. + */ + virtual QDateTime getLastNexusUpdate() const = 0; + + /** + * @brief Sets the last time nexus was checked for file updates on this mod. + */ + virtual void setLastNexusUpdate(QDateTime time) = 0; + + /** + * @return the last time nexus was queried for infos on this mod. + */ + virtual QDateTime getLastNexusQuery() const = 0; + + /** + * @brief Sets the last time nexus was queried for info on this mod. + */ + virtual void setLastNexusQuery(QDateTime time) = 0; + + /** + * @return the last time the mod was updated on Nexus. + */ + virtual QDateTime getNexusLastModified() const = 0; + + /** + * @brief Set the last time the mod was updated on Nexus. + */ + virtual void setNexusLastModified(QDateTime time) = 0; + + /** + * @return the assigned nexus category ID + */ + virtual int getNexusCategory() const = 0; + + /** + * @brief Assigns the given Nexus category ID + */ + virtual void setNexusCategory(int category) = 0; + +public: // Conflicts + // retrieve the list of mods (as mod index) that are overwritten by this one. + // Updates may be delayed. + // + virtual const std::set& getModOverwrite() const { return s_EmptySet; } + + // retrieve the list of mods (as mod index) that overwrite this one. + // Updates may be delayed. + // + virtual const std::set& getModOverwritten() const { return s_EmptySet; } + + // retrieve the list of mods (as mod index) with archives that are overwritten by + // this one. Updates may be delayed + // + virtual const std::set& getModArchiveOverwrite() const + { + return s_EmptySet; + } + + // retrieve the list of mods (as mod index) with archives that overwrite this one. + // Updates may be delayed. + // + virtual const std::set& getModArchiveOverwritten() const + { + return s_EmptySet; + } + + // retrieve the list of mods (as mod index) with archives that are overwritten by + // loose files of this mod. Updates may be delayed. + // + virtual const std::set& getModArchiveLooseOverwrite() const + { + return s_EmptySet; + } + + // retrieve the list of mods (as mod index) with loose files that overwrite this one's + // archive files. Updates may be delayed. + // + virtual const std::set& getModArchiveLooseOverwritten() const + { + return s_EmptySet; + } + +public slots: + + /** + * @brief Notify this mod that the content of the disk may have changed. + */ + virtual void diskContentModified() = 0; + +signals: + + /** + * @brief Emitted whenever the information of a mod changes. + * + * @param success true if the mod details were updated successfully, false if not. + */ + void modDetailsUpdated(bool success); + +protected: + /** + * + */ + ModInfo(OrganizerCore& core); + + /** + * @brief Prefetch content for this mod. + * + * This method can be used to prefetch content from the mod, e.g., for isValid() + * or getContents(). This method will only be called when first creating the mod + * using multiple threads for all the mods. + */ + virtual void prefetch() = 0; + static bool ByName(const ModInfo::Ptr& LHS, const ModInfo::Ptr& RHS); + +protected: + // the mod list + OrganizerCore& m_Core; + + // the index of the mod in s_Collection, only valid after updateIndices() + int m_Index; + + int m_PrimaryCategory; + std::set m_Categories; + MOBase::VersionInfo m_Version; + bool m_PluginSelected = false; + + // empty set that can be returned in overwrite functions by + // default + static const std::set s_EmptySet; + +protected: + friend class OrganizerCore; + + /** + * @brief Create a new mod from the specified directory and add it to the collection. + * + * @param dir Directory to create from. + * + * @return pointer to the info-structure of the newly created/added mod. + */ + static ModInfo::Ptr createFrom(const QDir& dir, OrganizerCore& core); + + /** + * @brief Create a new "foreign-managed" mod from a tuple of plugin and archives. + * + * @param espName Name of the plugin. + * @param bsaNames Names of archives. + * + * @return a new mod. + */ + static ModInfo::Ptr createFromPlugin(const QString& modName, const QString& espName, + const QStringList& bsaNames, + ModInfo::EModType modType, OrganizerCore& core); + + static ModInfo::Ptr createFromOverwrite(OrganizerCore& core); + + // update the m_Index attribute of all mods and the various mapping + // + static void updateIndices(); + +protected: + static QRecursiveMutex s_Mutex; + static std::vector s_Collection; + static ModInfo::Ptr s_Overwrite; + static std::map s_ModsByName; + static std::map, std::vector> s_ModsByModID; + static int s_NextID; +}; + +#endif // MODINFO_H diff --git a/src/src/modinfobackup.cpp b/src/src/modinfobackup.cpp new file mode 100644 index 0000000..14ac2f0 --- /dev/null +++ b/src/src/modinfobackup.cpp @@ -0,0 +1,17 @@ +#include "modinfobackup.h" + +std::vector ModInfoBackup::getFlags() const +{ + std::vector result = ModInfoRegular::getFlags(); + result.insert(result.begin(), ModInfo::FLAG_BACKUP); + return result; +} + +QString ModInfoBackup::getDescription() const +{ + return tr("This is the backup of a mod"); +} + +ModInfoBackup::ModInfoBackup(const QDir& path, OrganizerCore& core) + : ModInfoRegular(path, core) +{} diff --git a/src/src/modinfobackup.h b/src/src/modinfobackup.h new file mode 100644 index 0000000..b8ccc0e --- /dev/null +++ b/src/src/modinfobackup.h @@ -0,0 +1,57 @@ +#ifndef MODINFOBACKUP_H +#define MODINFOBACKUP_H + +#include "modinforegular.h" + +class ModInfoBackup : public ModInfoRegular +{ + + Q_OBJECT + + friend class ModInfo; + +public: + virtual bool updateAvailable() const override { return false; } + virtual bool updateIgnored() const override { return false; } + virtual bool downgradeAvailable() const override { return false; } + virtual bool updateNXMInfo() override { return false; } + virtual void setGameName(const QString& gameName) override {} + virtual void setNexusID(int) override {} + virtual void endorse(bool) override {} + virtual void ignoreUpdate(bool) override {} + virtual bool alwaysDisabled() const override { return true; } + virtual bool canBeUpdated() const override { return false; } + virtual QDateTime getExpires() const override { return QDateTime(); } + virtual bool canBeEnabled() const override { return false; } + virtual std::vector getIniTweaks() const override + { + return std::vector(); + } + virtual std::vector getFlags() const override; + virtual QString getDescription() const override; + virtual int getNexusFileStatus() const override { return 0; } + virtual void setNexusFileStatus(int) override {} + virtual QDateTime getLastNexusQuery() const override { return QDateTime(); } + virtual void setLastNexusQuery(QDateTime) override {} + virtual QDateTime getLastNexusUpdate() const override { return QDateTime(); } + virtual void setLastNexusUpdate(QDateTime) override {} + virtual QDateTime getNexusLastModified() const override { return QDateTime(); } + virtual void setNexusLastModified(QDateTime) override {} + virtual QString getNexusDescription() const override { return QString(); } + virtual void setNexusCategory(int) override {} + virtual int getNexusCategory() const override { return 0; } + virtual QString author() const override { return QString(); } + virtual void setAuthor(const QString&) override {} + virtual QString uploader() const override { return QString(); } + virtual void setUploader(const QString&) override {} + virtual QString uploaderUrl() const override { return QString(); } + virtual void setUploaderUrl(const QString&) override {} + virtual bool isBackup() const override { return true; } + + virtual void addInstalledFile(int, int) override {} + +private: + ModInfoBackup(const QDir& path, OrganizerCore& core); +}; + +#endif // MODINFOBACKUP_H diff --git a/src/src/modinfodialog.cpp b/src/src/modinfodialog.cpp new file mode 100644 index 0000000..7ae3492 --- /dev/null +++ b/src/src/modinfodialog.cpp @@ -0,0 +1,826 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "modinfodialog.h" +#include "modinfodialogcategories.h" +#include "modinfodialogconflicts.h" +#include "modinfodialogesps.h" +#include "modinfodialogfiletree.h" +#include "modinfodialogimages.h" +#include "modinfodialognexus.h" +#include "modinfodialogtextfiles.h" +#include "modlistview.h" +#include "organizercore.h" +#include "plugincontainer.h" +#include "shared/directoryentry.h" +#include "shared/filesorigin.h" +#include "ui_modinfodialog.h" +#include + +using namespace MOBase; +using namespace MOShared; +namespace fs = std::filesystem; + +const int max_scan_for_context_menu = 50; + +bool canPreviewFile(const PluginContainer& pluginContainer, bool isArchive, + const QString& filename) +{ + const auto ext = QFileInfo(filename).suffix().toLower(); + return pluginContainer.previewGenerator().previewSupported(ext, isArchive); +} + +bool isExecutableFilename(const QString& filename) +{ + static const std::set exeExtensions = {"exe", "cmd", "bat"}; + + const auto ext = QFileInfo(filename).suffix().toLower(); + + return exeExtensions.contains(ext); +} + +bool canRunFile(bool isArchive, const QString& filename) +{ + // can run executables that are not archives + return !isArchive && isExecutableFilename(filename); +} + +bool canOpenFile(bool isArchive, const QString& filename) +{ + // can open non-executables that are not archives + return !isArchive && !isExecutableFilename(filename); +} + +bool canExploreFile(bool isArchive, const QString&) +{ + // can explore anything as long as it's not in an archive + return !isArchive; +} + +bool canHideFile(bool isArchive, const QString& filename) +{ + if (isArchive) { + // can't hide files from archives + return false; + } + + if (filename.endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + // already hidden + return false; + } + + return true; +} + +bool canUnhideFile(bool isArchive, const QString& filename) +{ + if (isArchive) { + // can't unhide files from archives + return false; + } + + if (!filename.endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + // already visible + return false; + } + + return true; +} + +FileRenamer::RenameResults hideFile(FileRenamer& renamer, const QString& oldName) +{ + const QString newName = oldName + ModInfo::s_HiddenExt; + return renamer.rename(oldName, newName); +} + +FileRenamer::RenameResults unhideFile(FileRenamer& renamer, const QString& oldName) +{ + QString newName = oldName.left(oldName.length() - ModInfo::s_HiddenExt.length()); + return renamer.rename(oldName, newName); +} + +FileRenamer::RenameResults restoreHiddenFilesRecursive(FileRenamer& renamer, + const QString& targetDir) +{ + FileRenamer::RenameResults results = FileRenamer::RESULT_OK; + QDir currentDir = targetDir; + for (QString hiddenFile : + currentDir.entryList((QStringList() << "*" + ModInfo::s_HiddenExt), + QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot)) { + + QString oldName = currentDir.absoluteFilePath(hiddenFile); + QString newName = oldName.left(oldName.length() - ModInfo::s_HiddenExt.length()); + + auto partialResult = renamer.rename(oldName, newName); + + if (partialResult == FileRenamer::RESULT_CANCEL) { + return FileRenamer::RESULT_CANCEL; + } + + if (partialResult == FileRenamer::RESULT_SKIP) { + results = FileRenamer::RESULT_SKIP; + } + } + + for (QString dirName : currentDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot)) { + + const QString dirPath = currentDir.absoluteFilePath(dirName); + // recurse on childrend directories + auto partialResult = restoreHiddenFilesRecursive(renamer, dirPath); + + if (partialResult == FileRenamer::RESULT_CANCEL) { + return FileRenamer::RESULT_CANCEL; + } + + if (partialResult == FileRenamer::RESULT_SKIP) { + results = FileRenamer::RESULT_SKIP; + } + } + return results; +} + +ModInfoDialog::TabInfo::TabInfo(std::unique_ptr tab) + : tab(std::move(tab)), realPos(-1), widget(nullptr) +{} + +bool ModInfoDialog::TabInfo::isVisible() const +{ + return (realPos != -1); +} + +ModInfoDialog::ModInfoDialog(OrganizerCore& core, PluginContainer& plugin, + ModInfo::Ptr mod, ModListView* modListView, + QWidget* parent) + : TutorableDialog("ModInfoDialog", parent), ui(new Ui::ModInfoDialog), m_core(core), + m_plugin(plugin), m_modListView(modListView), m_initialTab(ModInfoTabIDs::None), + m_arrangingTabs(false) +{ + ui->setupUi(this); + + { + auto* sc = new QShortcut(QKeySequence::Delete, this); + connect(sc, &QShortcut::activated, [&] { + onDeleteShortcut(); + }); + } + { + auto* sc = new QShortcut(QKeySequence::MoveToNextPage, this); + connect(sc, &QShortcut::activated, [&] { + onNextMod(); + }); + } + { + auto* sc = new QShortcut(QKeySequence::MoveToPreviousPage, this); + connect(sc, &QShortcut::activated, [&] { + onPreviousMod(); + }); + } + + setMod(mod); + createTabs(); + + connect(ui->tabWidget, &QTabWidget::currentChanged, [&] { + onTabSelectionChanged(); + }); + connect(ui->tabWidget->tabBar(), &QTabBar::tabMoved, [&] { + onTabMoved(); + }); + connect(ui->close, &QPushButton::clicked, [&] { + onCloseButton(); + }); + connect(ui->previousMod, &QPushButton::clicked, [&] { + onPreviousMod(); + }); + connect(ui->nextMod, &QPushButton::clicked, [&] { + onNextMod(); + }); +} + +ModInfoDialog::~ModInfoDialog() = default; + +template +std::unique_ptr createTab(ModInfoDialog& d, ModInfoTabIDs id) +{ + return std::make_unique(ModInfoDialogTabContext( + d.m_core, d.m_plugin, &d, d.ui.get(), id, d.m_mod, d.getOrigin())); +} + +void ModInfoDialog::createTabs() +{ + m_tabs.clear(); + + m_tabs.push_back(createTab(*this, ModInfoTabIDs::TextFiles)); + m_tabs.push_back(createTab(*this, ModInfoTabIDs::IniFiles)); + m_tabs.push_back(createTab(*this, ModInfoTabIDs::Images)); + m_tabs.push_back(createTab(*this, ModInfoTabIDs::Esps)); + m_tabs.push_back(createTab(*this, ModInfoTabIDs::Conflicts)); + m_tabs.push_back(createTab(*this, ModInfoTabIDs::Categories)); + m_tabs.push_back(createTab(*this, ModInfoTabIDs::Nexus)); + m_tabs.push_back(createTab(*this, ModInfoTabIDs::Notes)); + m_tabs.push_back(createTab(*this, ModInfoTabIDs::Filetree)); + + // check for tabs in the ui not having a corresponding tab in the list + int count = ui->tabWidget->count(); + if (count < 0 || count > static_cast(m_tabs.size())) { + log::error("mod info dialog has more tabs than expected"); + count = static_cast(m_tabs.size()); + } + + // for each tab in the widget; connects the widgets with the tab objects + // + for (int i = 0; i < count; ++i) { + auto& tabInfo = m_tabs[static_cast(i)]; + + // remembering tab information so tabs can be removed and re-added + tabInfo.widget = ui->tabWidget->widget(i); + tabInfo.caption = ui->tabWidget->tabText(i); + tabInfo.icon = ui->tabWidget->tabIcon(i); + + connect(tabInfo.tab.get(), &ModInfoDialogTab::originModified, [this](int originID) { + onOriginModified(originID); + }); + + connect(tabInfo.tab.get(), &ModInfoDialogTab::modOpen, [&](const QString& name) { + setMod(name); + update(); + }); + + connect(tabInfo.tab.get(), &ModInfoDialogTab::hasDataChanged, [&] { + setTabsColors(); + }); + + connect(tabInfo.tab.get(), &ModInfoDialogTab::wantsFocus, + [&, id = tabInfo.tab->tabID()] { + switchToTab(id); + }); + } +} + +int ModInfoDialog::exec() +{ + GeometrySaver gs(Settings::instance(), this); + restoreState(); + + // whether to select the first tab; if the main window requested a specific + // tab, it is selected when encountered in update() + const auto noCustomTabRequested = (m_initialTab == ModInfoTabIDs::None); + const auto requestedTab = m_initialTab; + + update(true); + + if (noCustomTabRequested) { + m_core.settings().widgets().restoreIndex(ui->tabWidget); + } + + const int r = TutorableDialog::exec(); + saveState(); + + return r; +} + +void ModInfoDialog::setMod(ModInfo::Ptr mod) +{ + Q_ASSERT(mod); + m_mod = mod; + + // resetting the first activation flag so selecting tabs will trigger it + // again + for (auto& tabInfo : m_tabs) { + tabInfo.tab->resetFirstActivation(); + } +} + +void ModInfoDialog::setMod(const QString& name) +{ + unsigned int index = ModInfo::getIndex(name); + if (index == UINT_MAX) { + log::error("failed to resolve mod name {}", name); + return; + } + + auto mod = ModInfo::getByIndex(index); + if (!mod) { + log::error("mod by index {} is null", index); + return; + } + + setMod(mod); +} + +void ModInfoDialog::selectTab(ModInfoTabIDs id) +{ + if (!isVisible()) { + // can't select a tab if the dialog hasn't been properly updated yet + m_initialTab = id; + return; + } + + switchToTab(id); +} + +ModInfoDialog::TabInfo* ModInfoDialog::currentTab() +{ + const auto index = ui->tabWidget->currentIndex(); + if (index < 0) { + return nullptr; + } + + // looking for the actual tab at that position + for (auto& tabInfo : m_tabs) { + if (tabInfo.realPos == index) { + return &tabInfo; + } + } + + return nullptr; +} + +void ModInfoDialog::update(bool firstTime) +{ + // remembering the current selection, will be restored if the tab still + // exists + const int oldTab = ui->tabWidget->currentIndex(); + + setWindowTitle(m_mod->name()); + + // rebuilding the tab widget if needed depending on what tabs are valid for + // the current mod + setTabsVisibility(firstTime); + + // updating the data in all tabs + updateTabs(); + + // switching to the initial tab, if any + if (m_initialTab != ModInfoTabIDs::None) { + switchToTab(m_initialTab); + m_initialTab = ModInfoTabIDs::None; + } + + if (ui->tabWidget->currentIndex() == oldTab) { + if (auto* tabInfo = currentTab()) { + // activated() has to be fired manually because the tab index hasn't been + // changed + tabInfo->tab->activated(); + } else { + log::error("tab index {} not found", oldTab); + } + } +} + +void ModInfoDialog::setTabsVisibility(bool firstTime) +{ + // this flag is picked up by onTabSelectionChanged() to avoid triggering + // activation events while moving tabs around + QScopedValueRollback arrangingTabs(m_arrangingTabs, true); + + // one bool per tab to indicate whether the tab should be visible + std::vector visibility(m_tabs.size()); + + bool changed = false; + + for (std::size_t i = 0; i < m_tabs.size(); ++i) { + const auto& tabInfo = m_tabs[i]; + + bool visible = true; + + // a tab is visible if it can handle the current mod + if (m_mod->hasFlag(ModInfo::FLAG_FOREIGN)) { + visible = tabInfo.tab->canHandleUnmanaged(); + } else if (m_mod->hasFlag(ModInfo::FLAG_SEPARATOR)) { + visible = tabInfo.tab->canHandleSeparators(); + } + + // if the visibility of this tab is changing, set changed to true because + // the tabs have to be rebuilt + const auto currentlyVisible = (ui->tabWidget->indexOf(tabInfo.widget) != -1); + if (visible != currentlyVisible) { + changed = true; + } + + visibility[i] = visible; + } + + // the tabs have to be rebuilt the first time the dialog is shown, or when + // the visibility of any tab has changed + if (!firstTime && !changed) { + return; + } + + // save the current order (if necessary) because some tabs will be removed and + // others added + if (!firstTime) { + // but don't do it the first time visibility is set because the tabs are + // in the default order, which will clobber the current settings + saveTabOrder(); + } + + // remember selection, if any + auto sel = ModInfoTabIDs::None; + if (const auto* tabInfo = currentTab()) { + sel = tabInfo->tab->tabID(); + } + + // removes all tabs and re-adds the visible ones + reAddTabs(visibility, sel); +} + +void ModInfoDialog::reAddTabs(const std::vector& visibility, ModInfoTabIDs sel) +{ + Q_ASSERT(visibility.size() == m_tabs.size()); + + // ordered tab names from settings + const auto orderedNames = m_core.settings().geometry().modInfoTabOrder(); + + // whether the tabs can be sorted + // + // if the object name of a tab widget is not found in orderedNames, the list + // cannot be sorted safely; if the list is empty, it's probably a first run + // and there's nothing to sort + bool canSort = !orderedNames.empty(); + + // gathering visible tabs + std::vector visibleTabs; + for (std::size_t i = 0; i < m_tabs.size(); ++i) { + if (!visibility[i]) { + // this tab is not visible, skip it + continue; + } + + // this tab is visible + visibleTabs.push_back(&m_tabs[i]); + + if (canSort) { + // make sure the widget object name is found in the list + const auto objectName = m_tabs[i].widget->objectName(); + auto itor = std::find(orderedNames.begin(), orderedNames.end(), objectName); + + if (itor == orderedNames.end()) { + // this shouldn't happen, it means there's a tab in the UI that's no + // in the list + log::error("can't sort tabs, '{}' not found", objectName); + canSort = false; + } + } + } + + // sorting tabs + if (canSort) { + std::sort(visibleTabs.begin(), visibleTabs.end(), [&](auto&& a, auto&& b) { + // looking the names in the ordered list + auto aItor = + std::find(orderedNames.begin(), orderedNames.end(), a->widget->objectName()); + + auto bItor = + std::find(orderedNames.begin(), orderedNames.end(), b->widget->objectName()); + + // this shouldn't happen, it was checked above + Q_ASSERT(aItor != orderedNames.end() && bItor != orderedNames.end()); + + return (aItor < bItor); + }); + } + + // removing all tabs + ui->tabWidget->clear(); + + // reset real positions + for (auto& tabInfo : m_tabs) { + tabInfo.realPos = -1; + } + + // add visible tabs + for (std::size_t i = 0; i < visibleTabs.size(); ++i) { + auto& tabInfo = *visibleTabs[i]; + + // remembering real position + tabInfo.realPos = static_cast(i); + + // adding tab + ui->tabWidget->addTab(tabInfo.widget, tabInfo.icon, tabInfo.caption); + + // selecting + if (tabInfo.tab->tabID() == sel) { + ui->tabWidget->setCurrentIndex(static_cast(i)); + } + } +} + +void ModInfoDialog::updateTabs(bool becauseOriginChanged) +{ + auto* origin = getOrigin(); + + // list of tabs that should be updated + std::vector interestedTabs; + + for (auto& tabInfo : m_tabs) { + // don't touch invisible tabs + if (!tabInfo.isVisible()) { + continue; + } + + // updateTabs() is also called from onOriginModified() to update all the + // tabs that depend on the origin; if updateTabs() is called because the + // origin changed, but the tab doesn't use origin files, it can be safely + // skipped + // + // this happens for tabs like notes and categories, which don't need to + // be updated when files change + if (becauseOriginChanged && !tabInfo.tab->usesOriginFiles()) { + continue; + } + + // this tab should be updated + interestedTabs.push_back(&tabInfo); + } + + for (auto* tabInfo : interestedTabs) { + // set the current mod + tabInfo->tab->setMod(m_mod, origin); + + // clear + tabInfo->tab->clear(); + } + + // feed all the files from the filesystem + feedFiles(interestedTabs); + + // call update() on all tabs + for (auto* tabInfo : interestedTabs) { + tabInfo->tab->update(); + } + + // update the text colours + setTabsColors(); +} + +void ModInfoDialog::feedFiles(std::vector& interestedTabs) +{ + const auto rootPath = + m_mod->absolutePath() + (m_core.managedGame()->modDataDirectory().isEmpty() + ? "" + : "/" + m_core.managedGame()->modDataDirectory()); + if (rootPath.isEmpty()) { + return; + } + + if (fs::exists(rootPath.toStdWString())) { + const fs::path fsPath(rootPath.toStdWString()); + + for (const auto& entry : fs::recursive_directory_iterator(fsPath)) { + if (!entry.is_regular_file()) { + // skip directories + continue; + } + +#ifdef _WIN32 + const auto filePath = QString::fromStdWString(entry.path().native()); +#else + const auto filePath = QString::fromStdString(entry.path().native()); +#endif + + // for each tab + for (auto* tabInfo : interestedTabs) { + if (tabInfo->tab->feedFile(rootPath, filePath)) { + break; + } + } + } + } +} + +void ModInfoDialog::setTabsColors() +{ + const auto p = m_modListView->parentWidget()->palette(); + + for (const auto& tabInfo : m_tabs) { + if (!tabInfo.isVisible()) { + // don't bother with invisible tabs + continue; + } + + const QColor color = tabInfo.tab->hasData() + ? QColor::Invalid + : p.color(QPalette::Disabled, QPalette::WindowText); + + ui->tabWidget->tabBar()->setTabTextColor(tabInfo.realPos, color); + } +} + +void ModInfoDialog::switchToTab(ModInfoTabIDs id) +{ + // look a tab with the given id + for (const auto& tabInfo : m_tabs) { + if (tabInfo.tab->tabID() == id) { + // use realPos to select the proper tab in the widget + ui->tabWidget->setCurrentIndex(tabInfo.realPos); + return; + } + } + + // this could happen if the tab is not visible right now + log::debug("can't switch to tab ID {}, not available", static_cast(id)); +} + +MOShared::FilesOrigin* ModInfoDialog::getOrigin() +{ + auto* ds = m_core.directoryStructure(); + + if (!ds->originExists(m_mod->name().toStdWString())) { + return nullptr; + } + + auto* origin = &ds->getOriginByName(m_mod->name().toStdWString()); + if (origin->isDisabled()) { + return nullptr; + } + + return origin; +} + +void ModInfoDialog::saveState() const +{ + saveTabOrder(); + + // save state for each tab + for (const auto& tabInfo : m_tabs) { + tabInfo.tab->saveState(m_core.settings()); + } +} + +void ModInfoDialog::restoreState() +{ + // tab order is not restored here, it will be picked up if tabs have to be + // removed and re-added + + // restore state for each tab + for (const auto& tabInfo : m_tabs) { + tabInfo.tab->restoreState(m_core.settings()); + } +} + +void ModInfoDialog::saveTabOrder() const +{ + if (static_cast(m_tabs.size()) != ui->tabWidget->count()) { + // only save tab state when all tabs are visible + // + // if not all tabs are visible, it becomes very difficult to figure out in + // what order the user wants these tabs to be, so just avoid saving it + // completely + // + // this means that reordering tabs when not all tabs are visible is not + // saved, but it's better than breaking everything + return; + } + + QString names; + + for (int i = 0; i < ui->tabWidget->count(); ++i) { + if (!names.isEmpty()) { + names += " "; + } + + names += ui->tabWidget->widget(i)->objectName(); + } + + m_core.settings().geometry().setModInfoTabOrder(names); + // save last opened index + m_core.settings().widgets().saveIndex(ui->tabWidget); +} + +void ModInfoDialog::onOriginModified(int originID) +{ + // tell the main window the origin changed + emit originModified(originID); + + // update tabs that depend on the origin + updateTabs(true); +} + +void ModInfoDialog::onDeleteShortcut() +{ + // forward the request to the current tab + if (auto* tabInfo = currentTab()) { + tabInfo->tab->deleteRequested(); + } +} + +void ModInfoDialog::closeEvent(QCloseEvent* e) +{ + if (tryClose()) { + e->accept(); + } else { + e->ignore(); + } +} + +void ModInfoDialog::onCloseButton() +{ + if (tryClose()) { + close(); + } +} + +bool ModInfoDialog::tryClose() +{ + // cancel the close if any tab returns false; for example. this can happen if + // a tab has unsaved content, pops a confirmation dialog, and the user clicks + // cancel + + for (auto& tabInfo : m_tabs) { + if (!tabInfo.tab->canClose()) { + return false; + } + } + + return true; +} + +void ModInfoDialog::onTabSelectionChanged() +{ + if (m_arrangingTabs) { + // this can be fired while re-arranging tabs, which happens before mods + // are given to tabs, and might trigger first activation, which breaks all + // sorts of things + return; + } + + // this will call firstActivation() on the tab if needed + if (auto* tabInfo = currentTab()) { + tabInfo->tab->activated(); + } +} + +void ModInfoDialog::onTabMoved() +{ + // reset + for (auto& tabInfo : m_tabs) { + tabInfo.realPos = -1; + } + + // for each tab in the widget + for (int i = 0; i < ui->tabWidget->count(); ++i) { + const auto* w = ui->tabWidget->widget(i); + + bool found = false; + + // find the corresponding tab info + for (auto& tabInfo : m_tabs) { + if (tabInfo.widget == w) { + tabInfo.realPos = i; + found = true; + break; + } + } + + if (!found) { + log::error("unknown tab at index {}", i); + } + } +} + +void ModInfoDialog::onNextMod() +{ + auto index = m_modListView->nextMod(ModInfo::getIndex(m_mod->name())); + if (!index) { + return; + } + auto mod = ModInfo::getByIndex(*index); + if (!mod || mod == m_mod) { + return; + } + + setMod(mod); + update(); + + emit modChanged(*index); +} + +void ModInfoDialog::onPreviousMod() +{ + auto index = m_modListView->prevMod(ModInfo::getIndex(m_mod->name())); + if (!index) { + return; + } + auto mod = ModInfo::getByIndex(*index); + + setMod(mod); + update(); + + emit modChanged(*index); +} diff --git a/src/src/modinfodialog.h b/src/src/modinfodialog.h new file mode 100644 index 0000000..b322079 --- /dev/null +++ b/src/src/modinfodialog.h @@ -0,0 +1,254 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef MODINFODIALOG_H +#define MODINFODIALOG_H + +#include "filerenamer.h" +#include "modinfo.h" +#include "modinfodialogfwd.h" +#include "tutorabledialog.h" + +namespace Ui +{ +class ModInfoDialog; +} +namespace MOShared +{ +class FilesOrigin; +} + +class PluginContainer; +class OrganizerCore; +class Settings; +class ModInfoDialogTab; +class ModListView; + +/** + * this is a larger dialog used to visualise information about the mod. + * @todo this would probably a good place for a plugin-system + **/ +class ModInfoDialog : public MOBase::TutorableDialog +{ + Q_OBJECT; + + // creates a tab, it's a friend because it uses a bunch of member variables + // to create ModInfoDialogTabContext + // + template + friend std::unique_ptr createTab(ModInfoDialog& d, + ModInfoTabIDs index); + +public: + ModInfoDialog(OrganizerCore& core, PluginContainer& plugin, ModInfo::Ptr mod, + ModListView* view, QWidget* parent = nullptr); + + ~ModInfoDialog(); + + // switches to the tab with the given id + // + void selectTab(ModInfoTabIDs id); + + // updates all tabs, selects the initial tab, opens the dialog and + // saves/restores geometry + // + int exec() override; + +signals: + // emitted when a tab changes the origin + // + void originModified(int originID); + + // emitted when the mod of the dialog is changed + // + void modChanged(unsigned int modIndex); + +protected: + // forwards to tryClose() + // + void closeEvent(QCloseEvent* e); + +private: + // represents a single tab + // + struct TabInfo + { + // tab implementation + std::unique_ptr tab; + + // actual position in the tab bar, updated every time a tab is moved + int realPos; + + // widget used by the QTabWidget for this tab + // + // because QTabWidget doesn't support simply hiding tabs, they have to be + // completely removed from the widget when they don't support the current + // mod + // + // therefore, `widget, `caption` and `icon` are remembered so tabs can be + // removed and re-added when navigating between mods + // + // `widget` is also used figure out which tab is where when they're + // re-ordered + QWidget* widget; + + // caption for this tab, see `widget` + QString caption; + + // icon for this tab, see `widget` + QIcon icon; + + TabInfo(std::unique_ptr tab); + + // returns whether this tab is part of the tab widget + // + bool isVisible() const; + }; + + std::unique_ptr ui; + OrganizerCore& m_core; + PluginContainer& m_plugin; + ModListView* m_modListView; + ModInfo::Ptr m_mod; + std::vector m_tabs; + + // initial tab requested by the main window when the dialog is opened; whether + // the request can be honoured depends on what tabs are present + ModInfoTabIDs m_initialTab; + + // set to true when tabs are being removed and re-added while navigating + // between mods; since the current index changes while this is happening, + // onTabSelectionChanged() will be called repeatedly + // + // however, it will check this flag and ignore the event so first activations + // are not fired incorrectly + bool m_arrangingTabs; + + // creates all the tabs and connects events + // + void createTabs(); + + // saves the dialog state and calls saveState() on all tabs + // + void saveState() const; + + // restores the dialog state and calls restoreState() on all tabs + // + void restoreState(); + + // sets the currently selected mod; resets first activation, but doesn't + // update anything + // + void setMod(ModInfo::Ptr mod); + + // sets the currently selected mod, if found; forwards to setMod() above + // + void setMod(const QString& name); + + // returns the origin of the current mod, may be null + // + MOShared::FilesOrigin* getOrigin(); + + // returns the currently selected tab, taking re-ordering in to account; + // shouldn't be null, but could be + // + TabInfo* currentTab(); + + // fully updates the dialog; sets the title, the tab visibility and updates + // all the tabs; used when the current mod changes + // + // see setTabsVisibility() for firstTime + // + void update(bool firstTime = false); + + // builds the list of visible tabs; if the list is different from what's + // currently displayed, or firstTime is true, forwards to reAddTabs() + void setTabsVisibility(bool firstTime); + + // clears the tab widgets and re-adds the tabs having the visible flag in + // the given vector, following the tab order from the settings + // + void reAddTabs(const std::vector& visibility, ModInfoTabIDs sel); + + // called by update(); clears tabs, feeds files and calls update() on all + // tabs, then setTabsColors() + // + void updateTabs(bool becauseOriginChanged = false); + + // goes through all files on the filesystem for the current mod and calls + // feedFile() on every tab until one accepts it + // + void feedFiles(std::vector& interestedTabs); + + // goes through all tabs and sets the tab text colour depending on whether + // they have data or not + // + void setTabsColors(); + + // called when the delete key is pressed anywhere in the dialog; forwards to + // ModInfoDialogTab::deleteRequest() for the currently selected tab + // + void onDeleteShortcut(); + + // finds the tab with the given id and selects it + // + void switchToTab(ModInfoTabIDs id); + + // saves the current tab order; used by saveState(), but also by + // setTabsVisibility() to make sure any changes to order are saved before + // re-adding tabs + // + void saveTabOrder() const; + + // asks all the tabs if they accept closing the dialog, returns false if one + // objected + // + bool tryClose(); + + // called when the user clicks the close button; closing the dialog by other + // means ends up in closeEvent(); forwards to tryClose() + // + void onCloseButton(); + + // called when the user clicks the previous button; asks the main window for + // the previous mod in the list and loads it + // + void onPreviousMod(); + + // called when the user clicks the next button; asks the main window for the + // next mod in the list and loads it + // + void onNextMod(); + + // called when the selects a tab; handles first activation + // + void onTabSelectionChanged(); + + // called when the user re-orders tabs; sets the correct TabInfo::realPos for + // all tabs + // + void onTabMoved(); + + // called when a tab has modified the origin; emits originModified() and + // updates all the tabs that use origin files + // + void onOriginModified(int originID); +}; + +#endif // MODINFODIALOG_H diff --git a/src/src/modinfodialog.ui b/src/src/modinfodialog.ui new file mode 100644 index 0000000..e782dca --- /dev/null +++ b/src/src/modinfodialog.ui @@ -0,0 +1,1452 @@ + + + ModInfoDialog + + + + 0 + 0 + 735 + 534 + + + + Mod Info + + + + + + QTabWidget::Rounded + + + 0 + + + true + + + + Text Files + + + + + + Qt::Horizontal + + + false + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Text Files + + + + + + + A list of text-files in the mod directory. + + + A list of text-files in the mod directory like readmes. + + + true + + + true + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + INI Files + + + + + + Qt::Horizontal + + + + + 6 + + + + + Ini Files + + + + + + + This is a list of .ini files in the mod. + + + This is a list of .ini files in the mod. These are usually used to configure the behaviour of mods if there are configurable parameters. + + + true + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Images + + + + + + Qt::Horizontal + + + false + + + + + 6 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::StrongFocus + + + + + + + Qt::Vertical + + + + + + + + + + Show .dds files + + + + + + + + + + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Open with Preview Plugin + + + + + + + Open in Explorer + + + + + + + true + + + + + + + 0x0 + + + + + + + + + + + + + + + + + + Optional Plugins + + + + + + Qt::Horizontal + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Optional Plugins + + + + + + + List of esps, esms, and esls that will not be loaded by the game. + + + List of esps, esms, and esls contained in this mod that currently will not be loaded by the game. They will not even appear in the plugin list in the main MO-window. +They usually contain optional functionality, see the readme. + +Most mods do not have optional plugins, so chances are good you are looking at an empty list. + + + true + + + true + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Move a plugin to the data directory. + + + This moves a plugin to the data directory so it can be enabled in the main window. Please note that the plugin merely becomes "available", it will not necessarily be loaded! That is configured in the main window of MO. + + + + + + + :/MO/gui/next:/MO/gui/next + + + + 22 + 22 + + + + + + + + Make the selected plugin in the right list unavailable. + + + The selected plugin (in the right list) will be pushed into a subdirectory of the mod and will thus become "invisible" to the game. It can then no longer be activated. + + + + + + + :/MO/gui/previous:/MO/gui/previous + + + + 22 + 22 + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Available Plugins + + + + + + + Plugins in the data directory and thus visible to the game. + + + <html><head/><body><p>These are the plugins that are in the (virtual) data directory of your game and will thus be selectable in the plugin list in the main window.</p></body></html> + + + true + + + true + + + + + + + + + + + + + + + Conflicts + + + + + + 0 + + + + General + + + + + + + 0 + 100 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + 0 + 0 + + + + Files that exist in other mods but are overwritten by this mod + + + border: none; +text-align: left; + + + Winning file conflicts: + + + + + + + + + + QFrame::Sunken + + + 1 + + + QLCDNumber::Flat + + + + + + + + + + + Qt::CustomContextMenu + + + Files that exist in other mods but are overwritten by this mod + + + true + + + QAbstractItemView::ExtendedSelection + + + Qt::ElideLeft + + + true + + + true + + + + + + + + + + + + 0 + 0 + + + + Files that are unused because they are overwritten by other mods + + + border: none; +text-align: left; + + + Losing file conflicts: + + + + + + + + + + QFrame::Sunken + + + QLCDNumber::Flat + + + + + + + + + + + Qt::CustomContextMenu + + + Files that are unused because they are overwritten by other mods + + + true + + + QAbstractItemView::ExtendedSelection + + + Qt::ElideLeft + + + true + + + true + + + + + + + + + + + + 0 + 0 + + + + Files that have no conflicts + + + border: none; +text-align: left; + + + The following files have no conflicts + + + + + + + + + + QFrame::Sunken + + + QLCDNumber::Flat + + + + + + + + + + + Qt::CustomContextMenu + + + Files that have no conflicts + + + true + + + QAbstractItemView::ExtendedSelection + + + Qt::ElideLeft + + + true + + + true + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + + + + Advanced + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::CustomContextMenu + + + true + + + QAbstractItemView::ExtendedSelection + + + true + + + true + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Whether files that have no conflicts should be visible in the list + + + Show files that have no conflicts + + + true + + + + + + + Shows all mods overwriting or being overwritten by this mod + + + Show all conflicting mods + + + true + + + + + + + Shows only the nearest conflicting mods, in order of priority + + + Show nearest conflicting mod + + + + + + + + 20 + + + 0 + + + 0 + + + 0 + + + + + Filter + + + + + + + + + + + + + + + + + + + + + Categories + + + + + + true + + + true + + + false + + + + 1 + + + + + + + + + + Primary Category + + + + + + + + + + + + + + :/MO/gui/resources/internet-web-browser.png:/MO/gui/resources/internet-web-browser.png + + + Nexus Info + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + Mod ID + + + + + + + Mod ID for this mod on Nexus. + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Mod ID for this mod on Nexus. This is filled in automatically if you downloaded and installed the mod from inside MO. Otherwise you can enter it manually. To find the correct id, find the mod on nexus. The URL will look like this: <a href=" https://www.nexusmods.com/skyrimspecialedition/mods/6194"><span style=" text-decoration: underline; color:#0000ff;">https://www.nexusmods.com/skyrimspecialedition/mods/6194</span></a>. In this example, 6194 is the id you're looking for. Besides: The above is the link to Mod Organizer 2 on Nexus. Why not go there now and endorse us?</p></body></html> + + + + + + + Source Game + + + + + + + Source game for this mod. + + + <html><head/><body><p>Source game for this mod. This determines where the mod was downloaded from and decides where to fetch info, version updates, and send endorsements. Changing this will likely require you to enter a new Mod ID.</p></body></html> + + + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Installed Version of the Mod. The tooltip will contain the current version available on nexus. The installed version is only set if you installed the mod through MO.</span></p></body></html> + + + Version + + + + + + + 32 + + + + + + + Category + + + + + + + + 1 + 0 + + + + + 0 + 0 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Refresh + + + Refresh all information from Nexus. + + + Refresh + + + + :/MO/gui/refresh:/MO/gui/refresh + + + + + + + Open in Browser + + + + :/MO/gui/resources/internet-web-browser.png:/MO/gui/resources/internet-web-browser.png + + + + + + + Endorse + + + + :/MO/gui/icon_favorite:/MO/gui/icon_favorite + + + + + + + Track + + + + :/MO/gui/tracked:/MO/gui/tracked + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + QFrame::StyledPanel + + + QFrame::Sunken + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 0 + 200 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Use Custom URL + + + + + + + + + + Open in Browser + + + + + + + + + + + Notes + + + + + + + + Enter comments about the mod here. These are displayed in the notes column of the mod list. + + + Enter comments about the mod here. These are displayed in the notes column of the mod list. + + + Enter comments about the mod here. These are displayed in the notes column of the mod list. + + + + + + + Set Color + + + + + + + Reset Color + + + + + + + + + Enter notes about the mod here. These can be viewed in the mod list by hovering over the notes column or the flags column. + + + Enter notes about the mod here. These can be viewed in the mod list by hovering over the notes column or the flags column. + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextEditable|Qt::TextEditorInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + Enter notes about the mod here. These can be viewed in the mod list by hovering over the notes column or the flags column. + + + + + + + + Filetree + + + + + + + + + 0 + 0 + + + + Open Mod in Explorer + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::CustomContextMenu + + + A directory view of this mod + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">This is a modifiable directory view of the mod directory. You can move around files using drag &amp; drop and rename them (double click).</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Changes happen immediately on disc, so do</span><span style=" font-size:8pt; font-weight:600;"> be careful</span><span style=" font-size:8pt;">.</span></p></body></html> + + + QAbstractItemView::InternalMove + + + Qt::MoveAction + + + true + + + QAbstractItemView::ExtendedSelection + + + true + + + true + + + + + + + + + + + + + Previous + + + false + + + + + + + Next + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Close + + + false + + + + + + + + + + ModIDLineEdit + QLineEdit +
    modidlineedit.h
    +
    + + TextEditor + QPlainTextEdit +
    texteditor.h
    +
    + + LCDNumber + QLCDNumber +
    lcdnumber.h
    +
    + + HTMLEditor + QTextEdit +
    texteditor.h
    +
    + + ImagesTabHelpers::ThumbnailsWidget + QWidget +
    modinfodialogimages.h
    + 1 +
    + + ImagesTabHelpers::Scrollbar + QScrollBar +
    modinfodialogimages.h
    +
    +
    + + + + +
    diff --git a/src/src/modinfodialogcategories.cpp b/src/src/modinfodialogcategories.cpp new file mode 100644 index 0000000..24cd815 --- /dev/null +++ b/src/src/modinfodialogcategories.cpp @@ -0,0 +1,138 @@ +#include "modinfodialogcategories.h" +#include "categories.h" +#include "modinfo.h" +#include "ui_modinfodialog.h" + +CategoriesTab::CategoriesTab(ModInfoDialogTabContext cx) + : ModInfoDialogTab(std::move(cx)) +{ + connect(ui->categories, &QTreeWidget::itemChanged, [&](auto* item, int col) { + onCategoryChanged(item, col); + }); + + connect(ui->primaryCategories, + static_cast(&QComboBox::currentIndexChanged), + [&](int index) { + onPrimaryChanged(index); + }); +} + +void CategoriesTab::clear() +{ + ui->categories->clear(); + ui->primaryCategories->clear(); + setHasData(false); +} + +void CategoriesTab::update() +{ + clear(); + + add(CategoryFactory::instance(), mod().getCategories(), + ui->categories->invisibleRootItem(), 0); + + updatePrimary(); +} + +bool CategoriesTab::canHandleSeparators() const +{ + return true; +} + +bool CategoriesTab::usesOriginFiles() const +{ + return false; +} + +void CategoriesTab::add(const CategoryFactory& factory, + const std::set& enabledCategories, QTreeWidgetItem* root, + int rootLevel) +{ + for (int i = 0; i < static_cast(factory.numCategories()); ++i) { + if (factory.getParentID(i) != rootLevel) { + continue; + } + + int categoryID = factory.getCategoryID(i); + + QTreeWidgetItem* newItem = + new QTreeWidgetItem(QStringList(factory.getCategoryName(i))); + + newItem->setFlags(newItem->flags() | Qt::ItemIsUserCheckable); + + newItem->setCheckState(0, + enabledCategories.find(categoryID) != enabledCategories.end() + ? Qt::Checked + : Qt::Unchecked); + + newItem->setData(0, Qt::UserRole, categoryID); + + if (factory.hasChildren(i)) { + add(factory, enabledCategories, newItem, categoryID); + } + + root->addChild(newItem); + } +} + +void CategoriesTab::updatePrimary() +{ + ui->primaryCategories->clear(); + + int primaryCategory = mod().primaryCategory(); + + addChecked(ui->categories->invisibleRootItem()); + + for (int i = 0; i < ui->primaryCategories->count(); ++i) { + if (ui->primaryCategories->itemData(i).toInt() == primaryCategory) { + ui->primaryCategories->setCurrentIndex(i); + break; + } + } + + setHasData(ui->primaryCategories->count() > 0); +} + +void CategoriesTab::addChecked(QTreeWidgetItem* tree) +{ + for (int i = 0; i < tree->childCount(); ++i) { + QTreeWidgetItem* child = tree->child(i); + if (child->checkState(0) == Qt::Checked) { + ui->primaryCategories->addItem(child->text(0), child->data(0, Qt::UserRole)); + addChecked(child); + } + } +} + +void CategoriesTab::save(QTreeWidgetItem* currentNode) +{ + for (int i = 0; i < currentNode->childCount(); ++i) { + QTreeWidgetItem* childNode = currentNode->child(i); + + mod().setCategory(childNode->data(0, Qt::UserRole).toInt(), + childNode->checkState(0)); + + save(childNode); + } +} + +void CategoriesTab::onCategoryChanged(QTreeWidgetItem* item, int) +{ + QTreeWidgetItem* parent = item->parent(); + + while ((parent != nullptr) && ((parent->flags() & Qt::ItemIsUserCheckable) != 0) && + (parent->checkState(0) == Qt::Unchecked)) { + parent->setCheckState(0, Qt::Checked); + parent = parent->parent(); + } + + updatePrimary(); + save(ui->categories->invisibleRootItem()); +} + +void CategoriesTab::onPrimaryChanged(int index) +{ + if (index != -1) { + mod().setPrimaryCategory(ui->primaryCategories->itemData(index).toInt()); + } +} diff --git a/src/src/modinfodialogcategories.h b/src/src/modinfodialogcategories.h new file mode 100644 index 0000000..a2d524e --- /dev/null +++ b/src/src/modinfodialogcategories.h @@ -0,0 +1,26 @@ +#include "modinfodialogtab.h" + +class CategoryFactory; + +class CategoriesTab : public ModInfoDialogTab +{ +public: + CategoriesTab(ModInfoDialogTabContext cx); + + void clear() override; + void update() override; + bool canHandleSeparators() const override; + bool usesOriginFiles() const override; + +private: + void add(const CategoryFactory& factory, const std::set& enabledCategories, + QTreeWidgetItem* root, int rootLevel); + + void updatePrimary(); + void addChecked(QTreeWidgetItem* tree); + + void save(QTreeWidgetItem* currentNode); + + void onCategoryChanged(QTreeWidgetItem* item, int col); + void onPrimaryChanged(int index); +}; diff --git a/src/src/modinfodialogconflicts.cpp b/src/src/modinfodialogconflicts.cpp new file mode 100644 index 0000000..27f85b4 --- /dev/null +++ b/src/src/modinfodialogconflicts.cpp @@ -0,0 +1,1130 @@ +#include "modinfodialogconflicts.h" +#include "modinfodialog.h" +#include "modinfodialogconflictsmodels.h" +#include "organizercore.h" +#include "settings.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "ui_modinfodialog.h" +#include "utility.h" + +using namespace MOShared; +using namespace MOBase; +namespace fs = std::filesystem; + +// if there are more than 50 selected items in the conflict tree, don't bother +// checking whether menu items apply to them, just show all of them +const std::size_t max_small_selection = 50; + +std::size_t smallSelectionSize(const QTreeView* tree) +{ + const std::size_t too_many = std::numeric_limits::max(); + + std::size_t n = 0; + const auto* sel = tree->selectionModel(); + + for (const auto& range : sel->selection()) { + n += range.height(); + + if (n >= max_small_selection) { + return too_many; + } + } + + return n; +} + +template +void forEachInSelection(QTreeView* tree, F&& f) +{ + const auto* sel = tree->selectionModel(); + + const auto* proxy = dynamic_cast(tree->model()); + + if (!proxy) { + log::error("tree doesn't have a SortProxyModel"); + return; + } + + const auto* model = dynamic_cast(proxy->sourceModel()); + + if (!model) { + log::error("tree doesn't have a ConflictListModel"); + return; + } + + for (const auto& rowIndex : sel->selectedRows()) { + auto modelRow = proxy->mapToSource(rowIndex).row(); + if (auto* item = model->getItem(static_cast(modelRow))) { + if (!f(item)) { + return; + } + } + } +} + +ConflictsTab::ConflictsTab(ModInfoDialogTabContext cx) + : ModInfoDialogTab(cx), // don't move, cx is used again + m_general(this, cx.ui, cx.core), m_advanced(this, cx.ui, cx.core) +{ + connect(&m_general, &GeneralConflictsTab::modOpen, [&](const QString& name) { + emitModOpen(name); + }); + + connect(&m_advanced, &AdvancedConflictsTab::modOpen, [&](const QString& name) { + emitModOpen(name); + }); +} + +void ConflictsTab::update() +{ + setHasData(m_general.update()); + m_advanced.update(); +} + +void ConflictsTab::clear() +{ + m_general.clear(); + m_advanced.clear(); + setHasData(false); +} + +void ConflictsTab::saveState(Settings& s) +{ + s.widgets().saveIndex(ui->tabConflictsTabs); + + m_general.saveState(s); + m_advanced.saveState(s); +} + +void ConflictsTab::restoreState(const Settings& s) +{ + s.widgets().restoreIndex(ui->tabConflictsTabs, 0); + + m_general.restoreState(s); + m_advanced.restoreState(s); +} + +bool ConflictsTab::canHandleUnmanaged() const +{ + return true; +} + +void ConflictsTab::hideItems(QTreeView* tree) +{ + bool changed = false; + bool stop = false; + + const auto n = smallSelectionSize(tree); + + // logging + { + QString files; + if (n > max_small_selection) + files = "a lot of"; + else + files = QString("%1").arg(n); + + log::debug("hiding {} conflict files", files); + } + + QFlags flags = FileRenamer::HIDE; + + if (n > 1) { + flags |= FileRenamer::MULTIPLE; + } + + FileRenamer renamer(parentWidget(), flags); + + const auto* proxy = dynamic_cast(tree->model()); + + if (!proxy) { + log::error("tree doesn't have a SortProxyModel"); + return; + } + + const auto* model = dynamic_cast(proxy->sourceModel()); + + if (!model) { + log::error("tree doesn't have a ConflictListModel"); + return; + } + + forEachInSelection(tree, [&](const ConflictItem* item) { + if (stop) { + return false; + } + + if (!item->canHide()) { + log::debug("cannot hide {}, skipping", item->relativeName()); + return true; + } + + auto result = hideFile(renamer, item->fileName()); + + switch (result) { + case FileRenamer::RESULT_OK: { + // will trigger a refresh at the end + changed = true; + break; + } + + case FileRenamer::RESULT_SKIP: { + // nop + break; + } + + case FileRenamer::RESULT_CANCEL: { + // stop right now, but make sure to refresh if needed + stop = true; + break; + } + } + + return true; + }); + + log::debug("hiding conflict files done"); + + if (changed) { + log::debug("triggering refresh"); + + if (origin()) { + emitOriginModified(); + } + + update(); + } +} + +void ConflictsTab::activateItems(QTreeView* tree) +{ + const auto tryPreview = core().settings().interface().doubleClicksOpenPreviews(); + + // the menu item is only shown for a single selection, but handle all of them + // in case this changes + forEachInSelection(tree, [&](const ConflictItem* item) { + const auto path = item->fileName(); + + if (tryPreview && canPreviewFile(plugin(), item->isArchive(), path)) { + previewItem(item); + } else { + openItem(item, false); + } + + return true; + }); +} + +void ConflictsTab::openItems(QTreeView* tree, bool hooked) +{ + // the menu item is only shown for a single selection, but handle all of them + // in case this changes + forEachInSelection(tree, [&](const ConflictItem* item) { + openItem(item, hooked); + return true; + }); +} + +void ConflictsTab::openItem(const ConflictItem* item, bool hooked) +{ + core() + .processRunner() + .setFromFile(parentWidget(), QFileInfo(item->fileName())) + .setHooked(hooked) + .setWaitForCompletion() + .run(); +} + +void ConflictsTab::previewItems(QTreeView* tree) +{ + // the menu item is only shown for a single selection, but handle all of them + // in case this changes + forEachInSelection(tree, [&](const ConflictItem* item) { + previewItem(item); + return true; + }); +} + +void ConflictsTab::previewItem(const ConflictItem* item) +{ + core().previewFileWithAlternatives(parentWidget(), item->fileName()); +} + +void ConflictsTab::exploreItems(QTreeView* tree) +{ + // the menu item is only shown for a single selection, but handle all of them + // in case this changes + forEachInSelection(tree, [&](const ConflictItem* item) { + shell::Explore(item->fileName()); + return true; + }); +} + +void ConflictsTab::showContextMenu(const QPoint& pos, QTreeView* tree) +{ + auto actions = createMenuActions(tree); + + QMenu menu; + + // open + if (actions.open) { + connect(actions.open, &QAction::triggered, [&] { + openItems(tree, false); + }); + } + + // preview + if (actions.preview) { + connect(actions.preview, &QAction::triggered, [&] { + previewItems(tree); + }); + } + + if ((actions.open && actions.open->isEnabled()) && + (actions.preview && actions.preview->isEnabled())) { + if (Settings::instance().interface().doubleClicksOpenPreviews()) { + menu.addAction(actions.preview); + menu.addAction(actions.open); + } else { + menu.addAction(actions.open); + menu.addAction(actions.preview); + } + } else { + if (actions.open) { + menu.addAction(actions.open); + } + + if (actions.preview) { + menu.addAction(actions.preview); + } + } + + // run hooked + if (actions.runHooked) { + connect(actions.runHooked, &QAction::triggered, [&] { + openItems(tree, true); + }); + + menu.addAction(actions.runHooked); + } + + // goto + if (actions.gotoMenu) { + menu.addMenu(actions.gotoMenu); + + for (auto* a : actions.gotoActions) { + connect(a, &QAction::triggered, [&, name = a->text()] { + emitModOpen(name); + }); + + actions.gotoMenu->addAction(a); + } + } + + // explore + if (actions.explore) { + connect(actions.explore, &QAction::triggered, [&] { + exploreItems(tree); + }); + + menu.addAction(actions.explore); + } + + menu.addSeparator(); + + // hide + if (actions.hide) { + connect(actions.hide, &QAction::triggered, [&] { + hideItems(tree); + }); + + menu.addAction(actions.hide); + } + + if (!menu.isEmpty()) { + if (actions.open || actions.preview || actions.runHooked) { + // bold the first option + auto* top = menu.actions()[0]; + auto f = top->font(); + f.setBold(true); + top->setFont(f); + } + + menu.exec(tree->viewport()->mapToGlobal(pos)); + } +} + +ConflictsTab::Actions ConflictsTab::createMenuActions(QTreeView* tree) +{ + if (tree->selectionModel()->selection().isEmpty()) { + return {}; + } + + bool enableHide = true; + bool enableRun = true; + bool enableOpen = true; + bool enablePreview = true; + bool enableExplore = true; + bool enableGoto = true; + + const auto n = smallSelectionSize(tree); + + const auto* proxy = dynamic_cast(tree->model()); + + if (!proxy) { + log::error("tree doesn't have a SortProxyModel"); + return {}; + } + + const auto* model = dynamic_cast(proxy->sourceModel()); + + if (!model) { + log::error("tree doesn't have a ConflictListModel"); + return {}; + } + + auto modelSel = proxy->mapSelectionToSource(tree->selectionModel()->selection()); + + if (n == 1) { + // this is a single selection + const auto* item = + model->getItem(static_cast(modelSel.indexes()[0].row())); + + if (!item) { + return {}; + } + + enableHide = item->canHide(); + enableRun = item->canRun(); + enableOpen = item->canOpen(); + enablePreview = item->canPreview(plugin()); + enableExplore = item->canExplore(); + enableGoto = item->hasAlts(); + } else { + // this is a multiple selection, don't show open/preview so users don't open + // a thousand files + enableRun = false; + enableOpen = false; + enablePreview = false; + + // can't explore multiple files + enableExplore = false; + + // don't bother with this on multiple selection, at least for now + enableGoto = false; + + if (n <= max_small_selection) { + // if the number of selected items is low, checking them to accurately + // show the menu items is worth it + enableHide = false; + + forEachInSelection(tree, [&](const ConflictItem* item) { + if (item->canHide()) { + enableHide = true; + } + + if (enableHide && enableGoto) { + // found all, no need to check more + return false; + } + + return true; + }); + } + } + + Actions actions; + + if (enableRun) { + actions.open = new QAction(tr("&Execute"), parentWidget()); + actions.runHooked = new QAction(tr("Execute with &VFS"), parentWidget()); + } else if (enableOpen) { + actions.open = new QAction(tr("&Open"), parentWidget()); + actions.runHooked = new QAction(tr("Open with &VFS"), parentWidget()); + } + + actions.preview = new QAction(tr("&Preview"), parentWidget()); + actions.preview->setEnabled(enablePreview); + + actions.gotoMenu = new QMenu(tr("&Go to..."), parentWidget()); + actions.gotoMenu->setEnabled(enableGoto); + + actions.explore = new QAction(tr("Open in &Explorer"), parentWidget()); + actions.explore->setEnabled(enableExplore); + + actions.hide = new QAction(tr("&Hide"), parentWidget()); + actions.hide->setEnabled(enableHide); + + if (enableGoto && n == 1) { + const auto* item = + model->getItem(static_cast(modelSel.indexes()[0].row())); + + actions.gotoActions = createGotoActions(item); + } + + return actions; +} + +std::vector ConflictsTab::createGotoActions(const ConflictItem* item) +{ + if (!origin()) { + return {}; + } + + auto file = origin()->findFile(item->fileIndex()); + if (!file) { + return {}; + } + + std::vector mods; + const auto& ds = *core().directoryStructure(); + + // add all alternatives + for (const auto& alt : file->getAlternatives()) { + const auto& o = ds.getOriginByID(alt.originID()); + if (o.getID() != origin()->getID()) { + mods.push_back(ToQString(o.getName())); + } + } + + // add the real origin if different from this mod + const FilesOrigin& realOrigin = ds.getOriginByID(file->getOrigin()); + if (realOrigin.getID() != origin()->getID()) { + mods.push_back(ToQString(realOrigin.getName())); + } + + std::sort(mods.begin(), mods.end(), [](const auto& a, const auto& b) { + return (QString::localeAwareCompare(a, b) < 0); + }); + + std::vector actions; + + for (const auto& name : mods) { + actions.push_back(new QAction(name, parentWidget())); + } + + return actions; +} + +GeneralConflictsTab::GeneralConflictsTab(ConflictsTab* tab, Ui::ModInfoDialog* pui, + OrganizerCore& oc) + : m_tab(tab), ui(pui), m_core(oc), + m_overwriteModel(new OverwriteConflictListModel(ui->overwriteTree)), + m_overwrittenModel(new OverwrittenConflictListModel(ui->overwrittenTree)), + m_noConflictModel(new NoConflictListModel(ui->noConflictTree)) +{ + m_expanders.overwrite.set(ui->overwriteExpander, ui->overwriteTree, true); + m_expanders.overwritten.set(ui->overwrittenExpander, ui->overwrittenTree, true); + m_expanders.nonconflict.set(ui->noConflictExpander, ui->noConflictTree); + + m_filterOverwrite.setEdit(ui->overwriteLineEdit); + m_filterOverwrite.setList(ui->overwriteTree); + m_filterOverwrite.setUseSourceSort(true); + + m_filterOverwritten.setEdit(ui->overwrittenLineEdit); + m_filterOverwritten.setList(ui->overwrittenTree); + m_filterOverwritten.setUseSourceSort(true); + + m_filterNoConflicts.setEdit(ui->noConflictLineEdit); + m_filterNoConflicts.setList(ui->noConflictTree); + m_filterNoConflicts.setUseSourceSort(true); + + QObject::connect(ui->overwriteTree, &QTreeView::doubleClicked, [&](auto&&) { + m_tab->activateItems(ui->overwriteTree); + }); + + QObject::connect(ui->overwrittenTree, &QTreeView::doubleClicked, [&](auto&& item) { + m_tab->activateItems(ui->overwrittenTree); + }); + + QObject::connect(ui->noConflictTree, &QTreeView::doubleClicked, [&](auto&& item) { + m_tab->activateItems(ui->noConflictTree); + }); + + QObject::connect(ui->overwriteTree, &QTreeView::customContextMenuRequested, + [&](const QPoint& p) { + m_tab->showContextMenu(p, ui->overwriteTree); + }); + + QObject::connect(ui->overwrittenTree, &QTreeView::customContextMenuRequested, + [&](const QPoint& p) { + m_tab->showContextMenu(p, ui->overwrittenTree); + }); + + QObject::connect(ui->noConflictTree, &QTreeView::customContextMenuRequested, + [&](const QPoint& p) { + m_tab->showContextMenu(p, ui->noConflictTree); + }); +} + +void GeneralConflictsTab::clear() +{ + m_counts.clear(); + + m_overwriteModel->clear(); + m_overwrittenModel->clear(); + m_noConflictModel->clear(); + + ui->overwriteCount->display(0); + ui->overwrittenCount->display(0); + ui->noConflictCount->display(0); +} + +void GeneralConflictsTab::saveState(Settings& s) +{ + s.geometry().saveState(&m_expanders.overwrite); + s.geometry().saveState(&m_expanders.overwritten); + s.geometry().saveState(&m_expanders.nonconflict); + s.geometry().saveState(ui->overwriteTree->header()); + s.geometry().saveState(ui->noConflictTree->header()); + s.geometry().saveState(ui->overwrittenTree->header()); +} + +void GeneralConflictsTab::restoreState(const Settings& s) +{ + s.geometry().restoreState(&m_expanders.overwrite); + s.geometry().restoreState(&m_expanders.overwritten); + s.geometry().restoreState(&m_expanders.nonconflict); + s.geometry().restoreState(ui->overwriteTree->header()); + s.geometry().restoreState(ui->noConflictTree->header()); + s.geometry().restoreState(ui->overwrittenTree->header()); +} + +bool GeneralConflictsTab::update() +{ + clear(); + + if (m_tab->origin() != nullptr) { + const auto rootPath = m_tab->mod().absolutePath(); + std::set checkedDirs; + + for (const auto& file : m_tab->origin()->getFiles()) { + if (QString::fromStdWString(file->getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + // skip hidden file conflicts + continue; + } else { + const DirectoryEntry* parent = file->getParent(); + auto hidden = false; + // iterate on all parent directory entries to check for .mohiddden + while (parent != nullptr) { + auto insertResult = checkedDirs.insert(parent); + + if (insertResult.second == false) { + // if already present break as we can assume to have checked the parents as + // well + break; + } else { + if (QString::fromStdWString(parent->getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + hidden = true; + break; + } + parent = parent->getParent(); + } + } + if (hidden) { + // skip hidden file conflicts + continue; + } + } + + // careful: these two strings are moved into createXItem() below + QString relativeName = + QDir::fromNativeSeparators(ToQString(file->getRelativePath())); + QString fileName = rootPath + relativeName; + + bool archive = false; + const int fileOrigin = file->getOrigin(archive); + + ++m_counts.numTotalFiles; + + const auto& alternatives = file->getAlternatives(); + + if (fileOrigin == m_tab->origin()->getID()) { + // current mod is primary origin, the winner + (archive) ? ++m_counts.numTotalArchive : ++m_counts.numTotalLoose; + + if (!alternatives.empty()) { + m_overwriteModel->add( + createOverwriteItem(file->getIndex(), archive, std::move(fileName), + std::move(relativeName), alternatives)); + + ++m_counts.numOverwrite; + if (archive) { + ++m_counts.numOverwriteArchive; + } else { + ++m_counts.numOverwriteLoose; + } + } else { + // otherwise, put the file in the noconflict tree + m_noConflictModel->add(createNoConflictItem( + file->getIndex(), archive, std::move(fileName), std::move(relativeName))); + + ++m_counts.numNonConflicting; + if (archive) { + ++m_counts.numNonConflictingArchive; + } else { + ++m_counts.numNonConflictingLoose; + } + } + } else { + auto currId = m_tab->origin()->getID(); + auto currModAlt = std::find_if(alternatives.begin(), alternatives.end(), + [&currId](auto const& alt) { + return currId == alt.originID(); + }); + + if (currModAlt == alternatives.end()) { + log::error("Mod {} not found in the list of origins for file {}", + m_tab->origin()->getName(), fileName); + continue; + } + + bool currModFileArchive = currModAlt->isFromArchive(); + + m_overwrittenModel->add(createOverwrittenItem(file->getIndex(), fileOrigin, + archive, std::move(fileName), + std::move(relativeName))); + + ++m_counts.numOverwritten; + if (currModFileArchive) { + ++m_counts.numOverwrittenArchive; + ++m_counts.numTotalArchive; + } else { + ++m_counts.numOverwrittenLoose; + ++m_counts.numTotalLoose; + } + } + } + + m_overwriteModel->finished(); + m_overwrittenModel->finished(); + m_noConflictModel->finished(); + } + + updateUICounters(); + + return (m_counts.numOverwrite > 0 || m_counts.numOverwritten > 0); +} + +ConflictItem GeneralConflictsTab::createOverwriteItem( + FileIndex index, bool archive, QString fileName, QString relativeName, + const MOShared::AlternativesVector& alternatives) +{ + const auto& ds = *m_core.directoryStructure(); + std::wstring altString; + + for (const auto& alt : alternatives) { + if (!altString.empty()) { + altString += L", "; + } + + altString += ds.getOriginByID(alt.originID()).getName(); + } + + auto origin = ToQString(ds.getOriginByID(alternatives.back().originID()).getName()); + + return ConflictItem(ToQString(altString), std::move(relativeName), QString(), index, + std::move(fileName), true, std::move(origin), archive); +} + +ConflictItem GeneralConflictsTab::createNoConflictItem(FileIndex index, bool archive, + QString fileName, + QString relativeName) +{ + return ConflictItem(QString(), std::move(relativeName), QString(), index, + std::move(fileName), false, QString(), archive); +} + +ConflictItem GeneralConflictsTab::createOverwrittenItem(FileIndex index, int fileOrigin, + bool archive, QString fileName, + QString relativeName) +{ + const auto& ds = *m_core.directoryStructure(); + const FilesOrigin& realOrigin = ds.getOriginByID(fileOrigin); + + QString after = ToQString(realOrigin.getName()); + QString altOrigin = after; + + return ConflictItem(QString(), std::move(relativeName), std::move(after), index, + std::move(fileName), true, std::move(altOrigin), archive); +} + +QString percent(int a, int b) +{ + if (b == 0) { + return QString::number(0, 'f', 2); + } + return QString::number((((float)a / (float)b) * 100), 'f', 2); +} + +void GeneralConflictsTab::updateUICounters() +{ + ui->overwriteCount->display(m_counts.numOverwrite); + ui->overwrittenCount->display(m_counts.numOverwritten); + ui->noConflictCount->display(m_counts.numNonConflicting); + + QString tooltipBase = + tr("" + "" + "" + "" + "" + "" + "" + "" + "
    Type%1TotalPercent
    Loose files: %2%3%4%
    Archive files: %5%6%7%
    Combined: %8%9%10%
    "); + + QString tooltipOverwrite = + tooltipBase.arg(tr("Winning")) + .arg(m_counts.numOverwriteLoose) + .arg(m_counts.numTotalLoose) + .arg(percent(m_counts.numOverwriteLoose, m_counts.numTotalLoose)) + .arg(m_counts.numOverwriteArchive) + .arg(m_counts.numTotalArchive) + .arg(percent(m_counts.numOverwriteArchive, m_counts.numTotalArchive)) + .arg(m_counts.numOverwrite) + .arg(m_counts.numTotalFiles) + .arg(percent(m_counts.numOverwrite, m_counts.numTotalFiles)); + + QString tooltipOverwritten = + tooltipBase.arg(tr("Losing")) + .arg(m_counts.numOverwrittenLoose) + .arg(m_counts.numTotalLoose) + .arg(percent(m_counts.numOverwrittenLoose, m_counts.numTotalLoose)) + .arg(m_counts.numOverwrittenArchive) + .arg(m_counts.numTotalArchive) + .arg(percent(m_counts.numOverwrittenArchive, m_counts.numTotalArchive)) + .arg(m_counts.numOverwritten) + .arg(m_counts.numTotalFiles) + .arg(percent(m_counts.numOverwritten, m_counts.numTotalFiles)); + + QString tooltipNonConflict = + tooltipBase.arg(tr("Non conflicting")) + .arg(m_counts.numNonConflictingLoose) + .arg(m_counts.numTotalLoose) + .arg(percent(m_counts.numNonConflictingLoose, m_counts.numTotalLoose)) + .arg(m_counts.numNonConflictingArchive) + .arg(m_counts.numTotalArchive) + .arg(percent(m_counts.numNonConflictingArchive, m_counts.numTotalArchive)) + .arg(m_counts.numNonConflicting) + .arg(m_counts.numTotalFiles) + .arg(percent(m_counts.numNonConflicting, m_counts.numTotalFiles)); + + ui->overwriteCount->setToolTip(tooltipOverwrite); + ui->overwrittenCount->setToolTip(tooltipOverwritten); + ui->noConflictCount->setToolTip(tooltipNonConflict); +} + +void GeneralConflictsTab::onOverwriteActivated(const QModelIndex& index) +{ + const auto* proxy = dynamic_cast(ui->overwriteTree->model()); + + if (!proxy) { + log::error("tree doesn't have a SortProxyModel"); + return; + } + + const auto* model = dynamic_cast(proxy->sourceModel()); + + if (!model) { + log::error("tree doesn't have a ConflictListModel"); + return; + } + + auto modelIndex = proxy->mapToSource(index); + + auto* item = model->getItem(static_cast(modelIndex.row())); + if (!item) { + return; + } + + const auto origin = item->altOrigin(); + if (!origin.isEmpty()) { + emit modOpen(origin); + } +} + +void GeneralConflictsTab::onOverwrittenActivated(const QModelIndex& index) +{ + const auto* proxy = dynamic_cast(ui->overwrittenTree->model()); + + if (!proxy) { + log::error("tree doesn't have a SortProxyModel"); + return; + } + + const auto* model = dynamic_cast(proxy->sourceModel()); + + if (!model) { + log::error("tree doesn't have a ConflictListModel"); + return; + } + + proxy->mapSelectionToSource(ui->overwrittenTree->selectionModel()->selection()); + + auto modelIndex = proxy->mapToSource(index); + + auto* item = model->getItem(static_cast(modelIndex.row())); + if (!item) { + return; + } + + const auto origin = item->altOrigin(); + if (!origin.isEmpty()) { + emit modOpen(origin); + } +} + +AdvancedConflictsTab::AdvancedConflictsTab(ConflictsTab* tab, Ui::ModInfoDialog* pui, + OrganizerCore& oc) + : m_tab(tab), ui(pui), m_core(oc), + m_model(new AdvancedConflictListModel(ui->conflictsAdvancedList)) +{ + m_filter.setEdit(ui->conflictsAdvancedFilter); + m_filter.setList(ui->conflictsAdvancedList); + m_filter.setUseSourceSort(true); + + // left-elide the overwrites column so that the nearest are visible + ui->conflictsAdvancedList->setItemDelegateForColumn( + 0, new ElideLeftDelegate(ui->conflictsAdvancedList)); + + // left-elide the file column to see filenames + ui->conflictsAdvancedList->setItemDelegateForColumn( + 1, new ElideLeftDelegate(ui->conflictsAdvancedList)); + + // don't elide the overwritten by column so that the nearest are visible + + QObject::connect(ui->conflictsAdvancedShowNoConflict, &QCheckBox::clicked, [&] { + update(); + }); + + QObject::connect(ui->conflictsAdvancedShowAll, &QRadioButton::clicked, [&] { + update(); + }); + + QObject::connect(ui->conflictsAdvancedShowNearest, &QRadioButton::clicked, [&] { + update(); + }); + + QObject::connect(ui->conflictsAdvancedList, &QTreeView::activated, [&] { + m_tab->activateItems(ui->conflictsAdvancedList); + }); + + QObject::connect(ui->conflictsAdvancedList, &QTreeView::customContextMenuRequested, + [&](const QPoint& p) { + m_tab->showContextMenu(p, ui->conflictsAdvancedList); + }); +} + +void AdvancedConflictsTab::clear() +{ + m_model->clear(); +} + +void AdvancedConflictsTab::saveState(Settings& s) +{ + s.geometry().saveState(ui->conflictsAdvancedList->header()); + s.widgets().saveChecked(ui->conflictsAdvancedShowNoConflict); + s.widgets().saveChecked(ui->conflictsAdvancedShowAll); + s.widgets().saveChecked(ui->conflictsAdvancedShowNearest); +} + +void AdvancedConflictsTab::restoreState(const Settings& s) +{ + s.geometry().restoreState(ui->conflictsAdvancedList->header()); + s.widgets().restoreChecked(ui->conflictsAdvancedShowNoConflict); + s.widgets().restoreChecked(ui->conflictsAdvancedShowAll); + s.widgets().restoreChecked(ui->conflictsAdvancedShowNearest); +} + +void AdvancedConflictsTab::update() +{ + clear(); + + if (m_tab->origin() != nullptr) { + const auto rootPath = m_tab->mod().absolutePath(); + + const auto& files = m_tab->origin()->getFiles(); + m_model->reserve(files.size()); + std::set checkedDirs; + + for (const auto& file : files) { + if (QString::fromStdWString(file->getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + // skip hidden file conflicts + continue; + } else { + const DirectoryEntry* parent = file->getParent(); + auto hidden = false; + // iterate on all parent directory entries to check for .mohiddden + while (parent != nullptr) { + auto insertResult = checkedDirs.insert(parent); + + if (insertResult.second == false) { + // if already present break as we can assume to have checked the parents as + // well + break; + } else { + if (QString::fromStdWString(parent->getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + hidden = true; + break; + } + parent = parent->getParent(); + } + } + if (hidden) { + // skip hidden file conflicts + continue; + } + } + // careful: these two strings are moved into createItem() below + QString relativeName = + QDir::fromNativeSeparators(ToQString(file->getRelativePath())); + QString fileName = rootPath + relativeName; + + bool archive = false; + const int fileOrigin = file->getOrigin(archive); + const auto& alternatives = file->getAlternatives(); + + auto item = createItem(file->getIndex(), fileOrigin, archive, std::move(fileName), + std::move(relativeName), alternatives); + + if (item) { + m_model->add(std::move(*item)); + } + } + + m_model->finished(); + } +} + +std::optional +AdvancedConflictsTab::createItem(FileIndex index, int fileOrigin, bool archive, + QString fileName, QString relativeName, + const MOShared::AlternativesVector& alternatives) +{ + const auto& ds = *m_core.directoryStructure(); + + std::wstring before, after; + + auto currOrigin = m_tab->origin(); + bool isCurrOrigArchive = archive; + + if (!alternatives.empty()) { + const bool showAllAlts = ui->conflictsAdvancedShowAll->isChecked(); + + if (currOrigin->getID() == fileOrigin) { + // current origin is the active winner, all alternatives go in 'before' + + if (showAllAlts) { + for (const auto& alt : alternatives) { + const auto& altOrigin = ds.getOriginByID(alt.originID()); + if (!before.empty()) { + before += L", "; + } + + before += altOrigin.getName(); + } + } else { + // only add nearest, which is the last element of alternatives + const auto& altOrigin = ds.getOriginByID(alternatives.back().originID()); + + before += altOrigin.getName(); + } + + } else { + // current mod is one of the alternatives, find its position + + auto currOrgId = currOrigin->getID(); + + auto currModIter = std::find_if(alternatives.begin(), alternatives.end(), + [&currOrgId](auto const& alt) { + return currOrgId == alt.originID(); + }); + + if (currModIter == alternatives.end()) { + log::error("Mod {} not found in the list of origins for file {}", + currOrigin->getName(), fileName); + return {}; + } + + isCurrOrigArchive = currModIter->isFromArchive(); + + if (showAllAlts) { + // fills 'before' and 'after' with all the alternatives that come + // before and after the current mod, trusting the alternatives vector to be + // already sorted correctly + + for (auto iter = alternatives.begin(); iter != alternatives.end(); iter++) { + + const auto& altOrigin = ds.getOriginByID(iter->originID()); + + if (iter < currModIter) { + // mod comes before current + + if (!before.empty()) { + before += L", "; + } + + before += altOrigin.getName(); + } else if (iter > currModIter) { + // mod comes after current + + if (!after.empty()) { + after += L", "; + } + + after += altOrigin.getName(); + } + } + + // also add the active winner origin (the one outside alternatives) to 'after' + if (!after.empty()) { + after += L", "; + } + after += ds.getOriginByID(fileOrigin).getName(); + + } else { + // only show nearest origins + + // before + if (currModIter > alternatives.begin()) { + auto previousOrigId = (currModIter - 1)->originID(); + before += ds.getOriginByID(previousOrigId).getName(); + } + + // after + if (currModIter < (alternatives.end() - 1)) { + auto followingOrigId = (currModIter + 1)->originID(); + after += ds.getOriginByID(followingOrigId).getName(); + } else { + // current mod is last of alternatives, so closest to the active winner + + after += ds.getOriginByID(fileOrigin).getName(); + } + } + } + } + + const bool hasAlts = !before.empty() || !after.empty(); + + if (!hasAlts) { + // if both before and after are empty, it means this file has no conflicts + // at all, only display it if the user wants it + if (!ui->conflictsAdvancedShowNoConflict->isChecked()) { + return {}; + } + } + + auto beforeQS = QString::fromStdWString(before); + auto afterQS = QString::fromStdWString(after); + + return ConflictItem(std::move(beforeQS), std::move(relativeName), std::move(afterQS), + index, std::move(fileName), hasAlts, QString(), + isCurrOrigArchive); +} diff --git a/src/src/modinfodialogconflicts.h b/src/src/modinfodialogconflicts.h new file mode 100644 index 0000000..ea86905 --- /dev/null +++ b/src/src/modinfodialogconflicts.h @@ -0,0 +1,162 @@ +#ifndef MODINFODIALOGCONFLICTS_H +#define MODINFODIALOGCONFLICTS_H + +#include "expanderwidget.h" +#include "filterwidget.h" +#include "modinfodialogtab.h" +#include "shared/fileregisterfwd.h" +#include +#include + +using namespace MOBase; + +class ConflictsTab; +class OrganizerCore; +class ConflictItem; +class ConflictListModel; + +class GeneralConflictsTab : public QObject +{ + Q_OBJECT; + +public: + GeneralConflictsTab(ConflictsTab* tab, Ui::ModInfoDialog* ui, OrganizerCore& oc); + + void clear(); + void saveState(Settings& s); + void restoreState(const Settings& s); + + bool update(); + +signals: + void modOpen(QString name); + +private: + struct Expanders + { + MOBase::ExpanderWidget overwrite, overwritten, nonconflict; + }; + + ConflictsTab* m_tab; + Ui::ModInfoDialog* ui; + OrganizerCore& m_core; + Expanders m_expanders; + + ConflictListModel* m_overwriteModel; + ConflictListModel* m_overwrittenModel; + ConflictListModel* m_noConflictModel; + + FilterWidget m_filterOverwrite; + FilterWidget m_filterOverwritten; + FilterWidget m_filterNoConflicts; + + struct GeneralConflictNumbers + { + int numTotalFiles = 0; + int numTotalLoose = 0; + int numTotalArchive = 0; + int numNonConflicting = 0; + int numNonConflictingLoose = 0; + int numNonConflictingArchive = 0; + int numOverwrite = 0; + int numOverwriteLoose = 0; + int numOverwriteArchive = 0; + int numOverwritten = 0; + int numOverwrittenLoose = 0; + int numOverwrittenArchive = 0; + + void clear() { *this = {}; }; + }; + + GeneralConflictNumbers m_counts; + + ConflictItem createOverwriteItem(MOShared::FileIndex index, bool archive, + QString fileName, QString relativeName, + const MOShared::AlternativesVector& alternatives); + + ConflictItem createNoConflictItem(MOShared::FileIndex index, bool archive, + QString fileName, QString relativeName); + + ConflictItem createOverwrittenItem(MOShared::FileIndex index, int fileOrigin, + bool archive, QString fileName, + QString relativeName); + + void updateUICounters(); + + void onOverwriteActivated(const QModelIndex& index); + void onOverwrittenActivated(const QModelIndex& index); +}; + +class AdvancedConflictsTab : public QObject +{ + Q_OBJECT; + +public: + AdvancedConflictsTab(ConflictsTab* tab, Ui::ModInfoDialog* ui, OrganizerCore& oc); + + void clear(); + void saveState(Settings& s); + void restoreState(const Settings& s); + + void update(); + +signals: + void modOpen(QString name); + +private: + ConflictsTab* m_tab; + Ui::ModInfoDialog* ui; + OrganizerCore& m_core; + FilterWidget m_filter; + ConflictListModel* m_model; + + std::optional + createItem(MOShared::FileIndex index, int fileOrigin, bool archive, QString fileName, + QString relativeName, const MOShared::AlternativesVector& alternatives); +}; + +class ConflictsTab : public ModInfoDialogTab +{ + Q_OBJECT; + +public: + ConflictsTab(ModInfoDialogTabContext cx); + + void update() override; + void clear() override; + void saveState(Settings& s) override; + void restoreState(const Settings& s) override; + bool canHandleUnmanaged() const override; + + void activateItems(QTreeView* tree); + void openItems(QTreeView* tree, bool hooked); + void previewItems(QTreeView* tree); + void exploreItems(QTreeView* tree); + + void openItem(const ConflictItem* item, bool hooked); + void previewItem(const ConflictItem* item); + void hideItems(QTreeView* tree); + + void showContextMenu(const QPoint& pos, QTreeView* tree); + +private: + struct Actions + { + QAction* hide = nullptr; + QAction* open = nullptr; + QAction* runHooked = nullptr; + QAction* preview = nullptr; + QAction* explore = nullptr; + QMenu* gotoMenu = nullptr; + + std::vector gotoActions; + }; + + GeneralConflictsTab m_general; + AdvancedConflictsTab m_advanced; + + Actions createMenuActions(QTreeView* tree); + std::vector createGotoActions(const ConflictItem* item); +}; + +#endif // MODINFODIALOGCONFLICTS_H diff --git a/src/src/modinfodialogconflictsmodels.cpp b/src/src/modinfodialogconflictsmodels.cpp new file mode 100644 index 0000000..7594af6 --- /dev/null +++ b/src/src/modinfodialogconflictsmodels.cpp @@ -0,0 +1,311 @@ +#include "modinfodialogconflictsmodels.h" +#include "modinfodialog.h" +#include + +using MOBase::naturalCompare; + +ConflictItem::ConflictItem(QString before, QString relativeName, QString after, + MOShared::FileIndex index, QString fileName, + bool hasAltOrigins, QString altOrigin, bool archive) + : m_before(std::move(before)), m_relativeName(std::move(relativeName)), + m_after(std::move(after)), m_index(index), m_fileName(std::move(fileName)), + m_hasAltOrigins(hasAltOrigins), m_altOrigin(std::move(altOrigin)), + m_isArchive(archive) +{} + +const QString& ConflictItem::before() const +{ + return m_before; +} + +const QString& ConflictItem::relativeName() const +{ + return m_relativeName; +} + +const QString& ConflictItem::after() const +{ + return m_after; +} + +const QString& ConflictItem::fileName() const +{ + return m_fileName; +} + +const QString& ConflictItem::altOrigin() const +{ + return m_altOrigin; +} + +bool ConflictItem::hasAlts() const +{ + return m_hasAltOrigins; +} + +bool ConflictItem::isArchive() const +{ + return m_isArchive; +} + +MOShared::FileIndex ConflictItem::fileIndex() const +{ + return m_index; +} + +bool ConflictItem::canHide() const +{ + return canHideFile(isArchive(), fileName()); +} + +bool ConflictItem::canUnhide() const +{ + return canUnhideFile(isArchive(), fileName()); +} + +bool ConflictItem::canRun() const +{ + return canRunFile(isArchive(), fileName()); +} + +bool ConflictItem::canOpen() const +{ + return canOpenFile(isArchive(), fileName()); +} + +bool ConflictItem::canPreview(PluginContainer& pluginContainer) const +{ + return canPreviewFile(pluginContainer, isArchive(), fileName()); +} + +bool ConflictItem::canExplore() const +{ + return canExploreFile(isArchive(), fileName()); +} + +ConflictListModel::ConflictListModel(QTreeView* tree, std::vector columns) + : m_tree(tree), m_columns(std::move(columns)), m_sortColumn(-1), + m_sortOrder(Qt::AscendingOrder) +{ + m_tree->setModel(this); +} + +void ConflictListModel::clear() +{ + beginResetModel(); + m_items.clear(); + endResetModel(); +} + +void ConflictListModel::reserve(std::size_t s) +{ + m_items.reserve(s); +} + +QModelIndex ConflictListModel::index(int row, int col, const QModelIndex&) const +{ + return createIndex(row, col); +} + +QModelIndex ConflictListModel::parent(const QModelIndex&) const +{ + return {}; +} + +int ConflictListModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) { + return 0; + } + + return static_cast(m_items.size()); +} + +int ConflictListModel::columnCount(const QModelIndex&) const +{ + return static_cast(m_columns.size()); +} + +const ConflictItem* ConflictListModel::itemFromIndex(const QModelIndex& index) const +{ + const auto row = index.row(); + if (row < 0) { + return nullptr; + } + + const auto i = static_cast(row); + if (i >= m_items.size()) { + return nullptr; + } + + return &m_items[i]; +} + +QModelIndex ConflictListModel::indexFromItem(const ConflictItem* item, int col) +{ + for (std::size_t i = 0; i < m_items.size(); ++i) { + if (&m_items[i] == item) { + return createIndex(static_cast(i), col); + } + } + + return {}; +} + +QVariant ConflictListModel::data(const QModelIndex& index, int role) const +{ + if (role == Qt::DisplayRole || role == Qt::FontRole) { + const ConflictItem* item = itemFromIndex(index); + if (!item) { + return {}; + } + + const auto col = index.column(); + if (col < 0) { + return {}; + } + + const auto c = static_cast(col); + if (c >= m_columns.size()) { + return {}; + } + + if (role == Qt::DisplayRole) { + return (item->*m_columns[c].getText)(); + } else if (role == Qt::FontRole) { + if (item->isArchive()) { + QFont f = m_tree->font(); + f.setItalic(true); + return f; + } + } + } + + return {}; +} + +QVariant ConflictListModel::headerData(int col, Qt::Orientation, int role) const +{ + if (role == Qt::DisplayRole) { + if (col < 0) { + return {}; + } + + const auto i = static_cast(col); + if (i >= m_columns.size()) { + return {}; + } + + return m_columns[i].caption; + } + + return {}; +} + +void ConflictListModel::sort(int colIndex, Qt::SortOrder order) +{ + m_sortColumn = colIndex; + m_sortOrder = order; + + emit layoutAboutToBeChanged({}, QAbstractItemModel::VerticalSortHint); + + const auto oldList = persistentIndexList(); + std::vector> oldItems; + + const auto itemCount = oldList.size(); + oldItems.reserve(static_cast(itemCount)); + + for (int i = 0; i < itemCount; ++i) { + const QModelIndex& index = oldList[i]; + oldItems.push_back({itemFromIndex(index), index.column()}); + } + + doSort(); + + QModelIndexList newList; + newList.reserve(itemCount); + + for (int i = 0; i < itemCount; ++i) { + const auto& pair = oldItems[static_cast(i)]; + newList.append(indexFromItem(pair.first, pair.second)); + } + + changePersistentIndexList(oldList, newList); + + emit layoutChanged({}, QAbstractItemModel::VerticalSortHint); +} + +void ConflictListModel::add(ConflictItem item) +{ + m_items.emplace_back(std::move(item)); +} + +void ConflictListModel::finished() +{ + beginResetModel(); + endResetModel(); + + sort(m_sortColumn, m_sortOrder); +} + +const ConflictItem* ConflictListModel::getItem(std::size_t row) const +{ + if (row >= m_items.size()) { + return nullptr; + } + + return &m_items[row]; +} + +void ConflictListModel::doSort() +{ + if (m_items.empty()) { + return; + } + + if (m_sortColumn < 0) { + return; + } + + const auto c = static_cast(m_sortColumn); + if (c >= m_columns.size()) { + return; + } + + const auto& col = m_columns[c]; + + // avoids branching on sort order while sorting + auto sortAsc = [&](const auto& a, const auto& b) { + return (naturalCompare((a.*col.getText)(), (b.*col.getText)()) < 0); + }; + + auto sortDesc = [&](const auto& a, const auto& b) { + return (naturalCompare((a.*col.getText)(), (b.*col.getText)()) > 0); + }; + + if (m_sortOrder == Qt::AscendingOrder) { + std::sort(m_items.begin(), m_items.end(), sortAsc); + } else { + std::sort(m_items.begin(), m_items.end(), sortDesc); + } +} + +OverwriteConflictListModel::OverwriteConflictListModel(QTreeView* tree) + : ConflictListModel(tree, {{tr("File"), &ConflictItem::relativeName}, + {tr("Overwritten Mods"), &ConflictItem::before}}) +{} + +OverwrittenConflictListModel::OverwrittenConflictListModel(QTreeView* tree) + : ConflictListModel(tree, {{tr("File"), &ConflictItem::relativeName}, + {tr("Providing Mod"), &ConflictItem::after}}) +{} + +NoConflictListModel::NoConflictListModel(QTreeView* tree) + : ConflictListModel(tree, {{tr("File"), &ConflictItem::relativeName}}) +{} + +AdvancedConflictListModel::AdvancedConflictListModel(QTreeView* tree) + : ConflictListModel(tree, {{tr("Overwrites"), &ConflictItem::before}, + {tr("File"), &ConflictItem::relativeName}, + {tr("Overwritten By"), &ConflictItem::after}}) +{} diff --git a/src/src/modinfodialogconflictsmodels.h b/src/src/modinfodialogconflictsmodels.h new file mode 100644 index 0000000..46feb57 --- /dev/null +++ b/src/src/modinfodialogconflictsmodels.h @@ -0,0 +1,115 @@ +#include "shared/fileentry.h" + +class PluginContainer; + +class ConflictItem +{ +public: + ConflictItem(QString before, QString relativeName, QString after, + MOShared::FileIndex index, QString fileName, bool hasAltOrigins, + QString altOrigin, bool archive); + + const QString& before() const; + const QString& relativeName() const; + const QString& after() const; + + const QString& fileName() const; + const QString& altOrigin() const; + + bool hasAlts() const; + bool isArchive() const; + + MOShared::FileIndex fileIndex() const; + + bool canHide() const; + bool canUnhide() const; + bool canRun() const; + bool canOpen() const; + bool canPreview(PluginContainer& pluginContainer) const; + bool canExplore() const; + +private: + QString m_before; + QString m_relativeName; + QString m_after; + MOShared::FileIndex m_index; + QString m_fileName; + bool m_hasAltOrigins; + QString m_altOrigin; + bool m_isArchive; +}; + +class ConflictListModel : public QAbstractItemModel +{ + Q_OBJECT; + +public: + struct Column + { + QString caption; + const QString& (ConflictItem::*getText)() const; + }; + + ConflictListModel(QTreeView* tree, std::vector columns); + + void clear(); + void reserve(std::size_t s); + + QModelIndex index(int row, int col, const QModelIndex& = {}) const override; + QModelIndex parent(const QModelIndex&) const override; + int rowCount(const QModelIndex& parent = {}) const override; + int columnCount(const QModelIndex& = {}) const override; + QVariant data(const QModelIndex& index, int role) const override; + QVariant headerData(int col, Qt::Orientation, int role) const; + + void sort(int colIndex, Qt::SortOrder order = Qt::AscendingOrder); + void add(ConflictItem item); + + void finished(); + + const ConflictItem* getItem(std::size_t row) const; + +private: + QTreeView* m_tree; + std::vector m_columns; + std::vector m_items; + int m_sortColumn; + Qt::SortOrder m_sortOrder; + + const ConflictItem* itemFromIndex(const QModelIndex& index) const; + QModelIndex indexFromItem(const ConflictItem* item, int col); + + void doSort(); +}; + +class OverwriteConflictListModel : public ConflictListModel +{ + Q_OBJECT; + +public: + OverwriteConflictListModel(QTreeView* tree); +}; + +class OverwrittenConflictListModel : public ConflictListModel +{ + Q_OBJECT; + +public: + OverwrittenConflictListModel(QTreeView* tree); +}; + +class NoConflictListModel : public ConflictListModel +{ + Q_OBJECT; + +public: + NoConflictListModel(QTreeView* tree); +}; + +class AdvancedConflictListModel : public ConflictListModel +{ + Q_OBJECT; + +public: + AdvancedConflictListModel(QTreeView* tree); +}; diff --git a/src/src/modinfodialogesps.cpp b/src/src/modinfodialogesps.cpp new file mode 100644 index 0000000..b2f19e2 --- /dev/null +++ b/src/src/modinfodialogesps.cpp @@ -0,0 +1,371 @@ +#include "modinfodialogesps.h" +#include "modinfodialog.h" +#include "settings.h" +#include "ui_modinfodialog.h" +#include +#include + +using namespace MOBase; + +class ESPItem +{ +public: + ESPItem(QString rootPath, QString relativePath) + : m_rootPath(std::move(rootPath)), m_active(false) + { + if (relativePath.contains('/') || relativePath.contains('\\')) { + m_inactivePath = relativePath; + } else { + m_activePath = relativePath; + m_active = true; + } + + pathChanged(); + } + + const QString& rootPath() const { return m_rootPath; } + + const QString& relativePath() const + { + if (m_active) { + return m_activePath; + } else { + return m_inactivePath; + } + } + + const QString& filename() const { return m_filename; } + + const QString& activePath() const { return m_activePath; } + + const QString& inactivePath() const { return m_inactivePath; } + + const QFileInfo& fileInfo() const { return m_fileInfo; } + + bool isActive() const { return m_active; } + + bool activate(const QString& newName) + { + QDir root(m_rootPath); + + if (root.rename(m_inactivePath, newName)) { + m_active = true; + m_activePath = newName; + + if (QFileInfo(m_inactivePath).fileName() != newName) { + // file was renamed + m_inactivePath = QFileInfo(m_inactivePath).path() + QDir::separator() + newName; + } + + pathChanged(); + + return true; + } + + return false; + } + + bool deactivate(const QString& newName) + { + QDir root(m_rootPath); + + if (root.rename(m_activePath, newName)) { + m_active = false; + m_inactivePath = newName; + pathChanged(); + return true; + } + + return false; + } + +private: + QString m_rootPath; + QString m_activePath; + QString m_inactivePath; + QString m_filename; + QFileInfo m_fileInfo; + bool m_active; + + void pathChanged() + { + m_fileInfo.setFile(m_rootPath + QDir::separator() + relativePath()); + m_filename = m_fileInfo.fileName(); + } +}; + +class ESPListModel : public QAbstractItemModel +{ +public: + void clear() + { + beginResetModel(); + m_esps.clear(); + endResetModel(); + } + + QModelIndex index(int row, int col, const QModelIndex& = {}) const override + { + return createIndex(row, col); + } + + QModelIndex parent(const QModelIndex&) const override { return {}; } + + int rowCount(const QModelIndex& = {}) const override + { + return static_cast(m_esps.size()); + } + + int columnCount(const QModelIndex& = {}) const override { return 1; } + + QVariant data(const QModelIndex& index, int role) const override + { + if (role == Qt::DisplayRole) { + if (auto* esp = getESP(index)) { + return esp->filename(); + } + } + + return {}; + } + + void add(ESPItem esp) { m_esps.emplace_back(std::move(esp)); } + + void addOne(ESPItem esp) + { + const auto i = m_esps.size(); + + beginInsertRows({}, static_cast(i), static_cast(i)); + add(std::move(esp)); + endInsertRows(); + } + + bool removeRows(int row, int count, const QModelIndex& = {}) override + { + if (row < 0) { + return false; + } + + const auto start = static_cast(row); + if (start >= m_esps.size()) { + return false; + } + + const auto end = std::min(start + static_cast(count), m_esps.size()); + + beginRemoveRows({}, static_cast(start), static_cast(end)); + m_esps.erase(m_esps.begin() + start, m_esps.begin() + end); + endRemoveRows(); + + return true; + } + + void finished() + { + beginResetModel(); + + std::sort(m_esps.begin(), m_esps.end(), [](const auto& a, const auto& b) { + return (naturalCompare(a.filename(), b.filename()) < 0); + }); + + endResetModel(); + } + + const ESPItem* getESP(const QModelIndex& index) const + { + const auto row = index.row(); + if (row < 0) { + return nullptr; + } + + const auto i = static_cast(row); + if (i >= m_esps.size()) { + return nullptr; + } + + return &m_esps[i]; + } + + ESPItem* getESP(const QModelIndex& index) + { + return const_cast(std::as_const(*this).getESP(index)); + } + +private: + std::deque m_esps; +}; + +ESPsTab::ESPsTab(ModInfoDialogTabContext cx) + : ModInfoDialogTab(std::move(cx)), m_inactiveModel(new ESPListModel), + m_activeModel(new ESPListModel) +{ + ui->inactiveESPList->setModel(m_inactiveModel); + ui->activeESPList->setModel(m_activeModel); + + QObject::connect(ui->activateESP, &QToolButton::clicked, [&] { + onActivate(); + }); + + QObject::connect(ui->deactivateESP, &QToolButton::clicked, [&] { + onDeactivate(); + }); +} + +void ESPsTab::clear() +{ + m_inactiveModel->clear(); + m_activeModel->clear(); + setHasData(false); +} + +bool ESPsTab::feedFile(const QString& rootPath, const QString& fullPath) +{ + static const QString extensions[] = {".esp", ".esm", ".esl"}; + + for (const auto& e : extensions) { + if (fullPath.endsWith(e, Qt::CaseInsensitive)) { + ESPItem esp(rootPath, fullPath.mid(rootPath.length() + 1)); + + if (esp.isActive()) { + m_activeModel->add(std::move(esp)); + } else { + m_inactiveModel->add(std::move(esp)); + } + + return true; + } + } + + return false; +} + +void ESPsTab::update() +{ + m_inactiveModel->finished(); + m_activeModel->finished(); + + setHasData(m_inactiveModel->rowCount() > 0 || m_activeModel->rowCount() > 0); +} + +void ESPsTab::saveState(Settings& s) +{ + s.geometry().saveState(ui->ESPsSplitter); +} + +void ESPsTab::restoreState(const Settings& s) +{ + s.geometry().restoreState(ui->ESPsSplitter); +} + +void ESPsTab::onActivate() +{ + const auto index = ui->inactiveESPList->currentIndex(); + if (!index.isValid()) { + return; + } + + auto* esp = m_inactiveModel->getESP(index); + if (!esp) { + return; + } + + if (esp->isActive()) { + log::warn("ESPsTab::onActive(): item is already active"); + return; + } + + QDir root(esp->rootPath()); + const QFileInfo file(esp->fileInfo()); + + QString newName = file.fileName(); + + while (root.exists(newName)) { + bool okClicked = false; + + newName = QInputDialog::getText( + parentWidget(), QObject::tr("File Exists"), + QObject::tr("A file with that name exists, please enter a new one"), + QLineEdit::Normal, file.fileName(), &okClicked); + + if (!okClicked) { + return; + } + + if (newName.isEmpty()) { + newName = file.fileName(); + } + } + + if (esp->activate(newName)) { + // copy esp, original will be destroyed + auto copy = *esp; + m_inactiveModel->removeRow(index.row()); + m_activeModel->addOne(std::move(copy)); + selectRow(ui->inactiveESPList, index.row()); + } else { + reportError(QObject::tr("Failed to move file")); + } +} + +void ESPsTab::onDeactivate() +{ + const auto index = ui->activeESPList->currentIndex(); + if (!index.isValid()) { + return; + } + + auto* esp = m_activeModel->getESP(index); + if (!esp) { + return; + } + + if (!esp->isActive()) { + log::warn("ESPsTab::onDeactivate(): item is already inactive"); + return; + } + + QDir root(esp->rootPath()); + + // if we moved the file from optional to active in this session, we move the + // file back to where it came from. Otherwise, it is moved to the new folder + // "optional" + + QString newName = esp->inactivePath(); + + if (newName.isEmpty()) { + if (!root.exists("optional")) { + if (!root.mkdir("optional")) { + reportError(QObject::tr("Failed to create directory \"optional\"")); + return; + } + } + + newName = QString("optional") + QDir::separator() + esp->fileInfo().fileName(); + } + + if (esp->deactivate(newName)) { + // copy esp, original will be destroyed + auto copy = *esp; + + m_activeModel->removeRow(index.row()); + m_inactiveModel->addOne(std::move(copy)); + selectRow(ui->activeESPList, index.row()); + } else { + reportError(QObject::tr("Failed to move file")); + } +} + +void ESPsTab::selectRow(QListView* list, int row) +{ + const auto* model = list->model(); + const auto count = model->rowCount(); + if (count == 0) { + return; + } + + if (row >= count) { + list->setCurrentIndex(model->index(count - 1, 0)); + } else { + list->setCurrentIndex(model->index(row, 0)); + } +} diff --git a/src/src/modinfodialogesps.h b/src/src/modinfodialogesps.h new file mode 100644 index 0000000..53b1651 --- /dev/null +++ b/src/src/modinfodialogesps.h @@ -0,0 +1,31 @@ +#ifndef MODINFODIALOGESPS_H +#define MODINFODIALOGESPS_H + +#include "modinfodialogtab.h" + +class ESPItem; +class ESPListModel; + +class ESPsTab : public ModInfoDialogTab +{ + Q_OBJECT; + +public: + ESPsTab(ModInfoDialogTabContext cx); + + void clear() override; + bool feedFile(const QString& rootPath, const QString& fullPath) override; + void update(); + void saveState(Settings& s) override; + void restoreState(const Settings& s) override; + +private: + ESPListModel* m_inactiveModel; + ESPListModel* m_activeModel; + + void onActivate(); + void onDeactivate(); + void selectRow(QListView* list, int row); +}; + +#endif // MODINFODIALOGESPS_H diff --git a/src/src/modinfodialogfiletree.cpp b/src/src/modinfodialogfiletree.cpp new file mode 100644 index 0000000..30b37ee --- /dev/null +++ b/src/src/modinfodialogfiletree.cpp @@ -0,0 +1,563 @@ +#include "modinfodialogfiletree.h" +#include "filerenamer.h" +#include "modinfodialog.h" +#include "organizercore.h" +#include "ui_modinfodialog.h" +#include +#include +#include + +using namespace MOBase; +namespace shell = MOBase::shell; + +// if there are more than 50 selected items in the filetree, don't bother +// checking whether menu items apply to them, just show all of them +const int max_scan_for_context_menu = 50; + +FileTreeTab::FileTreeTab(ModInfoDialogTabContext cx) + : ModInfoDialogTab(std::move(cx)), m_fs(nullptr) +{ + m_fs = new QFileSystemModel(this); + m_fs->setReadOnly(false); + ui->filetree->setModel(m_fs); + ui->filetree->setColumnWidth(0, 300); + + m_actions.newFolder = new QAction(tr("&New Folder"), ui->filetree); + m_actions.open = new QAction(tr("&Open/Execute"), ui->filetree); + m_actions.runHooked = new QAction(tr("Open with &VFS"), ui->filetree); + m_actions.preview = new QAction(tr("&Preview"), ui->filetree); + m_actions.explore = new QAction(tr("Open in &Explorer"), ui->filetree); + m_actions.rename = new QAction(tr("&Rename"), ui->filetree); + m_actions.del = new QAction(tr("&Delete"), ui->filetree); + m_actions.hide = new QAction(tr("&Hide"), ui->filetree); + m_actions.unhide = new QAction(tr("&Unhide"), ui->filetree); + + connect(m_actions.newFolder, &QAction::triggered, [&] { + onCreateDirectory(); + }); + connect(m_actions.open, &QAction::triggered, [&] { + onOpen(); + }); + connect(m_actions.runHooked, &QAction::triggered, [&] { + onRunHooked(); + }); + connect(m_actions.preview, &QAction::triggered, [&] { + onPreview(); + }); + connect(m_actions.explore, &QAction::triggered, [&] { + onExplore(); + }); + connect(m_actions.rename, &QAction::triggered, [&] { + onRename(); + }); + connect(m_actions.del, &QAction::triggered, [&] { + onDelete(); + }); + connect(m_actions.hide, &QAction::triggered, [&] { + onHide(); + }); + connect(m_actions.unhide, &QAction::triggered, [&] { + onUnhide(); + }); + + connect(ui->openInExplorer, &QToolButton::clicked, [&] { + onOpenInExplorer(); + }); + + connect(ui->filetree, &QTreeView::customContextMenuRequested, [&](const QPoint& pos) { + onContextMenu(pos); + }); + + // disable renaming on double click, open the file instead + ui->filetree->setEditTriggers(ui->filetree->editTriggers() & + (~QAbstractItemView::DoubleClicked)); + + connect(ui->filetree, &QTreeView::activated, [&](auto&&) { + onActivated(); + }); +} + +void FileTreeTab::clear() +{ + m_fs->setRootPath({}); + + // always has data; even if the mod is empty, it still has a meta.ini + setHasData(true); +} + +void FileTreeTab::saveState(Settings& s) +{ + s.geometry().saveState(ui->filetree->header()); +} + +void FileTreeTab::restoreState(const Settings& s) +{ + if (!s.geometry().restoreState(ui->filetree->header())) { + ui->filetree->sortByColumn(0, Qt::AscendingOrder); + } +} + +void FileTreeTab::update() +{ + const auto rootPath = mod().absolutePath(); + + m_fs->setRootPath(rootPath); + ui->filetree->setRootIndex(m_fs->index(rootPath)); +} + +bool FileTreeTab::deleteRequested() +{ + if (!ui->filetree->hasFocus()) { + return false; + } + + onDelete(); + return true; +} + +QModelIndex FileTreeTab::singleSelection() const +{ + const auto rows = ui->filetree->selectionModel()->selectedRows(); + if (rows.size() != 1) { + return {}; + } + + return rows[0]; +} + +void FileTreeTab::onCreateDirectory() +{ + const auto selectedRows = ui->filetree->selectionModel()->selectedRows(); + if (selectedRows.size() > 1) { + return; + } + + QModelIndex selection; + + if (selectedRows.size() == 0) { + selection = m_fs->index(m_fs->rootPath(), 0); + } else { + selection = selectedRows[0]; + } + + QModelIndex index = m_fs->isDir(selection) ? selection : selection.parent(); + index = index.sibling(index.row(), 0); + + QString name = tr("New Folder"); + QString path = m_fs->filePath(index).append("/"); + + QModelIndex existingIndex = m_fs->index(path + name); + int suffix = 1; + while (existingIndex.isValid()) { + name = tr("New Folder") + QString::number(suffix++); + existingIndex = m_fs->index(path + name); + } + + QModelIndex newIndex = m_fs->mkdir(index, name); + if (!newIndex.isValid()) { + reportError(tr("Failed to create \"%1\"").arg(name)); + return; + } + + ui->filetree->setCurrentIndex(newIndex); + ui->filetree->edit(newIndex); +} + +void FileTreeTab::onActivated() +{ + auto selection = singleSelection(); + if (!selection.isValid()) { + return; + } + + // Don't open explorer on directories as we just want them to be expanded instead. + if (m_fs->isDir(selection)) { + return; + } + + const auto path = m_fs->filePath(selection); + const auto tryPreview = core().settings().interface().doubleClicksOpenPreviews(); + + if (tryPreview && canPreviewFile(plugin(), false, path)) { + onPreview(); + } else { + onOpen(); + } +} + +void FileTreeTab::onOpen() +{ + auto selection = singleSelection(); + if (!selection.isValid()) { + return; + } + + const auto path = m_fs->filePath(selection); + core() + .processRunner() + .setFromFile(parentWidget(), QFileInfo(path)) + .setHooked(false) + .setWaitForCompletion() + .run(); +} + +void FileTreeTab::onRunHooked() +{ + auto selection = singleSelection(); + if (!selection.isValid()) { + return; + } + + const auto path = m_fs->filePath(selection); + core() + .processRunner() + .setFromFile(parentWidget(), QFileInfo(path)) + .setHooked(true) + .setWaitForCompletion() + .run(); +} + +void FileTreeTab::onPreview() +{ + auto selection = singleSelection(); + if (!selection.isValid()) { + return; + } + + core().previewFile(parentWidget(), mod().name(), m_fs->filePath(selection)); +} + +void FileTreeTab::onExplore() +{ + auto selection = singleSelection(); + + if (selection.isValid()) { + shell::Explore(m_fs->filePath(selection)); + } else { + shell::Explore(mod().absolutePath()); + } +} + +void FileTreeTab::onRename() +{ + auto selection = singleSelection(); + if (!selection.isValid()) { + return; + } + + QModelIndex index = selection.sibling(selection.row(), 0); + if (!index.isValid() || m_fs->isReadOnly()) { + return; + } + + ui->filetree->edit(index); +} + +void FileTreeTab::onDelete() +{ + const auto rows = ui->filetree->selectionModel()->selectedRows(); + if (rows.count() == 0) { + return; + } + + QString message; + + if (rows.count() == 1) { + QString fileName = m_fs->fileName(rows[0]); + message = tr("Are you sure you want to delete \"%1\"?").arg(fileName); + } else { + message = tr("Are you sure you want to delete the selected files?"); + } + + if (QMessageBox::question(parentWidget(), tr("Confirm"), message) != + QMessageBox::Yes) { + return; + } + + for (const auto& index : rows) { + deleteFile(index); + } +} + +void FileTreeTab::onHide() +{ + changeVisibility(false); +} + +void FileTreeTab::onUnhide() +{ + changeVisibility(true); +} + +void FileTreeTab::onOpenInExplorer() +{ + shell::Explore(mod().absolutePath()); +} + +bool FileTreeTab::deleteFile(const QModelIndex& index) +{ + bool res = false; + + if (m_fs->isDir(index)) { + res = deleteFileRecursive(index); + } else { + res = m_fs->remove(index); + } + + if (!res) { + reportError(tr("Failed to delete %1").arg(m_fs->fileName(index))); + } + + return res; +} + +bool FileTreeTab::deleteFileRecursive(const QModelIndex& parent) +{ + for (int row = 0; row < m_fs->rowCount(parent); ++row) { + QModelIndex index = m_fs->index(row, 0, parent); + + if (m_fs->isDir(index)) { + if (!deleteFileRecursive(index)) { + log::error("failed to delete {}", m_fs->fileName(index)); + return false; + } + } else { + if (!m_fs->remove(index)) { + log::error("failed to delete {}", m_fs->fileName(index)); + return false; + } + } + } + + if (!m_fs->remove(parent)) { + log::error("failed to delete {}", m_fs->fileName(parent)); + return false; + } + + return true; +} + +void FileTreeTab::changeVisibility(bool visible) +{ + const auto selection = ui->filetree->selectionModel()->selectedRows(); + + bool changed = false; + bool stop = false; + + log::debug("{} {} filetree files", (visible ? "unhiding" : "hiding"), + selection.size()); + + QFlags flags = + (visible ? FileRenamer::UNHIDE : FileRenamer::HIDE); + + if (selection.size() > 1) { + flags |= FileRenamer::MULTIPLE; + } + + FileRenamer renamer(parentWidget(), flags); + + for (const auto& index : selection) { + if (stop) { + break; + } + + const QString path = m_fs->filePath(index); + auto result = FileRenamer::RESULT_CANCEL; + + if (visible) { + if (!canUnhideFile(false, path)) { + log::debug("cannot unhide {}, skipping", path); + continue; + } + result = unhideFile(renamer, path); + } else { + if (!canHideFile(false, path)) { + log::debug("cannot hide {}, skipping", path); + continue; + } + result = hideFile(renamer, path); + } + + switch (result) { + case FileRenamer::RESULT_OK: { + // will trigger a refresh at the end + changed = true; + break; + } + + case FileRenamer::RESULT_SKIP: { + // nop + break; + } + + case FileRenamer::RESULT_CANCEL: { + // stop right now, but make sure to refresh if needed + stop = true; + break; + } + } + } + + log::debug("{} filetree files done", (visible ? "unhiding" : "hiding")); + + if (changed) { + if (origin()) { + emitOriginModified(); + } + } +} + +void FileTreeTab::onContextMenu(const QPoint& pos) +{ + const auto selection = ui->filetree->selectionModel()->selectedRows(); + + QMenu menu(ui->filetree); + + bool enableNewFolder = false; + bool enableRun = false; + bool enableOpen = false; + bool enablePreview = false; + bool enableExplore = false; + bool enableRename = false; + bool enableDelete = false; + bool enableHide = false; + bool enableUnhide = false; + + if (selection.size() == 0) { + // no selection, only new folder and explore + enableNewFolder = true; + enableExplore = true; + } else if (selection.size() == 1) { + // single selection + enableNewFolder = true; + enableRename = true; + enableDelete = true; + + // only enable open action if a file is selected + bool hasFiles = false; + + const QString fileName = m_fs->fileName(selection[0]); + + if (m_fs->fileInfo(selection[0]).isFile()) { + if (canRunFile(false, fileName)) { + enableRun = true; + } else if (canOpenFile(false, fileName)) { + enableOpen = true; + } + } + + enablePreview = canPreviewFile(plugin(), false, fileName); + enableExplore = canExploreFile(false, fileName); + enableHide = canHideFile(false, fileName); + enableUnhide = canUnhideFile(false, fileName); + } else { + // this is a multiple selection, don't show open or explore actions so users + // don't open a thousand files + enableNewFolder = true; + enableDelete = true; + + if (selection.size() < max_scan_for_context_menu) { + // if the number of selected items is low, checking them to accurately + // show the menu items is worth it + + for (const auto& index : selection) { + const QString fileName = m_fs->fileName(index); + + if (canHideFile(false, fileName)) { + enableHide = true; + } + + if (canUnhideFile(false, fileName)) { + enableUnhide = true; + } + + if (enableHide && enableUnhide) { + // found both, no need to check more + break; + } + } + } + } + + bool enableRunHooked = false; + + if (enableRun || enableOpen) { + if (auto p = core().currentProfile()) { + if (mod().canBeEnabled()) { + const auto index = ModInfo::getIndex(mod().name()); + if (index == UINT_MAX) { + log::error("mod '{}' not found (filetree)", mod().name()); + } else { + enableRunHooked = p->modEnabled(index); + } + } + } + } + + if (enableRun) { + m_actions.open->setText(tr("&Execute")); + m_actions.runHooked->setText(tr("Execute with &VFS")); + } else if (enableOpen) { + m_actions.open->setText(tr("&Open")); + m_actions.runHooked->setText(tr("Open with &VFS")); + } + + m_actions.preview->setEnabled(enablePreview); + + if ((enableRun || enableOpen) && enablePreview) { + if (Settings::instance().interface().doubleClicksOpenPreviews()) { + menu.addAction(m_actions.preview); + menu.addAction(m_actions.open); + } else { + menu.addAction(m_actions.open); + menu.addAction(m_actions.preview); + } + } else { + if (enableOpen || enableRun) { + menu.addAction(m_actions.open); + } + + if (enablePreview) { + menu.addAction(m_actions.preview); + } + } + + if (enableRunHooked) { + menu.addAction(m_actions.runHooked); + } + + menu.addAction(m_actions.explore); + m_actions.explore->setEnabled(enableExplore); + + menu.addSeparator(); + + menu.addAction(m_actions.newFolder); + m_actions.newFolder->setEnabled(enableNewFolder); + + menu.addAction(m_actions.rename); + m_actions.rename->setEnabled(enableRename); + + menu.addAction(m_actions.del); + m_actions.del->setEnabled(enableDelete); + + menu.addSeparator(); + + menu.addAction(m_actions.hide); + m_actions.hide->setEnabled(enableHide); + + menu.addAction(m_actions.unhide); + m_actions.unhide->setEnabled(enableUnhide); + + if (enableOpen || enableRun || enablePreview) { + // bold the first option, unbold all the others + for (int i = 0; i < menu.actions().size(); ++i) { + if (auto* a = menu.actions()[i]) { + auto f = a->font(); + f.setBold(i == 0); + a->setFont(f); + } + } + } + + menu.exec(ui->filetree->viewport()->mapToGlobal(pos)); +} diff --git a/src/src/modinfodialogfiletree.h b/src/src/modinfodialogfiletree.h new file mode 100644 index 0000000..f73280c --- /dev/null +++ b/src/src/modinfodialogfiletree.h @@ -0,0 +1,56 @@ +#ifndef MODINFODIALOGFILETREE_H +#define MODINFODIALOGFILETREE_H + +#include "modinfodialogtab.h" +#include + +class FileTreeTab : public ModInfoDialogTab +{ + Q_OBJECT; + +public: + FileTreeTab(ModInfoDialogTabContext cx); + + void clear() override; + void saveState(Settings& s); + void restoreState(const Settings& s); + void update() override; + bool deleteRequested() override; + +private: + struct Actions + { + QAction* newFolder = nullptr; + QAction* open = nullptr; + QAction* runHooked = nullptr; + QAction* preview = nullptr; + QAction* explore = nullptr; + QAction* rename = nullptr; + QAction* del = nullptr; + QAction* hide = nullptr; + QAction* unhide = nullptr; + }; + + QFileSystemModel* m_fs; + Actions m_actions; + + void onCreateDirectory(); + void onActivated(); + void onOpen(); + void onRunHooked(); + void onPreview(); + void onExplore(); + void onRename(); + void onDelete(); + void onHide(); + void onUnhide(); + void onOpenInExplorer(); + void onContextMenu(const QPoint& pos); + + QModelIndex singleSelection() const; + bool deleteFile(const QModelIndex& index); + bool deleteFileRecursive(const QModelIndex& index); + void changeVisibility(bool visible); +}; + +#endif // MODINFODIALOGFILETREE_H diff --git a/src/src/modinfodialogfwd.h b/src/src/modinfodialogfwd.h new file mode 100644 index 0000000..88df4f7 --- /dev/null +++ b/src/src/modinfodialogfwd.h @@ -0,0 +1,52 @@ +#ifndef MODINFODIALOGFWD_H +#define MODINFODIALOGFWD_H + +#include "filerenamer.h" +#include + +class ModInfo; +using ModInfoPtr = QSharedPointer; + +enum class ModInfoTabIDs +{ + None = -1, + TextFiles = 0, + IniFiles, + Images, + Esps, + Conflicts, + Categories, + Nexus, + Notes, + Filetree +}; + +class PluginContainer; + +bool canPreviewFile(const PluginContainer& pluginContainer, bool isArchive, + const QString& filename); +bool canRunFile(bool isArchive, const QString& filename); +bool canOpenFile(bool isArchive, const QString& filename); +bool canExploreFile(bool isArchive, const QString& filename); +bool canHideFile(bool isArchive, const QString& filename); +bool canUnhideFile(bool isArchive, const QString& filename); + +FileRenamer::RenameResults hideFile(FileRenamer& renamer, const QString& oldName); +FileRenamer::RenameResults unhideFile(FileRenamer& renamer, const QString& oldName); +FileRenamer::RenameResults restoreHiddenFilesRecursive(FileRenamer& renamer, + const QString& targetDir); + +class ElideLeftDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + +protected: + void initStyleOption(QStyleOptionViewItem* o, const QModelIndex& i) const + { + QStyledItemDelegate::initStyleOption(o, i); + o->textElideMode = Qt::ElideLeft; + } +}; + +#endif // MODINFODIALOGFWD_H diff --git a/src/src/modinfodialogimages.cpp b/src/src/modinfodialogimages.cpp new file mode 100644 index 0000000..2715c60 --- /dev/null +++ b/src/src/modinfodialogimages.cpp @@ -0,0 +1,1096 @@ +#include "modinfodialogimages.h" +#include "settings.h" +#include "ui_modinfodialog.h" +#include "utility.h" +#include + +using namespace MOBase; +using namespace ImagesTabHelpers; + +QSize resizeWithAspectRatio(const QSize& original, const QSize& available) +{ + const auto ratio = + std::min({1.0, static_cast(available.width()) / original.width(), + static_cast(available.height()) / original.height()}); + + const QSize scaledSize(static_cast(std::round(original.width() * ratio)), + static_cast(std::round(original.height() * ratio))); + + return scaledSize; +} + +QRect centeredRect(const QRect& rect, const QSize& size) +{ + return QRect((rect.left() + rect.width() / 2) - size.width() / 2, + (rect.top() + rect.height() / 2) - size.height() / 2, size.width(), + size.height()); +} + +QString dimensionString(const QSize& s) +{ + return QString::fromUtf8("%1 \xc3\x97 %2").arg(s.width()).arg(s.height()); +} + +ImagesTab::ImagesTab(ModInfoDialogTabContext cx) + : ModInfoDialogTab(std::move(cx)), m_image(new ScalableImage), + m_ddsAvailable(false), m_ddsEnabled(false) +{ + getSupportedFormats(); + + auto* ly = new QVBoxLayout(ui->imagesImage); + ly->setContentsMargins({0, 0, 0, 0}); + ly->addWidget(m_image); + + delete ui->imagesThumbnails->layout(); + + ui->tabImagesSplitter->setSizes({128, 1}); + ui->tabImagesSplitter->setStretchFactor(0, 0); + ui->tabImagesSplitter->setStretchFactor(1, 1); + + ui->previewPluginButton->setEnabled(false); + + ui->imagesThumbnails->setTab(this); + + ui->imagesScrollerVBar->setTab(this); + connect(ui->imagesScrollerVBar, &QScrollBar::valueChanged, [&] { + onScrolled(); + }); + + ui->imagesShowDDS->setEnabled(m_ddsAvailable); + + m_filter.setEdit(ui->imagesFilter); + connect(&m_filter, &FilterWidget::changed, [&] { + onFilterChanged(); + }); + + connect(ui->imagesExplore, &QAbstractButton::clicked, [&] { + onExplore(); + }); + connect(ui->imagesShowDDS, &QCheckBox::toggled, [&] { + onShowDDS(); + }); + connect(ui->previewPluginButton, &QAbstractButton::clicked, [&] { + onPreviewButton(); + }); + + ui->imagesShowDDS->setEnabled(m_ddsAvailable); + + ui->imagesThumbnails->setAutoFillBackground(false); + + if (ui->imagesThumbnails->palette().color(QPalette::Base) != Qt::transparent) { + ui->imagesThumbnails->setAttribute(Qt::WA_OpaquePaintEvent, true); + } + + { + auto list = std::make_unique(); + parentWidget()->style()->polish(list.get()); + + m_theme.borderColor = QColor(Qt::black); + m_theme.backgroundColor = QColor(Qt::black); + m_theme.textColor = list->palette().color(QPalette::WindowText); + + m_theme.highlightBackgroundColor = list->palette().color(QPalette::Highlight); + m_theme.highlightTextColor = list->palette().color(QPalette::HighlightedText); + + m_theme.font = list->font(); + + const QFontMetrics fm(m_theme.font); + m_metrics.textHeight = fm.height(); + + m_image->setColors(m_theme.borderColor, m_theme.backgroundColor); + } +} + +void ImagesTab::clear() +{ + m_files.clear(); + ui->imagesScrollerVBar->setValue(0); + select(BadIndex); + setHasData(false); +} + +bool ImagesTab::feedFile(const QString& rootPath, const QString& fullPath) +{ + for (const auto& ext : m_supportedFormats) { + if (fullPath.endsWith(ext, Qt::CaseInsensitive)) { + m_files.add({fullPath}); + return true; + } + } + + return false; +} + +void ImagesTab::update() +{ + checkFiltering(); + updateScrollbar(); + + // visibility needs to be rechecked here because the scrollbar configuration + // may have changed in updateScrollbar(), in which case any ensureVisible() + // calls in checkFiltering() might have been incorrect + if (m_files.selectedIndex() != BadIndex) { + ensureVisible(m_files.selectedIndex(), Visibility::Partial); + } + + ui->imagesThumbnails->update(); + + setHasData(m_files.size() > 0); +} + +void ImagesTab::saveState(Settings& s) +{ + s.widgets().saveChecked(ui->imagesShowDDS); + s.geometry().saveState(ui->tabImagesSplitter); +} + +void ImagesTab::restoreState(const Settings& s) +{ + s.widgets().restoreChecked(ui->imagesShowDDS); + s.geometry().restoreState(ui->tabImagesSplitter); +} + +void ImagesTab::checkFiltering() +{ + if (m_filter.empty() && m_ddsEnabled) { + // no filtering needed + + if (m_files.isFiltered()) { + // was filtered, needs switch + switchToAll(); + } + } else { + // filtering is needed + switchToFiltered(); + } +} + +void ImagesTab::switchToAll() +{ + // remember selection + const auto* oldSelection = m_files.selectedFile(); + + // switch + m_files.switchToAll(); + + // reselect old + if (oldSelection) { + select(m_files.indexOf(oldSelection)); + } else { + select(BadIndex); + } +} + +void ImagesTab::switchToFiltered() +{ + // remember old selection, will be checked when building the filtered list + // below + const auto* oldSelection = m_files.selectedFile(); + std::size_t newSelection = BadIndex; + + // switch, also clears list + m_files.switchToFiltered(); + + const bool hasTextFilter = !m_filter.empty(); + + for (File& f : m_files.allFiles()) { + if (hasTextFilter) { + // check filter widget + const auto m = m_filter.matches([&](const QRegularExpression& regex) { + return regex.match(f.filename()).hasMatch(); + }); + + if (!m) { + // no match, skip + continue; + } + } + + if (!m_ddsEnabled) { + // skip .dds files + if (f.filename().endsWith(".dds", Qt::CaseInsensitive)) { + continue; + } + } + + if (&f == oldSelection) { + // found the old selection, remember its index + newSelection = m_files.size(); + } + + m_files.addFiltered(&f); + } + + // reselect old, or clear if it wasn't found + select(newSelection); +} + +void ImagesTab::getSupportedFormats() +{ + m_ddsAvailable = false; + + for (const auto& entry : QImageReader::supportedImageFormats()) { + QString s(entry); + if (s.isNull() || s.isEmpty()) { + continue; + } + + // used to enable the checkbox + if (s.compare("dds", Qt::CaseInsensitive) == 0) { + m_ddsAvailable = true; + } + + // make sure it starts with a dot + if (s[0] != '.') { + s = '.' + s; + } + + m_supportedFormats.emplace_back(std::move(s)); + } +} + +void ImagesTab::select(std::size_t i, Visibility v) +{ + m_files.select(i); + + if (auto* f = m_files.selectedFile()) { + // when jumping elsewhere in the list, such as with page down/up, the file + // might not be visible yet, which means it hasn't been loaded and would + // pass a null image in setImage() below + f->ensureOriginalLoaded(); + + ui->imagesPath->setText(QDir::toNativeSeparators(f->path())); + ui->imagesExplore->setEnabled(true); + if (plugin().previewGenerator().previewSupported( + QFileInfo(f->path()).suffix().toLower(), false)) + ui->previewPluginButton->setEnabled(true); + else + ui->previewPluginButton->setEnabled(false); + ui->imagesSize->setText(dimensionString(f->original().size())); + + if (f->original().isNull()) { + m_image->clear(); + + QImage image(300, 100, QImage::Format_RGBA64); + QPainter paint; + paint.begin(&image); + paint.fillRect(0, 0, 300, 100, QBrush(QColor(0, 0, 0, 255))); + paint.setPen(m_theme.textColor); + paint.setFont(m_theme.font); + paint.drawImage(QPoint(150 - 16, 50 - 20 - 16), QImage(":/MO/gui/warning")); + const auto flags = Qt::AlignHCenter | Qt::AlignVCenter | Qt::TextWordWrap; + paint.drawText(0, 46, 300, 54, flags, + "This image format is not supported by Qt, but the preview plugin " + "may be able to display it. Use the button above."); + paint.end(); + + m_image->setImage(image); + } else + m_image->setImage(f->original()); + ensureVisible(i, v); + } else { + ui->imagesPath->clear(); + ui->imagesExplore->setEnabled(false); + ui->previewPluginButton->setEnabled(false); + ui->imagesSize->clear(); + m_image->clear(); + } + + ui->imagesThumbnails->update(); +} + +void ImagesTab::moveSelection(int by) +{ + if (m_files.empty()) { + return; + } + + auto i = m_files.selectedIndex(); + if (i == BadIndex) { + i = 0; + } + + if (by > 0) { + // moving down + i += static_cast(by); + + if (i >= m_files.size()) { + i = (m_files.size() - 1); + } + } else if (by < 0) { + // moving up + const auto abs_by = static_cast(std::abs(by)); + + if (abs_by > i) { + i = 0; + } else { + i -= abs_by; + } + } + + select(i); +} + +void ImagesTab::ensureVisible(std::size_t i, Visibility v) +{ + if (v == Visibility::Ignore) { + return; + } + + const auto geo = makeGeometry(); + + const auto fullyVisible = geo.fullyVisibleCount(); + const auto partiallyVisible = fullyVisible + 1; + + const auto first = ui->imagesScrollerVBar->value(); + const auto last = + (v == Visibility::Full ? first + fullyVisible : first + partiallyVisible); + + if (i < first) { + // go up + ui->imagesScrollerVBar->setValue(static_cast(i)); + } else if (i >= last) { + // go down + + if (i >= fullyVisible) { + ui->imagesScrollerVBar->setValue(static_cast(i - fullyVisible + 1)); + } + } +} + +std::size_t ImagesTab::fileIndexAtPos(const QPoint& p) const +{ + const auto geo = makeGeometry(); + + // this is the index relative to the top + const auto offset = geo.indexAt(p); + if (offset == BadIndex) { + return BadIndex; + } + + const auto first = ui->imagesScrollerVBar->value(); + if (first < 0) { + return BadIndex; + } + + const auto i = static_cast(first) + offset; + if (i >= m_files.size()) { + return BadIndex; + } + + return i; +} + +const File* ImagesTab::fileAtPos(const QPoint& p) const +{ + const auto i = fileIndexAtPos(p); + if (i >= m_files.size()) { + return nullptr; + } + + return m_files.get(i); +} + +Geometry ImagesTab::makeGeometry() const +{ + return Geometry(ui->imagesThumbnails->size(), m_metrics); +} + +void ImagesTab::paintThumbnailsArea(QPaintEvent* e) +{ + PaintContext cx(ui->imagesThumbnails, makeGeometry()); + + cx.painter.fillRect(ui->imagesThumbnails->rect(), + ui->imagesThumbnails->palette().color(QPalette::Window)); + + const auto visible = cx.geo.fullyVisibleCount() + 1; + const auto first = ui->imagesScrollerVBar->value(); + + for (std::size_t i = 0; i < visible; ++i) { + const auto fileIndex = first + i; + auto* file = m_files.get(fileIndex); + if (!file) { + break; + } + + cx.file = file; + cx.thumbIndex = i; + cx.fileIndex = fileIndex; + + paintThumbnail(cx); + } +} + +void ImagesTab::paintThumbnail(const PaintContext& cx) +{ + paintThumbnailBackground(cx); + paintThumbnailBorder(cx); + paintThumbnailImage(cx); + paintThumbnailText(cx); +} + +void ImagesTab::paintThumbnailBackground(const PaintContext& cx) +{ + if (m_files.selectedIndex() == cx.fileIndex) { + const auto rect = cx.geo.thumbRect(cx.thumbIndex); + cx.painter.fillRect(rect, m_theme.highlightBackgroundColor); + } +} + +void ImagesTab::paintThumbnailBorder(const PaintContext& cx) +{ + auto borderRect = cx.geo.borderRect(cx.thumbIndex); + + // rects don't include the bottom right corner, but drawRect() does, so + // resize it + borderRect.setRight(borderRect.right() - 1); + borderRect.setBottom(borderRect.bottom() - 1); + + cx.painter.setPen(m_theme.borderColor); + cx.painter.drawRect(borderRect); +} + +void ImagesTab::paintThumbnailImage(const PaintContext& cx) +{ + if (cx.file->failed()) { + return; + } + + cx.file->loadIfNeeded(cx.geo); + + const auto imageRect = cx.geo.imageRect(cx.thumbIndex); + const auto scaledThumbRect = centeredRect(imageRect, cx.file->thumbnail().size()); + + cx.painter.fillRect(scaledThumbRect, m_theme.backgroundColor); + cx.painter.drawImage(scaledThumbRect, cx.file->thumbnail()); +} + +void ImagesTab::paintThumbnailText(const PaintContext& cx) +{ + const auto tr = cx.geo.textRect(cx.thumbIndex); + + if (cx.fileIndex == m_files.selectedIndex()) { + cx.painter.setPen(m_theme.highlightTextColor); + } else { + cx.painter.setPen(m_theme.textColor); + } + + cx.painter.setFont(m_theme.font); + + QFontMetrics fm(m_theme.font); + + const auto text = fm.elidedText(cx.file->filename(), Qt::ElideRight, tr.width()); + + const auto flags = Qt::AlignHCenter | Qt::AlignVCenter | Qt::TextSingleLine; + + cx.painter.drawText(tr, flags, text); +} + +void ImagesTab::scrollAreaResized(const QSize&) +{ + updateScrollbar(); +} + +void ImagesTab::thumbnailAreaMouseEvent(QMouseEvent* e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + + const auto i = fileIndexAtPos(e->pos()); + if (i != BadIndex) { + // the only way to click on a thumbnail is if it's already visible, so the + // only thing that can happen is a click on a partially visible thumbnail, + // which would scroll so it is fully visible, and that's just annoying + select(i, Visibility::Ignore); + } +} + +void ImagesTab::thumbnailAreaWheelEvent(QWheelEvent* e) +{ + const auto d = (e->angleDelta() / 8).y(); + + ui->imagesScrollerVBar->setValue(ui->imagesScrollerVBar->value() + (d > 0 ? -1 : 1)); +} + +bool ImagesTab::thumbnailAreaKeyPressEvent(QKeyEvent* e) +{ + switch (e->key()) { + case Qt::Key_Down: { + moveSelection(ui->imagesScrollerVBar->singleStep()); + return true; + } + + case Qt::Key_Up: { + moveSelection(-ui->imagesScrollerVBar->singleStep()); + return true; + } + + case Qt::Key_PageDown: { + moveSelection(ui->imagesScrollerVBar->pageStep()); + return true; + } + + case Qt::Key_PageUp: { + moveSelection(-ui->imagesScrollerVBar->pageStep()); + return true; + } + + case Qt::Key_Home: { + select(0); + return true; + } + + case Qt::Key_End: { + if (!m_files.empty()) { + select(m_files.size() - 1); + } + + return true; + } + } + + return false; +} + +void ImagesTab::onScrolled() +{ + ui->imagesThumbnails->update(); +} + +void ImagesTab::showTooltip(QHelpEvent* e) +{ + const auto* f = fileAtPos(e->pos()); + if (!f) { + QToolTip::hideText(); + e->ignore(); + return; + } + + const auto s = QString("%1 (%2)") + .arg(QDir::toNativeSeparators(f->path())) + .arg(dimensionString(f->original().size())); + + QToolTip::showText(e->globalPos(), s, ui->imagesThumbnails); +} + +void ImagesTab::onExplore() +{ + if (auto* f = m_files.selectedFile()) { + shell::Explore(f->path()); + } +} + +void ImagesTab::onShowDDS() +{ + const auto b = ui->imagesShowDDS->isChecked(); + if (b != m_ddsEnabled) { + m_ddsEnabled = b; + update(); + } +} + +void ImagesTab::onPreviewButton() +{ + core().previewFileWithAlternatives(parentWidget(), m_files.selectedFile()->path()); +} + +void ImagesTab::onFilterChanged() +{ + update(); +} + +void ImagesTab::updateScrollbar() +{ + if (m_files.size() == 0) { + ui->imagesScrollerVBar->setRange(0, 0); + ui->imagesScrollerVBar->setEnabled(false); + return; + } + + const auto geo = makeGeometry(); + const auto availableSize = ui->imagesThumbnails->size(); + const auto fullyVisible = geo.fullyVisibleCount(); + + if (fullyVisible >= m_files.size()) { + ui->imagesScrollerVBar->setRange(0, 0); + ui->imagesScrollerVBar->setEnabled(false); + } else { + const auto d = m_files.size() - fullyVisible; + ui->imagesScrollerVBar->setRange(0, static_cast(d)); + ui->imagesScrollerVBar->setSingleStep(1); + ui->imagesScrollerVBar->setPageStep(static_cast(fullyVisible - 1)); + ui->imagesScrollerVBar->setEnabled(true); + } +} + +namespace ImagesTabHelpers +{ + +void Scrollbar::setTab(ImagesTab* tab) +{ + m_tab = tab; +} + +void Scrollbar::wheelEvent(QWheelEvent* e) +{ + if (m_tab) { + m_tab->thumbnailAreaWheelEvent(e); + } +} + +void ThumbnailsWidget::setTab(ImagesTab* tab) +{ + m_tab = tab; +} + +void ThumbnailsWidget::paintEvent(QPaintEvent* e) +{ + if (m_tab) { + m_tab->paintThumbnailsArea(e); + } +} + +void ThumbnailsWidget::mousePressEvent(QMouseEvent* e) +{ + if (m_tab) { + m_tab->thumbnailAreaMouseEvent(e); + } +} + +void ThumbnailsWidget::wheelEvent(QWheelEvent* e) +{ + if (m_tab) { + m_tab->thumbnailAreaWheelEvent(e); + } +} + +void ThumbnailsWidget::resizeEvent(QResizeEvent* e) +{ + if (m_tab) { + m_tab->scrollAreaResized(e->size()); + } +} + +void ThumbnailsWidget::keyPressEvent(QKeyEvent* e) +{ + if (m_tab) { + if (m_tab->thumbnailAreaKeyPressEvent(e)) { + return; + } + } + + QWidget::keyPressEvent(e); +} + +bool ThumbnailsWidget::event(QEvent* e) +{ + if (e->type() == QEvent::ToolTip) { + m_tab->showTooltip(static_cast(e)); + return true; + } + + return QWidget::event(e); +} + +ScalableImage::ScalableImage(QString path) : m_path(std::move(path)), m_border(1) +{ + auto sp = sizePolicy(); + sp.setHeightForWidth(true); + setSizePolicy(sp); +} + +void ScalableImage::setImage(const QString& path) +{ + m_path = path; + m_original = {}; + m_scaled = {}; + + update(); +} + +void ScalableImage::setImage(QImage image) +{ + m_path.clear(); + m_original = std::move(image); + m_scaled = {}; + + update(); +} + +void ScalableImage::clear() +{ + setImage(QImage()); +} + +bool ScalableImage::hasHeightForWidth() const +{ + return true; +} + +int ScalableImage::heightForWidth(int w) const +{ + return w; +} + +void ScalableImage::setColors(const QColor& border, const QColor& background) +{ + m_borderColor = border; + m_backgroundColor = background; +} + +void ScalableImage::paintEvent(QPaintEvent* e) +{ + if (m_original.isNull()) { + if (m_path.isNull()) { + return; + } + + m_original.load(m_path); + + if (m_original.isNull()) { + return; + } + } + + const QRect widgetRect = rect(); + const QRect imageRect = widgetRect.adjusted(m_border, m_border, -m_border, -m_border); + + const QSize scaledSize = resizeWithAspectRatio(m_original.size(), imageRect.size()); + + if (m_scaled.isNull() || m_scaled.size() != scaledSize) { + m_scaled = m_original.scaled(scaledSize.width(), scaledSize.height(), + Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + } + + const QRect drawBorderRect = widgetRect.adjusted(0, 0, -1, -1); + const QRect drawImageRect = centeredRect(imageRect, m_scaled.size()); + + QPainter painter(this); + + // border + painter.setPen(m_borderColor); + painter.drawRect(drawBorderRect); + + // background + painter.fillRect(drawImageRect, m_backgroundColor); + + // image + painter.drawImage(drawImageRect, m_scaled); +} + +Metrics::Metrics() + : margins(3), border(1), padding(0), spacing(5), textSpacing(2), textHeight(0) +{} + +Geometry::Geometry(QSize widgetSize, Metrics metrics) + : m_widgetSize(widgetSize), m_metrics(metrics), m_topRect(calcTopRect()) +{} + +QRect Geometry::calcTopRect() const +{ + const auto thumbWidth = m_widgetSize.width(); + const auto& m = m_metrics; + + const auto imageSize = + thumbWidth - (m.margins * 2) - (m.border * 2) - (m.padding * 2); + + const auto thumbHeight = m.margins + m.border + m.padding + imageSize + m.padding + + m.border + m.textSpacing + m.textHeight + m.margins; + + return {0, 0, thumbWidth, thumbHeight}; +} + +std::size_t Geometry::fullyVisibleCount() const +{ + const auto r = thumbRect(0); + + const auto thumbWithSpacing = r.height() + m_metrics.spacing; + const auto visible = (m_widgetSize.height() / thumbWithSpacing); + + return static_cast(visible); +} + +QRect Geometry::thumbRect(std::size_t i) const +{ + // rect for the top thumbnail + QRect r = m_topRect; + + // move down + const auto thumbWithSpacing = m_metrics.spacing + r.height(); + r.translate(0, static_cast(i * thumbWithSpacing)); + + return r; +} + +QRect Geometry::borderRect(std::size_t i) const +{ + auto r = thumbRect(i); + const auto& m = m_metrics; + + // remove margins and text + r.adjust(m.margins, m.margins, -m.margins, -m.margins); + + // remove text + r.adjust(0, 0, 0, -(m.textSpacing + m.textHeight)); + + return r; +} + +QRect Geometry::imageRect(std::size_t i) const +{ + auto r = borderRect(i); + + // remove border and padding + const auto m = m_metrics.border + m_metrics.padding; + r.adjust(m, m, -m, -m); + + return r; +} + +QRect Geometry::textRect(std::size_t i) const +{ + const auto r = borderRect(i); + + return QRect(r.left(), r.bottom() + m_metrics.textSpacing, r.width(), + m_metrics.textHeight); +} + +QRect Geometry::selectionRect(std::size_t i) const +{ + const auto br = borderRect(i); + const auto tr = textRect(i); + + return QRect(br.left(), br.top(), br.width(), tr.bottom() - br.top()); +} + +std::size_t Geometry::indexAt(const QPoint& p) const +{ + // calculate index purely based on y position + const std::size_t offset = p.y() / (m_topRect.height() + m_metrics.spacing); + + if (!selectionRect(offset).contains(p)) { + return BadIndex; + } + + return offset; +} + +QSize Geometry::scaledImageSize(const QSize& originalSize) const +{ + const auto availableSize = imageRect(0).size(); + return resizeWithAspectRatio(originalSize, availableSize); +} + +File::File(QString path) : m_path(std::move(path)), m_failed(false) {} + +void File::ensureOriginalLoaded() +{ + if (!m_original.isNull()) { + // already loaded + return; + } + + QImageReader reader(m_path); + + if (!reader.read(&m_original)) { + log::error("failed to load '{}'\n{} (error {})", m_path, reader.errorString(), + static_cast(reader.error())); + + m_failed = true; + } +} + +const QString& File::path() const +{ + return m_path; +} + +const QString& File::filename() const +{ + if (m_filename.isEmpty()) { + m_filename = QFileInfo(m_path).fileName(); + } + + return m_filename; +} + +const QImage& File::original() const +{ + return m_original; +} + +const QImage& File::thumbnail() const +{ + return m_thumbnail; +} + +bool File::failed() const +{ + return m_failed; +} + +void File::loadIfNeeded(const Geometry& geo) +{ + if (needsLoad(geo)) { + load(geo); + } +} + +bool File::needsLoad(const Geometry& geo) const +{ + if (m_failed) { + return false; + } + + if (m_original.isNull() || m_thumbnail.isNull()) { + return true; + } + + const auto scaledSize = geo.scaledImageSize(m_original.size()); + return (m_thumbnail.size() != scaledSize); +} + +void File::load(const Geometry& geo) +{ + m_failed = false; + ensureOriginalLoaded(); + + if (m_failed) { + QImage warning(":/MO/gui/warning"); + const auto scaledSize = geo.scaledImageSize(warning.size()); + + m_thumbnail = + warning.scaled(scaledSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + } else { + const auto scaledSize = geo.scaledImageSize(m_original.size()); + + m_thumbnail = + m_original.scaled(scaledSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + } +} + +Files::Files() : m_selection(BadIndex), m_filtered(false) {} + +void Files::clear() +{ + m_allFiles.clear(); + m_filteredFiles.clear(); + m_selection = BadIndex; + m_filtered = false; +} + +void Files::add(File f) +{ + m_allFiles.emplace_back(std::move(f)); +} + +void Files::addFiltered(File* f) +{ + m_filteredFiles.push_back(f); +} + +bool Files::empty() const +{ + if (m_filtered) { + return m_filteredFiles.empty(); + } else { + return m_allFiles.empty(); + } +} + +std::size_t Files::size() const +{ + if (m_filtered) { + return m_filteredFiles.size(); + } else { + return m_allFiles.size(); + } +} + +void Files::switchToAll() +{ + m_filtered = false; + m_filteredFiles.clear(); +} + +void Files::switchToFiltered() +{ + m_filtered = true; + m_filteredFiles.clear(); +} + +const File* Files::get(std::size_t i) const +{ + if (m_filtered) { + if (i < m_filteredFiles.size()) { + return m_filteredFiles[i]; + } + } else { + if (i < m_allFiles.size()) { + return &m_allFiles[i]; + } + } + + return nullptr; +} + +File* Files::get(std::size_t i) +{ + return const_cast(std::as_const(*this).get(i)); +} + +std::size_t Files::indexOf(const File* f) const +{ + if (m_filtered) { + for (std::size_t i = 0; i < m_filteredFiles.size(); ++i) { + if (m_filteredFiles[i] == f) { + return i; + } + } + } else { + for (std::size_t i = 0; i < m_allFiles.size(); ++i) { + if (&m_allFiles[i] == f) { + return i; + } + } + } + + return BadIndex; +} + +const File* Files::selectedFile() const +{ + return get(m_selection); +} + +File* Files::selectedFile() +{ + return get(m_selection); +} + +std::size_t Files::selectedIndex() const +{ + return m_selection; +} + +void Files::select(std::size_t i) +{ + m_selection = i; +} + +std::vector& Files::allFiles() +{ + return m_allFiles; +} + +bool Files::isFiltered() const +{ + return m_filtered; +} + +PaintContext::PaintContext(QWidget* w, Geometry geo) + : painter(w), geo(geo), file(nullptr), thumbIndex(0), fileIndex(0) +{} + +} // namespace ImagesTabHelpers diff --git a/src/src/modinfodialogimages.h b/src/src/modinfodialogimages.h new file mode 100644 index 0000000..665be50 --- /dev/null +++ b/src/src/modinfodialogimages.h @@ -0,0 +1,380 @@ +#ifndef MODINFODIALOGIMAGES_H +#define MODINFODIALOGIMAGES_H + +#include "filterwidget.h" +#include "modinfodialogtab.h" +#include "organizercore.h" +#include "plugincontainer.h" +#include + +using namespace MOBase; + +class ImagesTab; + +namespace ImagesTabHelpers +{ + +static constexpr std::size_t BadIndex = std::numeric_limits::max(); + +// vertical scrollbar, this is only to handle wheel events to scroll by one +// instead of the system's scroll setting +// +class Scrollbar : public QScrollBar +{ +public: + using QScrollBar::QScrollBar; + void setTab(ImagesTab* tab); + +protected: + // forwards to ImagesTab::thumbnailAreaWheelEvent() + // + void wheelEvent(QWheelEvent* event) override; + +private: + ImagesTab* m_tab = nullptr; +}; + +// widget inside the scroller, calls ImagesTab::paintThumbnailArea() when +// needed and also forwards mouse clicks and tooltip events +// +class ThumbnailsWidget : public QWidget +{ + Q_OBJECT; + +public: + using QWidget::QWidget; + void setTab(ImagesTab* tab); + +protected: + // forwards to ImagesTab::paintThumbnailArea() + // + void paintEvent(QPaintEvent* e) override; + + // forwards to ImagesTab::thumbnailAreaMouseEvent() + // + void mousePressEvent(QMouseEvent* e) override; + + // forwards to ImagesTab::thumbnailAreaWheelEvent() + // + void wheelEvent(QWheelEvent* e); + + // forwards to ImagesTab::scrollAreaResized() + // + void resizeEvent(QResizeEvent* e) override; + + // forwards to ImagesTab::thumbnailAreaKeyPressEvent() + // + void keyPressEvent(QKeyEvent* e) override; + + // forwards to ImagesTab::showTooltip for tooltip events + // + bool event(QEvent* e) override; + +private: + ImagesTab* m_tab = nullptr; +}; + +// a widget that draws an image scaled to fit while keeping the aspect ratio +// +class ScalableImage : public QWidget +{ + Q_OBJECT; + +public: + ScalableImage(QString path = {}); + + // sets the image to draw + void setImage(const QString& path); + void setImage(QImage image); + + // removes the image, won't draw the border nor the image + void clear(); + + // tells the QWidget's layout manager this widget is always square + bool hasHeightForWidth() const override; + int heightForWidth(int w) const override; + + // sets the colors + void setColors(const QColor& border, const QColor& background); + +protected: + void paintEvent(QPaintEvent* e) override; + +private: + QString m_path; + QImage m_original, m_scaled; + int m_border; + QColor m_borderColor, m_backgroundColor; +}; + +struct Theme +{ + QColor borderColor, backgroundColor, textColor; + QColor highlightBackgroundColor, highlightTextColor; + QFont font; +}; + +struct Metrics +{ + // space outside the thumbnail border + int margins; + + // size of the border + int border; + + // space between the border and the image + int padding; + + // spacing between the thumbnail and the text + int textSpacing; + + // height of the text + int textHeight; + + // spacing between thumbnails + int spacing; + + Metrics(); +}; + +// handles all the geometry calculations by ImagesTab for painting or handling +// mouse clicks +// +// a thumbnail looks like this: +// +// +-----------------------+ <--- thumb rect +// | margins | +// | | +// | +-border--------+ <------- border rect +// | | padding | | +// | | | | +// | | +-------+ <----------- image rect +// | | | | | | +// | | | image | | | +// | | | | | | +// | | +-------+ | | +// | | | | +// | +---------------+ | +// | text spacing | +// | +---------------+ <------- text rect +// | | text | | +// | +---------------+ | +// | | +// +-----------------------+ +// +// spacing +// +// +-----------------------+ <-- thumb rect +// | margins | +// | | +// .... +// +// +class Geometry +{ +public: + Geometry(QSize widgetSize, Metrics metrics); + + // returns the number of images fully visible in the widget + // + std::size_t fullyVisibleCount() const; + + // rectangle around the whole thumbnail + // + QRect thumbRect(std::size_t i) const; + + // rectangle of the border for the given thumbnail + // + QRect borderRect(std::size_t i) const; + + // rectangle of the image for the given thumbnail + // + QRect imageRect(std::size_t i) const; + + // rectangle of the text for the given thumbnail + // + QRect textRect(std::size_t i) const; + + // rectangle that responds to selection: includes the border and extends down + // to the text + // + QRect selectionRect(std::size_t i) const; + + // returns the index of the image at the given point; this does not take into + // account any scrolling, the image at the top of widget is always 0 + // + // returns BadIndex if there's no thumbnail at this point + // + std::size_t indexAt(const QPoint& p) const; + + // returns the size of the image that fits in imageRect() while keeping the + // same aspect ratio as the given one + // + QSize scaledImageSize(const QSize& originalSize) const; + +private: + // size of the widget containing all the thumbnails + const QSize m_widgetSize; + + // metrics + const Metrics m_metrics; + + // rectangle of the first thumbnail on top + const QRect m_topRect; + + // calculates the top rectangle + // + QRect calcTopRect() const; +}; + +class File +{ +public: + File(QString path); + + void ensureOriginalLoaded(); + + const QString& path() const; + const QString& filename() const; + const QImage& original() const; + const QImage& thumbnail() const; + bool failed() const; + + void loadIfNeeded(const Geometry& geo); + +private: + QString m_path; + mutable QString m_filename; + QImage m_original, m_thumbnail; + bool m_failed; + + bool needsLoad(const Geometry& geo) const; + void load(const Geometry& geo); +}; + +class Files +{ +public: + Files(); + + void clear(); + + void add(File f); + void addFiltered(File* f); + + bool empty() const; + std::size_t size() const; + + void switchToAll(); + void switchToFiltered(); + + const File* get(std::size_t i) const; + File* get(std::size_t i); + std::size_t indexOf(const File* f) const; + + const File* selectedFile() const; + File* selectedFile(); + std::size_t selectedIndex() const; + void select(std::size_t i); + + std::vector& allFiles(); + + bool isFiltered() const; + +private: + std::vector m_allFiles; + std::vector m_filteredFiles; + std::size_t m_selection; + bool m_filtered; +}; + +struct PaintContext +{ + mutable QPainter painter; + Geometry geo; + File* file; + std::size_t thumbIndex; + std::size_t fileIndex; + + PaintContext(QWidget* w, Geometry geo); +}; + +} // namespace ImagesTabHelpers + +class ImagesTab : public ModInfoDialogTab +{ + Q_OBJECT; + friend class ImagesTabHelpers::Scrollbar; + friend class ImagesTabHelpers::ThumbnailsWidget; + +public: + ImagesTab(ModInfoDialogTabContext cx); + + void clear() override; + bool feedFile(const QString& rootPath, const QString& fullPath) override; + void update() override; + void saveState(Settings& s) override; + void restoreState(const Settings& s) override; + +private: + enum class Visibility + { + Ignore = 0, + Full, + Partial + }; + + using ScalableImage = ImagesTabHelpers::ScalableImage; + using Files = ImagesTabHelpers::Files; + using File = ImagesTabHelpers::File; + using Theme = ImagesTabHelpers::Theme; + using Metrics = ImagesTabHelpers::Metrics; + using PaintContext = ImagesTabHelpers::PaintContext; + using Geometry = ImagesTabHelpers::Geometry; + + ScalableImage* m_image; + std::vector m_supportedFormats; + Files m_files; + FilterWidget m_filter; + bool m_ddsAvailable, m_ddsEnabled; + Theme m_theme; + Metrics m_metrics; + + void getSupportedFormats(); + void enableDDS(bool b); + + void scrollAreaResized(const QSize& s); + void paintThumbnailsArea(QPaintEvent* e); + void thumbnailAreaMouseEvent(QMouseEvent* e); + void thumbnailAreaWheelEvent(QWheelEvent* e); + bool thumbnailAreaKeyPressEvent(QKeyEvent* e); + void onScrolled(); + + void showTooltip(QHelpEvent* e); + void onExplore(); + void onShowDDS(); + void onPreviewButton(); + void onFilterChanged(); + + void select(std::size_t i, Visibility v = Visibility::Full); + void moveSelection(int by); + void ensureVisible(std::size_t i, Visibility v); + + std::size_t fileIndexAtPos(const QPoint& p) const; + const File* fileAtPos(const QPoint& p) const; + + Geometry makeGeometry() const; + + void paintThumbnail(const PaintContext& cx); + void paintThumbnailBackground(const PaintContext& cx); + void paintThumbnailBorder(const PaintContext& cx); + void paintThumbnailImage(const PaintContext& cx); + void paintThumbnailText(const PaintContext& cx); + + void checkFiltering(); + void switchToAll(); + void switchToFiltered(); + void updateScrollbar(); +}; + +#endif // MODINFODIALOGIMAGES_H diff --git a/src/src/modinfodialognexus.cpp b/src/src/modinfodialognexus.cpp new file mode 100644 index 0000000..c27f42e --- /dev/null +++ b/src/src/modinfodialognexus.cpp @@ -0,0 +1,468 @@ +#include "modinfodialognexus.h" +#include "bbcode.h" +#include "iplugingame.h" +#include "organizercore.h" +#include "settings.h" +#include "ui_modinfodialog.h" +#include +#include +#include + +using namespace MOBase; + +bool isValidModID(int id) +{ + return (id > 0); +} + +NexusTab::NexusTab(ModInfoDialogTabContext cx) + : ModInfoDialogTab(std::move(cx)), m_requestStarted(false), m_loading(false) +{ + ui->modID->setValidator(new QIntValidator(ui->modID)); + ui->endorse->setVisible(core().settings().nexus().endorsementIntegration()); + ui->track->setVisible(core().settings().nexus().trackedIntegration()); + + connect(ui->modID, &QLineEdit::editingFinished, [&] { + onModIDChanged(); + }); + connect(ui->sourceGame, + static_cast(&QComboBox::currentIndexChanged), [&] { + onSourceGameChanged(); + }); + connect(ui->version, &QLineEdit::editingFinished, [&] { + onVersionChanged(); + }); + connect(ui->category, &QLineEdit::editingFinished, [&] { + onCategoryChanged(); + }); + + connect(ui->refresh, &QPushButton::clicked, [&] { + onRefreshBrowser(); + }); + connect(ui->visitNexus, &QPushButton::clicked, [&] { + onVisitNexus(); + }); + connect(ui->endorse, &QPushButton::clicked, [&] { + onEndorse(); + }); + connect(ui->track, &QPushButton::clicked, [&] { + onTrack(); + }); + + connect(ui->hasCustomURL, &QCheckBox::toggled, [&] { + onCustomURLToggled(); + }); + connect(ui->customURL, &QLineEdit::editingFinished, [&] { + onCustomURLChanged(); + }); + connect(ui->visitCustomURL, &QPushButton::clicked, [&] { + onVisitCustomURL(); + }); +} + +NexusTab::~NexusTab() +{ + cleanup(); +} + +void NexusTab::cleanup() +{ + if (m_modConnection) { + disconnect(m_modConnection); + m_modConnection = {}; + } +} + +void NexusTab::clear() +{ + ui->modID->clear(); + ui->sourceGame->clear(); + ui->version->clear(); + ui->category->clear(); +#ifdef MO2_WEBENGINE + ui->browser->setPage(new NexusTabWebpage(ui->browser)); +#else + ui->browser->setHtml(""); +#endif + ui->hasCustomURL->setChecked(false); + ui->customURL->clear(); + setHasData(false); +} + +void NexusTab::update() +{ + QScopedValueRollback loading(m_loading, true); + + clear(); + + ui->modID->setText(QString("%1").arg(mod().nexusId())); + + QString gameName = mod().gameName(); + ui->sourceGame->addItem(core().managedGame()->gameName(), + core().managedGame()->gameShortName()); + + if (core().managedGame()->validShortNames().size() == 0) { + ui->sourceGame->setDisabled(true); + } else { + for (auto game : plugin().plugins()) { + for (QString gameName : core().managedGame()->validShortNames()) { + if (game->gameShortName().compare(gameName, Qt::CaseInsensitive) == 0) { + ui->sourceGame->addItem(game->gameName(), game->gameShortName()); + break; + } + } + } + } + + ui->sourceGame->setCurrentIndex(ui->sourceGame->findData(gameName)); + + ui->category->setText(QString("%1").arg(mod().getNexusCategory())); + +#ifdef MO2_WEBENGINE + auto* page = new NexusTabWebpage(ui->browser); + ui->browser->setPage(page); + + connect(page, &NexusTabWebpage::linkClicked, [&](const QUrl& url) { + shell::Open(url); + }); +#else + ui->browser->setOpenExternalLinks(true); +#endif + + ui->endorse->setEnabled((mod().endorsedState() == EndorsedState::ENDORSED_FALSE) || + (mod().endorsedState() == EndorsedState::ENDORSED_NEVER)); + + setHasData(mod().nexusId() >= 0); +} + +void NexusTab::firstActivation() +{ + updateWebpage(); +} + +void NexusTab::setMod(ModInfoPtr mod, MOShared::FilesOrigin* origin) +{ + cleanup(); + + ModInfoDialogTab::setMod(mod, origin); + + m_modConnection = connect(mod.data(), &ModInfo::modDetailsUpdated, [&] { + onModChanged(); + }); +} + +bool NexusTab::usesOriginFiles() const +{ + return false; +} + +void NexusTab::updateVersionColor() +{ + if (mod().version() != mod().newestVersion()) { + ui->version->setStyleSheet("color: red"); + ui->version->setToolTip( + tr("Current Version: %1").arg(mod().newestVersion().canonicalString())); + } else { + ui->version->setStyleSheet("color: green"); + ui->version->setToolTip(tr("No update available")); + } +} + +void NexusTab::updateWebpage() +{ + const int modID = mod().nexusId(); + + if (isValidModID(modID)) { + const QString nexusLink = + NexusInterface::instance().getModURL(modID, mod().gameName()); + + ui->visitNexus->setToolTip(nexusLink); + refreshData(modID); + } else { + onModChanged(); + } + + ui->version->setText(mod().version().displayString()); + ui->hasCustomURL->setChecked(mod().hasCustomURL()); + ui->customURL->setText(mod().url()); + ui->customURL->setEnabled(mod().hasCustomURL()); + ui->visitCustomURL->setEnabled(mod().hasCustomURL()); + ui->visitCustomURL->setToolTip(mod().parseCustomURL().toString()); + + updateTracking(); +} + +void NexusTab::updateTracking() +{ + if (mod().trackedState() == TrackedState::TRACKED_TRUE) { + ui->track->setChecked(true); + ui->track->setText(tr("Tracked")); + } else { + ui->track->setChecked(false); + ui->track->setText(tr("Untracked")); + } +} + +void NexusTab::refreshData(int modID) +{ + if (tryRefreshData(modID)) { + m_requestStarted = true; + } else { + onModChanged(); + } +} + +bool NexusTab::tryRefreshData(int modID) +{ + if (isValidModID(modID) && !m_requestStarted) { + if (mod().updateNXMInfo()) { + ui->browser->setHtml(""); + return true; + } + } + + return false; +} + +void NexusTab::onModChanged() +{ + m_requestStarted = false; + + const QString nexusDescription = mod().getNexusDescription(); + + QString descriptionAsHTML = R"( + + + + + %1 +)"; + + if (nexusDescription.isEmpty()) { + descriptionAsHTML = descriptionAsHTML.arg(tr(R"( +
    +

    This mod does not have a valid Nexus ID. You can add a custom web + page for it in the "Custom URL" box below.

    +
    )")); + } else { + descriptionAsHTML = descriptionAsHTML.arg(BBCode::convertToHTML(nexusDescription)); + } + +#ifdef MO2_WEBENGINE + ui->browser->page()->setHtml(descriptionAsHTML); +#else + ui->browser->setHtml(descriptionAsHTML); +#endif + updateVersionColor(); + updateTracking(); +} + +void NexusTab::onModIDChanged() +{ + if (m_loading) { + return; + } + + const int oldID = mod().nexusId(); + const int newID = ui->modID->text().toInt(); + + if (oldID != newID) { + mod().setNexusID(newID); + mod().setLastNexusQuery(QDateTime::fromSecsSinceEpoch(0)); + +#ifdef MO2_WEBENGINE + ui->browser->page()->setHtml(""); +#else + ui->browser->setHtml(""); +#endif + + if (isValidModID(newID)) { + refreshData(newID); + } + } +} + +void NexusTab::onSourceGameChanged() +{ + if (m_loading) { + return; + } + + for (auto game : plugin().plugins()) { + if (game->gameName() == ui->sourceGame->currentText()) { + mod().setGameName(game->gameShortName()); + mod().setLastNexusQuery(QDateTime::fromSecsSinceEpoch(0)); + refreshData(mod().nexusId()); + return; + } + } +} + +void NexusTab::onVersionChanged() +{ + if (m_loading) { + return; + } + + MOBase::VersionInfo version(ui->version->text()); + mod().setVersion(version); + updateVersionColor(); +} + +void NexusTab::onCategoryChanged() +{ + if (m_loading) { + return; + } + + int category = ui->category->text().toInt(); + mod().setNexusCategory(category); +} + +void NexusTab::onRefreshBrowser() +{ + const auto modID = mod().nexusId(); + + if (isValidModID(modID)) { + mod().setLastNexusQuery(QDateTime::fromSecsSinceEpoch(0)); + updateWebpage(); + } else { + log::info("Mod has no valid Nexus ID, info can't be updated."); + } +} + +void NexusTab::onVisitNexus() +{ + const int modID = mod().nexusId(); + + if (isValidModID(modID)) { + const QString nexusLink = + NexusInterface::instance().getModURL(modID, mod().gameName()); + + shell::Open(QUrl(nexusLink)); + } +} + +void NexusTab::onEndorse() +{ + // use modPtr() instead of mod() or this because the callback may be + // executed after the dialog is closed + core().loggedInAction(parentWidget(), [m = modPtr()] { + m->endorse(true); + }); +} + +void NexusTab::onTrack() +{ + // use modPtr() instead of mod() or this because the callback may be + // executed after the dialog is closed + core().loggedInAction(parentWidget(), [m = modPtr()] { + if (m->trackedState() == TrackedState::TRACKED_TRUE) { + m->track(false); + } else { + m->track(true); + } + }); +} + +void NexusTab::onCustomURLToggled() +{ + if (m_loading) { + return; + } + + mod().setHasCustomURL(ui->hasCustomURL->isChecked()); + ui->customURL->setEnabled(mod().hasCustomURL()); + ui->visitCustomURL->setEnabled(mod().hasCustomURL()); +} + +void NexusTab::onCustomURLChanged() +{ + if (m_loading) { + return; + } + + mod().setCustomURL(ui->customURL->text()); + ui->visitCustomURL->setToolTip(mod().parseCustomURL().toString()); +} + +void NexusTab::onVisitCustomURL() +{ + const QUrl url = mod().parseCustomURL(); + if (url.isValid()) { + shell::Open(url); + } +} diff --git a/src/src/modinfodialognexus.h b/src/src/modinfodialognexus.h new file mode 100644 index 0000000..3eec02c --- /dev/null +++ b/src/src/modinfodialognexus.h @@ -0,0 +1,74 @@ +#ifndef MODINFODIALOGNEXUS_H +#define MODINFODIALOGNEXUS_H + +#include "modinfodialogtab.h" + +#ifdef MO2_WEBENGINE +class NexusTabWebpage : public QWebEnginePage +{ + Q_OBJECT + +public: + NexusTabWebpage(QObject* parent = 0) : QWebEnginePage(parent) {} + + bool acceptNavigationRequest(const QUrl& url, QWebEnginePage::NavigationType type, + bool) override + { + if (type == QWebEnginePage::NavigationTypeLinkClicked) { + emit linkClicked(url); + return false; + } + + return true; + } + +signals: + void linkClicked(const QUrl&); +}; +#endif + +class NexusTab : public ModInfoDialogTab +{ + Q_OBJECT; + +public: + NexusTab(ModInfoDialogTabContext cx); + + ~NexusTab(); + + void clear() override; + void update() override; + void firstActivation() override; + void setMod(ModInfoPtr mod, MOShared::FilesOrigin* origin) override; + bool usesOriginFiles() const override; + +private: + QMetaObject::Connection m_modConnection; + bool m_requestStarted; + bool m_loading; + + void cleanup(); + void updateVersionColor(); + void updateWebpage(); + void updateTracking(); + + void refreshData(int modID); + bool tryRefreshData(int modID); + void onModChanged(); + + void onModIDChanged(); + void onSourceGameChanged(); + void onVersionChanged(); + void onCategoryChanged(); + + void onRefreshBrowser(); + void onVisitNexus(); + void onEndorse(); + void onTrack(); + + void onCustomURLToggled(); + void onCustomURLChanged(); + void onVisitCustomURL(); +}; + +#endif // MODINFODIALOGNEXUS_H diff --git a/src/src/modinfodialogtab.cpp b/src/src/modinfodialogtab.cpp new file mode 100644 index 0000000..862c44e --- /dev/null +++ b/src/src/modinfodialogtab.cpp @@ -0,0 +1,263 @@ +#include "modinfodialogtab.h" +#include "modinfo.h" +#include "shared/filesorigin.h" +#include "texteditor.h" +#include "ui_modinfodialog.h" + +ModInfoDialogTab::ModInfoDialogTab(ModInfoDialogTabContext cx) + : ui(cx.ui), m_core(cx.core), m_plugin(cx.plugin), m_parent(cx.parent), + m_origin(cx.origin), m_tabID(cx.id), m_hasData(false), m_firstActivation(true) +{} + +void ModInfoDialogTab::activated() +{ + if (m_firstActivation) { + m_firstActivation = false; + firstActivation(); + } +} + +void ModInfoDialogTab::resetFirstActivation() +{ + m_firstActivation = true; +} + +void ModInfoDialogTab::update() +{ + // no-op +} + +bool ModInfoDialogTab::feedFile(const QString&, const QString&) +{ + // no-op + return false; +} + +void ModInfoDialogTab::firstActivation() +{ + // no-op +} + +bool ModInfoDialogTab::canClose() +{ + return true; +} + +void ModInfoDialogTab::saveState(Settings&) +{ + // no-op +} + +void ModInfoDialogTab::restoreState(const Settings& s) +{ + // no-op +} + +bool ModInfoDialogTab::deleteRequested() +{ + // no-op + return false; +} + +bool ModInfoDialogTab::canHandleSeparators() const +{ + return false; +} + +bool ModInfoDialogTab::canHandleUnmanaged() const +{ + return false; +} + +bool ModInfoDialogTab::usesOriginFiles() const +{ + return true; +} + +void ModInfoDialogTab::setMod(ModInfoPtr mod, MOShared::FilesOrigin* origin) +{ + m_mod = mod; + m_origin = origin; +} + +ModInfo& ModInfoDialogTab::mod() const +{ + Q_ASSERT(m_mod); + return *m_mod; +} + +ModInfoPtr ModInfoDialogTab::modPtr() const +{ + Q_ASSERT(m_mod); + return m_mod; +} + +MOShared::FilesOrigin* ModInfoDialogTab::origin() const +{ + return m_origin; +} + +ModInfoTabIDs ModInfoDialogTab::tabID() const +{ + return m_tabID; +} + +bool ModInfoDialogTab::hasData() const +{ + return m_hasData; +} + +OrganizerCore& ModInfoDialogTab::core() +{ + return m_core; +} + +PluginContainer& ModInfoDialogTab::plugin() +{ + return m_plugin; +} + +QWidget* ModInfoDialogTab::parentWidget() +{ + return m_parent; +} + +void ModInfoDialogTab::emitOriginModified() +{ + if (m_origin) { + emit originModified(m_origin->getID()); + } +} + +void ModInfoDialogTab::emitModOpen(QString name) +{ + emit modOpen(name); +} + +void ModInfoDialogTab::setHasData(bool b) +{ + if (m_hasData != b) { + m_hasData = b; + emit hasDataChanged(); + } +} + +void ModInfoDialogTab::setFocus() +{ + emit wantsFocus(); +} + +NotesTab::NotesTab(ModInfoDialogTabContext cx) : ModInfoDialogTab(std::move(cx)) +{ + connect(ui->comments, &QLineEdit::editingFinished, [&] { + onComments(); + }); + connect(ui->notes, &HTMLEditor::editingFinished, [&] { + onNotes(); + }); + connect(ui->setColorButton, &QPushButton::clicked, [&] { + onSetColor(); + }); + connect(ui->resetColorButton, &QPushButton::clicked, [&] { + onResetColor(); + }); +} + +void NotesTab::updateCommentsColor(bool clear) +{ + QPalette commentPalette = QPalette(); + + if (!clear) { + auto modColor = mod().color(); + if (modColor.isValid()) { + commentPalette.setColor(QPalette::Base, modColor); + commentPalette.setColor(QPalette::Text, ColorSettings::idealTextColor(modColor)); + } + } + ui->comments->setPalette(commentPalette); +} + +void NotesTab::clear() +{ + ui->comments->clear(); + ui->notes->clear(); + updateCommentsColor(true); + setHasData(false); +} + +void NotesTab::update() +{ + const auto comments = mod().comments(); + const auto notes = mod().notes(); + + ui->comments->setText(comments); + ui->notes->setText(notes); + updateCommentsColor(); + checkHasData(); +} + +bool NotesTab::canHandleSeparators() const +{ + return true; +} + +void NotesTab::onComments() +{ + mod().setComments(ui->comments->text()); + checkHasData(); +} + +void NotesTab::onNotes() +{ + // Avoid saving html stub if notes field is empty. + if (ui->notes->toPlainText().isEmpty()) { + mod().setNotes({}); + } else { + mod().setNotes(ui->notes->toHtml()); + } + + checkHasData(); +} + +void NotesTab::onSetColor() +{ + QColorDialog dialog(m_parent); + dialog.setOption(QColorDialog::ShowAlphaChannel); + + QColor currentColor = mod().color(); + + if (currentColor.isValid()) { + dialog.setCurrentColor(currentColor); + } + + if (!dialog.exec()) + return; + + currentColor = dialog.currentColor(); + if (!currentColor.isValid()) + return; + + mod().setColor(currentColor); + updateCommentsColor(); + checkHasData(); +} + +void NotesTab::onResetColor() +{ + QColor color = QColor(); + + mod().setColor(color); + updateCommentsColor(); + checkHasData(); +} + +bool NotesTab::usesOriginFiles() const +{ + return false; +} + +void NotesTab::checkHasData() +{ + setHasData(!ui->comments->text().isEmpty() || !ui->notes->toPlainText().isEmpty() || + mod().color().isValid()); +} diff --git a/src/src/modinfodialogtab.h b/src/src/modinfodialogtab.h new file mode 100644 index 0000000..deb5936 --- /dev/null +++ b/src/src/modinfodialogtab.h @@ -0,0 +1,302 @@ +#ifndef MODINFODIALOGTAB_H +#define MODINFODIALOGTAB_H + +#include "modinfodialogfwd.h" +#include + +namespace MOShared +{ +class FilesOrigin; +} +namespace Ui +{ +class ModInfoDialog; +} + +class Settings; +class OrganizerCore; + +// helper struct to avoid passing too much stuff to tab constructors +// +struct ModInfoDialogTabContext +{ + OrganizerCore& core; + PluginContainer& plugin; + QWidget* parent; + Ui::ModInfoDialog* ui; + ModInfoTabIDs id; + ModInfoPtr mod; + MOShared::FilesOrigin* origin; + + ModInfoDialogTabContext(OrganizerCore& core, PluginContainer& plugin, QWidget* parent, + Ui::ModInfoDialog* ui, ModInfoTabIDs id, ModInfoPtr mod, + MOShared::FilesOrigin* origin) + : core(core), plugin(plugin), parent(parent), ui(ui), id(id), mod(mod), + origin(origin) + {} +}; + +// base class for all tabs in the mod info dialog +// +// when the dialog is opened or when next/previous is clicked, the sequence is: +// setMod(), clear(), feedFile() an update() +// +// when the dialog is closed, canClose() is called on all tabs +// +// when a tab is selected for the first time for the current mod, +// firstActivation() is called; this is used by NexusTab to refresh stuff +// +// when the dialog is first shown, restoreState() is called on all tabs and +// saveState() is called when the dialog is closed +// +// there isn't a good framework for keyboard shortcuts because only the delete +// key is used for now, which calls deletedRequested() on all tabs until one +// returns true +// +// each tab override canHandleSeparators() and canHandleUnmanaged() to return +// true if they can handle separators or unmanaged mods; if these return false +// (which they do by default), the tabs will be removed from the widget entirely +// +// when tabs modify the origin and call emitOriginModified() (such as the +// conflicts tabs), all tabs that return true in usesOriginFiles() will go +// through the full update sequence as above +// +// tabs can call emitModOpen() to request showing a different mod +// +// hasDataChanged() should be called when a tab goes from having data to being +// empty or vice versa; this will update the tab text colour +// +class ModInfoDialogTab : public QObject +{ + Q_OBJECT; + +public: + ModInfoDialogTab(const ModInfoDialogTab&) = delete; + ModInfoDialogTab& operator=(const ModInfoDialogTab&) = delete; + ModInfoDialogTab(ModInfoDialogTab&&) = default; + ModInfoDialogTab& operator=(ModInfoDialogTab&&) = default; + virtual ~ModInfoDialogTab() = default; + + // called by ModInfoDialog every time this tab is selected; this will call + // firstActivation() the first time it's called, until resetFirstActivation() + // is called + // + void activated(); + + // called by ModInfoDialog when the selected mod has changed, to make sure + // activated() will call firstActivation() next time + // + void resetFirstActivation(); + + // called when the selected mod changed, `mod` can never be empty, but + // `origin` can (if the mod is not active, for example) + // + // derived classes can override this to connect to events on the mod for + // examples (see NexusTab), but must call the base class implementation + // + virtual void setMod(ModInfoPtr mod, MOShared::FilesOrigin* origin); + + // this tab should clear its user interface; clear() will always be called + // before feedFile() and update() + // + virtual void clear() = 0; + + // the dialog will go through each file in the mod and call feedFile() + // with it on all tabs; if a tab handles the file, it should return true to + // prevent other tabs from displaying it + // + // this prevents individual tabs from having to go through the filesystem + // independently, which would kill performance, but it cannot be the only way + // to update tabs because some of them don't actually use files (like + // NotesTab) or they use the internal structures such as `FilesOrigin` (like + // ConflictsTab) + // + // once all the files have been fed to tabs, update() will be called; tabs + // that need to do additional work after being fed files, or tabs that are + // unable to use feedFile() at all can use update() to do work + // + virtual bool feedFile(const QString& rootPath, const QString& filename); + + // called after all the files on the filesystem have been sent through + // feedFile() + // + // this can be used to do a final processing of the files handled by + // feedFile() (ImagesTab will set up the scrollbars, for example) or something + // completely different (CategoriesTab will fill up the lists) + // + virtual void update(); + + // called when a tab is visible for the first time for the current mod, can + // be used to do expensive work that's not worth doing until the tab is + // actually selected by the user + // + virtual void firstActivation(); + + // called when closing the dialog, can return false to stop the dialog from + // closing + // + // this is typically used by tabs that require manual saving, like text files; + // tabs that refuse to close should focus themselves before showing whatever + // confirmation they have + // + virtual bool canClose(); + + // called after the dialog is closed, tabs should save whatever UI state they + // want + // + virtual void saveState(Settings& s); + + // called before the is shown, tabs should restore whatever UI state they + // saved in saveState() + // + virtual void restoreState(const Settings& s); + + // called on the selected tab when the Delete key is pressed on the keyboard; + // tabs _must_ check which widget currently has focus to decide whether this + // should be handled or not; do not blindly delete stuff when this is called + // + // if the delete request was handled, this should return true + // + virtual bool deleteRequested(); + + // return true if this tab can handle a separator mod, defaults to false; + // when this returns false, the tab is removed from the widget entirely + // + // if a tab can show meaningful information about a separator (like + // categories or notes), it should return true + // + virtual bool canHandleSeparators() const; + + // return true if this tab can handle unmanaged mods, defaults to false; + // when this returns false, the tab is removed from the widget entirely + // + virtual bool canHandleUnmanaged() const; + + // return true if this tab uses the files from the mod's origin, defaults to + // false + // + // tabs that do not care about the files inside a mod should return false, + // such as the notes or categories tab + // + // mods that return true will be updated anytime a tab calls + // emitOriginModifed() + // + virtual bool usesOriginFiles() const; + + // returns the currently selected mod + // + ModInfo& mod() const; + + // returns the currently selected mod, can never be empty + // + ModInfoPtr modPtr() const; + + // returns the origin of the selected mod; this can be null for mods that + // don't have an origin, like deactivated mods + // + MOShared::FilesOrigin* origin() const; + + // return this tab's ID + // + ModInfoTabIDs tabID() const; + + // returns whether this tab has data; derived classes should call setHasData() + // + bool hasData() const; + +signals: + // emitted when a tab modified the files in a mod + // + void originModified(int originID); + + // emitted when a tab wants to open a mod by name + // + void modOpen(QString name); + + // emitted when a tab used to have data and is now empty, or vice versa + // + void hasDataChanged(); + + // emitted when a tab wants focus + // + void wantsFocus(); + +protected: + Ui::ModInfoDialog* ui; + + ModInfoDialogTab(ModInfoDialogTabContext cx); + + OrganizerCore& core(); + PluginContainer& plugin(); + QWidget* parentWidget(); + + // emits originModified + // + void emitOriginModified(); + + // emits modOpen + // + void emitModOpen(QString name); + + // emits hasDataChanged + // + void setHasData(bool b); + + // emits wantsFocus + // + void setFocus(); + + // parent widget, used to display modal dialogs + QWidget* m_parent; + +private: + // core + OrganizerCore& m_core; + + // plugin + PluginContainer& m_plugin; + + // current mod, never null + ModInfoPtr m_mod; + + // current mod origin, may be null + MOShared::FilesOrigin* m_origin; + + // tab ID + ModInfoTabIDs m_tabID; + + // whether the tab has data + bool m_hasData; + + // true if the tab has never been selected for the current mod + bool m_firstActivation; +}; + +// the Notes tab +// +class NotesTab : public ModInfoDialogTab +{ +public: + NotesTab(ModInfoDialogTabContext cx); + + void clear() override; + void update() override; + + // returns true, separators can have notes + // + bool canHandleSeparators() const override; + + // returns false, notes don't use files + // + bool usesOriginFiles() const override; + +private: + void updateCommentsColor(bool clear = false); + void onComments(); + void onNotes(); + void onSetColor(); + void onResetColor(); + void checkHasData(); +}; + +#endif // MODINFODIALOGTAB_H diff --git a/src/src/modinfodialogtextfiles.cpp b/src/src/modinfodialogtextfiles.cpp new file mode 100644 index 0000000..8fbc8ec --- /dev/null +++ b/src/src/modinfodialogtextfiles.cpp @@ -0,0 +1,236 @@ +#include "modinfodialogtextfiles.h" +#include "modinfodialog.h" +#include "settings.h" +#include "ui_modinfodialog.h" +#include + +class FileListModel : public QAbstractItemModel +{ +public: + void clear() + { + beginResetModel(); + m_files.clear(); + endResetModel(); + } + + QModelIndex index(int row, int col, const QModelIndex& = {}) const override + { + return createIndex(row, col); + } + + QModelIndex parent(const QModelIndex&) const override { return {}; } + + int rowCount(const QModelIndex& index = {}) const override + { + // no child nodes + if (index.isValid()) + return 0; + + return static_cast(m_files.size()); + } + + int columnCount(const QModelIndex& = {}) const override { return 1; } + + QVariant data(const QModelIndex& index, int role) const override + { + if (role == Qt::DisplayRole) { + const auto row = index.row(); + if (row < 0) { + return {}; + } + + const auto i = static_cast(row); + if (i >= m_files.size()) { + return {}; + } + + return m_files[i].text; + } + + return {}; + } + + void add(const QString& rootPath, QString fullPath) + { + QString text = fullPath.mid(rootPath.length() + 1); + m_files.emplace_back(std::move(fullPath), std::move(text)); + } + + void finished() + { + beginResetModel(); + + std::sort(m_files.begin(), m_files.end(), [](const auto& a, const auto& b) { + return (naturalCompare(a.text, b.text) < 0); + }); + + endResetModel(); + } + + QString fullPath(const QModelIndex& index) const + { + const auto row = index.row(); + if (row < 0) { + return {}; + } + + const auto i = static_cast(row); + if (i >= m_files.size()) { + return {}; + } + + return m_files[i].fullPath; + } + +private: + struct File + { + QString fullPath; + QString text; + + File(QString fp, QString t) : fullPath(std::move(fp)), text(std::move(t)) {} + }; + + std::deque m_files; +}; + +GenericFilesTab::GenericFilesTab(ModInfoDialogTabContext cx, QListView* list, + QSplitter* sp, TextEditor* e, QLineEdit* filter) + : ModInfoDialogTab(std::move(cx)), m_list(list), m_editor(e), m_splitter(sp), + m_model(new FileListModel) +{ + m_list->setModel(m_model); + m_editor->setupToolbar(); + + m_splitter->setSizes({200, 1}); + m_splitter->setStretchFactor(0, 0); + m_splitter->setStretchFactor(1, 1); + + m_filter.setEdit(filter); + m_filter.setList(m_list); + + QObject::connect(m_list->selectionModel(), &QItemSelectionModel::currentRowChanged, + [&](auto current, auto previous) { + onSelection(current, previous); + }); +} + +void GenericFilesTab::clear() +{ + m_model->clear(); + select({}); + setHasData(false); +} + +bool GenericFilesTab::canClose() +{ + if (!m_editor->dirty()) { + return true; + } + + setFocus(); + + const int res = QMessageBox::question( + parentWidget(), QObject::tr("Save changes?"), + QObject::tr("Save changes to \"%1\"?").arg(m_editor->filename()), + QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel); + + if (res == QMessageBox::Cancel) { + return false; + } + + if (res == QMessageBox::Yes) { + m_editor->save(); + } + + return true; +} + +bool GenericFilesTab::feedFile(const QString& rootPath, const QString& fullPath) +{ + if (wantsFile(rootPath, fullPath)) { + m_model->add(rootPath, fullPath); + return true; + } + + return false; +} + +void GenericFilesTab::update() +{ + m_model->finished(); + setHasData(m_model->rowCount() > 0); +} + +void GenericFilesTab::saveState(Settings& s) +{ + s.geometry().saveState(m_splitter); +} + +void GenericFilesTab::restoreState(const Settings& s) +{ + s.geometry().restoreState(m_splitter); +} + +void GenericFilesTab::onSelection(const QModelIndex& current, + const QModelIndex& previous) +{ + if (!canClose()) { + m_list->selectionModel()->select(previous, QItemSelectionModel::Current); + return; + } + + select(current); +} + +void GenericFilesTab::select(const QModelIndex& index) +{ + if (!index.isValid()) { + m_editor->clear(); + m_editor->setEnabled(false); + return; + } + + m_editor->setEnabled(true); + m_editor->load(m_model->fullPath(m_filter.mapToSource(index))); +} + +TextFilesTab::TextFilesTab(ModInfoDialogTabContext cx) + : GenericFilesTab(cx, cx.ui->textFileList, cx.ui->tabTextSplitter, + cx.ui->textFileEditor, cx.ui->textFileFilter) +{} + +bool TextFilesTab::wantsFile(const QString& rootPath, const QString& fullPath) const +{ + static const QString extensions[] = {".txt", ".json", ".cfg", ".log", ".toml"}; + + for (const auto& e : extensions) { + if (fullPath.endsWith(e, Qt::CaseInsensitive)) { + return true; + } + } + + return false; +} + +IniFilesTab::IniFilesTab(ModInfoDialogTabContext cx) + : GenericFilesTab(cx, cx.ui->iniFileList, cx.ui->tabIniSplitter, + cx.ui->iniFileEditor, cx.ui->iniFileFilter) +{} + +bool IniFilesTab::wantsFile(const QString& rootPath, const QString& fullPath) const +{ + static const QString extensions[] = {".ini"}; + static const QString meta("meta.ini"); + + for (const auto& e : extensions) { + if (fullPath.endsWith(e, Qt::CaseInsensitive)) { + if (!fullPath.endsWith(meta)) { + return true; + } + } + } + + return false; +} diff --git a/src/src/modinfodialogtextfiles.h b/src/src/modinfodialogtextfiles.h new file mode 100644 index 0000000..e276b81 --- /dev/null +++ b/src/src/modinfodialogtextfiles.h @@ -0,0 +1,62 @@ +#ifndef MODINFODIALOGTEXTFILES_H +#define MODINFODIALOGTEXTFILES_H + +#include "filterwidget.h" +#include "modinfodialogtab.h" +#include +#include + +using namespace MOBase; + +class FileListItem; +class FileListModel; +class TextEditor; + +class GenericFilesTab : public ModInfoDialogTab +{ + Q_OBJECT; + +public: + void clear() override; + bool canClose() override; + bool feedFile(const QString& rootPath, const QString& fullPath) override; + void update() override; + void saveState(Settings& s) override; + void restoreState(const Settings& s) override; + +protected: + QListView* m_list; + TextEditor* m_editor; + QSplitter* m_splitter; + FileListModel* m_model; + FilterWidget m_filter; + + GenericFilesTab(ModInfoDialogTabContext cx, QListView* list, QSplitter* splitter, + TextEditor* editor, QLineEdit* filter); + + virtual bool wantsFile(const QString& rootPath, const QString& fullPath) const = 0; + +private: + void onSelection(const QModelIndex& current, const QModelIndex& previous); + void select(const QModelIndex& index); +}; + +class TextFilesTab : public GenericFilesTab +{ +public: + TextFilesTab(ModInfoDialogTabContext cx); + +protected: + bool wantsFile(const QString& rootPath, const QString& fullPath) const override; +}; + +class IniFilesTab : public GenericFilesTab +{ +public: + IniFilesTab(ModInfoDialogTabContext cx); + +protected: + bool wantsFile(const QString& rootPath, const QString& fullPath) const override; +}; + +#endif // MODINFODIALOGTEXTFILES_H diff --git a/src/src/modinfoforeign.cpp b/src/src/modinfoforeign.cpp new file mode 100644 index 0000000..4271648 --- /dev/null +++ b/src/src/modinfoforeign.cpp @@ -0,0 +1,68 @@ +#include "modinfoforeign.h" + +#include "iplugingame.h" +#include "organizercore.h" +#include "utility.h" + +#include + +using namespace MOBase; +using namespace MOShared; + +QDateTime ModInfoForeign::creationTime() const +{ + return m_CreationTime; +} + +std::vector ModInfoForeign::getFlags() const +{ + std::vector result = ModInfoWithConflictInfo::getFlags(); + result.push_back(FLAG_FOREIGN); + + if (m_PluginSelected) { + result.push_back(ModInfo::FLAG_PLUGIN_SELECTED); + } + + return result; +} + +int ModInfoForeign::getHighlight() const +{ + return m_PluginSelected ? ModInfo::HIGHLIGHT_PLUGIN : ModInfo::HIGHLIGHT_NONE; +} + +QString ModInfoForeign::getDescription() const +{ + return tr("This pseudo mod represents content managed outside MO. It isn't modified " + "by MO."); +} + +ModInfoForeign::ModInfoForeign(const QString& modName, const QString& referenceFile, + const QStringList& archives, ModInfo::EModType modType, + OrganizerCore& core) + : ModInfoWithConflictInfo(core), m_ReferenceFile(referenceFile), + m_Archives(archives), m_ModType(modType) +{ + m_CreationTime = QFileInfo(referenceFile).birthTime(); + IPluginGame const* game = core.managedGame(); + QList directories = {game->dataDirectory()}; + directories.append(game->secondaryDataDirectories().values()); + for (QDir directory : directories) { + if (referenceFile.startsWith(directory.absolutePath(), Qt::CaseInsensitive)) { + m_BaseDirectory = directory.absolutePath(); + } + } + switch (modType) { + case ModInfo::EModType::MOD_DLC: + m_Name = tr("DLC: ") + modName; + m_InternalName = QString("DLC: ") + modName; + break; + case ModInfo::EModType::MOD_CC: + m_Name = tr("Creation Club: ") + modName; + m_InternalName = QString("Creation Club: ") + modName; + break; + default: + m_Name = tr("Unmanaged: ") + modName; + m_InternalName = QString("Unmanaged: ") + modName; + } +} diff --git a/src/src/modinfoforeign.h b/src/src/modinfoforeign.h new file mode 100644 index 0000000..60d03ea --- /dev/null +++ b/src/src/modinfoforeign.h @@ -0,0 +1,124 @@ +#ifndef MODINFOFOREIGN_H +#define MODINFOFOREIGN_H + +#include + +#include "modinfowithconflictinfo.h" + +class ModInfoForeign : public ModInfoWithConflictInfo +{ + + Q_OBJECT + + friend class ModInfo; + +public: + virtual bool updateAvailable() const override { return false; } + virtual bool updateIgnored() const override { return false; } + virtual bool downgradeAvailable() const override { return false; } + virtual bool updateNXMInfo() override { return false; } + virtual void setCategory(int, bool) override {} + virtual bool setName(const QString&) override { return false; } + virtual void setComments(const QString&) override {} + virtual void setNotes(const QString&) override {} + virtual void setGameName(const QString& gameName) override {} + virtual void setNexusID(int) override {} + virtual void setNewestVersion(const MOBase::VersionInfo&) override {} + virtual void ignoreUpdate(bool) override {} + virtual void setNexusDescription(const QString&) override {} + virtual void setInstallationFile(const QString&) override {} + virtual void addNexusCategory(int) override {} + virtual void setIsEndorsed(bool) override {} + virtual void setNeverEndorse() override {} + virtual void setIsTracked(bool) override {} + virtual void endorse(bool) override {} + virtual void track(bool) override {} + virtual bool isEmpty() const override { return false; } + virtual QString name() const override { return m_Name; } + virtual QString internalName() const override { return m_InternalName; } + virtual QString comments() const override { return ""; } + virtual QString notes() const override { return ""; } + virtual QDateTime creationTime() const override; + virtual QString absolutePath() const override { return m_BaseDirectory; } + virtual MOBase::VersionInfo newestVersion() const override { return QString(); } + virtual MOBase::VersionInfo ignoredVersion() const override { return QString(); } + virtual QString installationFile() const override { return ""; } + virtual bool converted() const override { return false; } + virtual bool validated() const override { return false; } + virtual QString gameName() const override { return ""; } + virtual int nexusId() const override { return -1; } + virtual bool isForeign() const override { return true; } + virtual QDateTime getExpires() const override { return QDateTime(); } + virtual std::vector getIniTweaks() const override + { + return std::vector(); + } + virtual std::vector getFlags() const override; + virtual int getHighlight() const override; + virtual QString getDescription() const override; + virtual int getNexusFileStatus() const override { return 0; } + virtual void setNexusFileStatus(int) override {} + virtual QDateTime getLastNexusUpdate() const override { return QDateTime(); } + virtual void setLastNexusUpdate(QDateTime) override {} + virtual int getNexusCategory() const override { return 0; } + virtual void setNexusCategory(int) override {} + virtual QDateTime getLastNexusQuery() const override { return QDateTime(); } + virtual void setLastNexusQuery(QDateTime) override {} + virtual QDateTime getNexusLastModified() const override { return QDateTime(); } + virtual void setNexusLastModified(QDateTime) override {} + virtual QString getNexusDescription() const override { return QString(); } + virtual QString author() const override { return QString(); } + virtual void setAuthor(const QString&) override {} + virtual QString uploader() const override { return QString(); } + virtual void setUploader(const QString&) override {} + virtual QString uploaderUrl() const override { return QString(); } + virtual void setUploaderUrl(const QString&) override {} + virtual QStringList archives(bool = false) override { return m_Archives; } + virtual QStringList stealFiles() const override + { + return m_Archives + QStringList(m_ReferenceFile); + } + virtual bool alwaysEnabled() const override { return true; } + virtual void addInstalledFile(int, int) override {} + virtual std::set> installedFiles() const override { return {}; } + + virtual QVariant pluginSetting(const QString& pluginName, const QString& key, + const QVariant& defaultValue) const override + { + return defaultValue; + } + virtual std::map + pluginSettings(const QString& pluginName) const override + { + return {}; + } + virtual bool setPluginSetting(const QString& pluginName, const QString& key, + const QVariant& value) override + { + return false; + } + virtual std::map + clearPluginSettings(const QString& pluginName) override + { + return {}; + } + + ModInfo::EModType modType() const { return m_ModType; } + +protected: + ModInfoForeign(const QString& modName, const QString& referenceFile, + const QStringList& archives, ModInfo::EModType modType, + OrganizerCore& core); + +private: + QString m_Name; + QString m_InternalName; + QString m_ReferenceFile; + QString m_BaseDirectory; + QStringList m_Archives; + QDateTime m_CreationTime; + int m_Priority; + ModInfo::EModType m_ModType; +}; + +#endif // MODINFOFOREIGN_H diff --git a/src/src/modinfooverwrite.cpp b/src/src/modinfooverwrite.cpp new file mode 100644 index 0000000..52007e9 --- /dev/null +++ b/src/src/modinfooverwrite.cpp @@ -0,0 +1,88 @@ +#include "modinfooverwrite.h" + +#include "settings.h" +#include "shared/appconfig.h" + +#include "organizercore.h" +#include +#include + +ModInfoOverwrite::ModInfoOverwrite(OrganizerCore& core) : ModInfoWithConflictInfo(core) +{} + +bool ModInfoOverwrite::isEmpty() const +{ + QDirIterator iter(absolutePath(), QDir::NoDotAndDotDot | QDir::Files | QDir::Dirs); + if (!iter.hasNext()) + return true; + while (iter.hasNext()) { + iter.next(); + if (iter.fileInfo().isDir() && + !m_Core.managedGame()->getModMappings().keys().contains(iter.fileName(), + Qt::CaseInsensitive)) + return false; + if (iter.fileInfo().isDir() && + m_Core.managedGame()->getModMappings().keys().contains(iter.fileName(), + Qt::CaseInsensitive)) { + if (QDir(iter.filePath()).count() > 2) { + return false; + } + } + if (iter.fileInfo().isFile() && iter.fileName() != "meta.ini") + return false; + } + + return true; +} + +QString ModInfoOverwrite::absolutePath() const +{ + return Settings::instance().paths().overwrite(); +} + +std::vector ModInfoOverwrite::getFlags() const +{ + std::vector result; + result.push_back(FLAG_OVERWRITE); + if (m_PluginSelected) + result.push_back(FLAG_PLUGIN_SELECTED); + for (auto flag : ModInfoWithConflictInfo::getFlags()) + result.push_back(flag); + return result; +} + +std::vector ModInfoOverwrite::getConflictFlags() const +{ + std::vector result; + result.push_back(FLAG_OVERWRITE_CONFLICT); + for (auto flag : ModInfoWithConflictInfo::getConflictFlags()) + result.push_back(flag); + return result; +} + +int ModInfoOverwrite::getHighlight() const +{ + int highlight = + (isValid() ? HIGHLIGHT_IMPORTANT : HIGHLIGHT_INVALID) | HIGHLIGHT_CENTER; + auto flags = getFlags(); + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_PLUGIN_SELECTED) != + flags.end()) + highlight |= HIGHLIGHT_PLUGIN; + return highlight; +} + +QString ModInfoOverwrite::getDescription() const +{ + return tr("This pseudo mod contains files from the virtual data tree that got " + "modified (i.e. by the construction kit)"); +} + +QStringList ModInfoOverwrite::archives(bool checkOnDisk) +{ + QStringList result; + QDir dir(this->absolutePath()); + for (const QString& archive : dir.entryList(QStringList({"*.bsa", "*.ba2"}))) { + result.append(this->absolutePath() + "/" + archive); + } + return result; +} diff --git a/src/src/modinfooverwrite.h b/src/src/modinfooverwrite.h new file mode 100644 index 0000000..0d7660e --- /dev/null +++ b/src/src/modinfooverwrite.h @@ -0,0 +1,108 @@ +#ifndef MODINFOOVERWRITE_H +#define MODINFOOVERWRITE_H + +#include + +#include "modinfowithconflictinfo.h" + +#include + +class ModInfoOverwrite : public ModInfoWithConflictInfo +{ + + Q_OBJECT + + friend class ModInfo; + +public: + virtual bool updateAvailable() const override { return false; } + virtual bool updateIgnored() const override { return false; } + virtual bool downgradeAvailable() const override { return false; } + virtual bool updateNXMInfo() override { return false; } + virtual void setCategory(int, bool) override {} + virtual bool setName(const QString&) override { return false; } + virtual void setComments(const QString&) override {} + virtual void setNotes(const QString&) override {} + virtual void setGameName(const QString& gameName) override {} + virtual void setNexusID(int) override {} + virtual void setNewestVersion(const MOBase::VersionInfo&) override {} + virtual void ignoreUpdate(bool) override {} + virtual void setNexusDescription(const QString&) override {} + virtual void setInstallationFile(const QString&) override {} + virtual void addNexusCategory(int) override {} + virtual void setIsEndorsed(bool) override {} + virtual void setNeverEndorse() override {} + virtual void setIsTracked(bool) override {} + virtual void endorse(bool) override {} + virtual void track(bool) override {} + virtual bool alwaysEnabled() const override { return true; } + virtual bool isEmpty() const override; + virtual QString name() const override { return "Overwrite"; } + virtual QString comments() const override { return ""; } + virtual QString notes() const override { return ""; } + virtual QDateTime creationTime() const override { return QDateTime(); } + virtual QString absolutePath() const override; + virtual MOBase::VersionInfo newestVersion() const override { return QString(); } + virtual MOBase::VersionInfo ignoredVersion() const override { return QString(); } + virtual QString installationFile() const override { return ""; } + virtual bool converted() const override { return false; } + virtual bool validated() const override { return false; } + virtual QString gameName() const override { return ""; } + virtual int nexusId() const override { return -1; } + virtual bool isOverwrite() const override { return true; } + virtual QDateTime getExpires() const override { return QDateTime(); } + virtual std::vector getIniTweaks() const override + { + return std::vector(); + } + virtual std::vector getFlags() const override; + virtual std::vector getConflictFlags() const override; + virtual int getHighlight() const override; + virtual QString getDescription() const override; + virtual int getNexusFileStatus() const override { return 0; } + virtual void setNexusFileStatus(int) override {} + virtual QDateTime getLastNexusUpdate() const override { return QDateTime(); } + virtual void setLastNexusUpdate(QDateTime) override {} + virtual QDateTime getLastNexusQuery() const override { return QDateTime(); } + virtual void setLastNexusQuery(QDateTime) override {} + virtual QDateTime getNexusLastModified() const override { return QDateTime(); } + virtual void setNexusLastModified(QDateTime) override {} + virtual QString getNexusDescription() const override { return QString(); } + virtual void setNexusCategory(int) override {} + virtual int getNexusCategory() const override { return 0; } + virtual QString author() const override { return QString(); } + virtual void setAuthor(const QString&) override {} + virtual QString uploader() const override { return QString(); } + virtual void setUploader(const QString&) override {} + virtual QString uploaderUrl() const override { return QString(); } + virtual void setUploaderUrl(const QString&) override {} + virtual QStringList archives(bool checkOnDisk = false) override; + virtual void addInstalledFile(int, int) override {} + virtual std::set> installedFiles() const override { return {}; } + + virtual QVariant pluginSetting(const QString& pluginName, const QString& key, + const QVariant& defaultValue) const override + { + return defaultValue; + } + virtual std::map + pluginSettings(const QString& pluginName) const override + { + return {}; + } + virtual bool setPluginSetting(const QString& pluginName, const QString& key, + const QVariant& value) override + { + return false; + } + virtual std::map + clearPluginSettings(const QString& pluginName) override + { + return {}; + } + +private: + ModInfoOverwrite(OrganizerCore& core); +}; + +#endif // MODINFOOVERWRITE_H diff --git a/src/src/modinforegular.cpp b/src/src/modinforegular.cpp new file mode 100644 index 0000000..808b572 --- /dev/null +++ b/src/src/modinforegular.cpp @@ -0,0 +1,1043 @@ +#include "modinforegular.h" + +#include "categories.h" +#include "messagedialog.h" +#include "moddatacontent.h" +#include "organizercore.h" +#include "plugincontainer.h" +#include "report.h" +#include "settings.h" +#include +#include + +#include +#include +#include + +#include + +using namespace MOBase; +using namespace MOShared; + +namespace +{ +// Arguably this should be a class static or we should be using FileString rather +// than QString for the names. Or both. +static bool ByName(const ModInfo::Ptr& LHS, const ModInfo::Ptr& RHS) +{ + return QString::compare(LHS->name(), RHS->name(), Qt::CaseInsensitive) < 0; +} + +QString loadMetaPath(const QString& value) +{ + if (value.isEmpty()) { + return value; + } + + if (MOBase::isWindowsDrivePath(value) || QDir::isAbsolutePath(value)) { + return MOBase::normalizePathForHost(value); + } + + return QDir::fromNativeSeparators(value); +} + +QString storeMetaPath(const QString& value) +{ + if (value.isEmpty()) { + return value; + } + + if (MOBase::isWindowsDrivePath(value) || QDir::isAbsolutePath(value)) { + return MOBase::normalizePathForWine(value); + } + + return QDir::fromNativeSeparators(value); +} +} // namespace + +ModInfoRegular::ModInfoRegular(const QDir& path, OrganizerCore& core) + : ModInfoWithConflictInfo(core), m_Name(path.dirName()), + m_Path(path.absolutePath()), m_Repository(), + m_GameName(core.managedGame()->gameShortName()), m_IsAlternate(false), + m_Converted(false), m_Validated(false), m_MetaInfoChanged(false), + m_EndorsedState(EndorsedState::ENDORSED_UNKNOWN), + m_TrackedState(TrackedState::TRACKED_UNKNOWN), + m_NexusBridge(&core.pluginContainer()) +{ + m_CreationTime = QFileInfo(path.absolutePath()).birthTime(); + // read out the meta-file for information + readMeta(); + if (m_GameName.compare(core.managedGame()->gameShortName(), Qt::CaseInsensitive) != 0) + if (!core.managedGame()->primarySources().contains(m_GameName, Qt::CaseInsensitive)) + m_IsAlternate = true; + + // populate m_Archives + m_Archives = QStringList(); + if (Settings::instance().archiveParsing()) { + archives(true); + } + + connect(&m_NexusBridge, + SIGNAL(descriptionAvailable(QString, int, QVariant, QVariant)), this, + SLOT(nxmDescriptionAvailable(QString, int, QVariant, QVariant))); + connect(&m_NexusBridge, SIGNAL(endorsementToggled(QString, int, QVariant, QVariant)), + this, SLOT(nxmEndorsementToggled(QString, int, QVariant, QVariant))); + connect(&m_NexusBridge, SIGNAL(trackingToggled(QString, int, QVariant, bool)), this, + SLOT(nxmTrackingToggled(QString, int, QVariant, bool))); + connect(&m_NexusBridge, + SIGNAL(requestFailed(QString, int, int, QVariant, int, QString)), this, + SLOT(nxmRequestFailed(QString, int, int, QVariant, int, QString))); +} + +ModInfoRegular::~ModInfoRegular() +{ + try { + saveMeta(); + } catch (const std::exception& e) { + log::error("failed to save meta information for \"{}\": {}", m_Name, e.what()); + } +} + +bool ModInfoRegular::isEmpty() const +{ + QDirIterator iter(m_Path, QDir::NoDotAndDotDot | QDir::Files | QDir::Dirs); + if (!iter.hasNext()) + return true; + iter.next(); + if ((iter.fileName() == "meta.ini") && !iter.hasNext()) + return true; + return false; +} + +void ModInfoRegular::readMeta() +{ + QSettings metaFile(m_Path + "/meta.ini", QSettings::IniFormat); + m_Comments = metaFile.value("comments", "").toString(); + m_Notes = metaFile.value("notes", "").toString(); + QString tempGameName = metaFile.value("gameName", m_GameName).toString(); + if (tempGameName.size()) + m_GameName = tempGameName; + m_NexusID = metaFile.value("modid", -1).toInt(); + m_Version.parse(metaFile.value("version", "").toString()); + m_NewestVersion = metaFile.value("newestVersion", "").toString(); + m_IgnoredVersion = metaFile.value("ignoredVersion", "").toString(); + m_InstallationFile = + loadMetaPath(metaFile.value("installationFile", "").toString()); + m_NexusDescription = metaFile.value("nexusDescription", "").toString(); + m_NexusFileStatus = metaFile.value("nexusFileStatus", "1").toInt(); + m_NexusCategory = metaFile.value("nexusCategory", 0).toInt(); + m_Author = metaFile.value("author", "").toString(); + m_Uploader = metaFile.value("uploader", "").toString(); + m_UploaderUrl = metaFile.value("uploaderUrl", "").toString(); + m_Repository = metaFile.value("repository", "Nexus").toString(); + m_Converted = metaFile.value("converted", false).toBool(); + m_Validated = metaFile.value("validated", false).toBool(); + + // this handles changes to how the URL works after 2.2.0 + // + // in 2.2.0, "hasCustomUrl" does not exist and "url" is only used when the mod + // id is invalid, although it can be set at any time in the mod info dialog + // + // post 2.2.0, a custom url can be set on any mod, whether the mod id is + // valid or not, so an additional flag "hasCustomURL" is required, with a + // corresponding checkbox in the mod info dialog + // + // there are several cases to handle to make sure no data is lost and to + // determine whether the user has set a custom url before: + // + // 1) some mods have an incorrect url set along with a valid mod id; + // there is apparently a bug with the fomod installer that can set the + // url of a mod to a value used by a _previous_ installation + // + // 2) it is possible to set the url even if the mod id is valid, in which + // case it is saved, but never used in 2.2.0 + // + // 3) opening the mod info dialog on the nexus tab for a mod that has a + // valid id will force the url to be the same as what the plugin gives + // back + // + // the algorithm is as follows: + // always read the url from the meta file and store it so this piece of data + // is never lost; the problem then only becomes about whether to enable + // hasCustomURL + // + // if hasCustomURL is present in the meta file, just read that and be + // done with it + // + // if not, then the flag depends on the mod id and the url + // if the mod id is valid, the custom url is disabled; although the url + // could be _set_ by the user when a mod id was valid, it was never + // _used_, so the behaviour won't change + // + // if the mod id is invalid, the url should normally be empty, unless the + // user specified one, in which case hasCustomURL should be true + // (the only case where this fails is if a mod id was valid before and + // the user visited the nexus tab, in which case the url was set + // automatically, but then the id was manually changed to 0 + // + // in that case, the mod id is invalid and the url is not empty, but it + // was never set by the user; this case is impossible to distinguish + // from a user manually entering a url, and so is handled as such) + + // always read the url + m_CustomURL = metaFile.value("url").toString(); + + if (metaFile.contains("hasCustomURL")) { + m_HasCustomURL = metaFile.value("hasCustomURL").toBool(); + } else { + if (m_NexusID > 0) { + // the mod id is valid, disable the custom url + m_HasCustomURL = false; + } else { + if (!m_CustomURL.isEmpty()) { + // the mod id is invalid and the url is not empty, enable it + m_HasCustomURL = true; + } + } + } + + m_LastNexusQuery = QDateTime::fromString( + metaFile.value("lastNexusQuery", "").toString(), Qt::ISODate); + m_LastNexusUpdate = QDateTime::fromString( + metaFile.value("lastNexusUpdate", "").toString(), Qt::ISODate); + m_NexusLastModified = QDateTime::fromString( + metaFile.value("nexusLastModified", QDateTime::currentDateTimeUtc()).toString(), + Qt::ISODate); + m_NexusCategory = metaFile.value("nexusCategory", 0).toInt(); + m_Color = metaFile.value("color", QColor()).value(); + m_TrackedState = metaFile.value("tracked", false).toBool() + ? TrackedState::TRACKED_TRUE + : TrackedState::TRACKED_FALSE; + if (metaFile.contains("endorsed")) { + if (metaFile.value("endorsed").canConvert()) { + using ut = std::underlying_type_t; + switch (metaFile.value("endorsed").toInt()) { + case static_cast(EndorsedState::ENDORSED_FALSE): + m_EndorsedState = EndorsedState::ENDORSED_FALSE; + break; + case static_cast(EndorsedState::ENDORSED_TRUE): + m_EndorsedState = EndorsedState::ENDORSED_TRUE; + break; + case static_cast(EndorsedState::ENDORSED_NEVER): + m_EndorsedState = EndorsedState::ENDORSED_NEVER; + break; + default: + m_EndorsedState = EndorsedState::ENDORSED_UNKNOWN; + break; + } + } else { + m_EndorsedState = metaFile.value("endorsed", false).toBool() + ? EndorsedState::ENDORSED_TRUE + : EndorsedState::ENDORSED_FALSE; + } + } + + QString categoriesString = metaFile.value("category", "").toString(); + + QStringList categories = categoriesString.split(',', Qt::SkipEmptyParts); + for (QStringList::iterator iter = categories.begin(); iter != categories.end(); + ++iter) { + bool ok = false; + int categoryID = iter->toInt(&ok); + if (categoryID < 0) { + // ignore invalid id + continue; + } + if (ok && (categoryID != 0) && + (CategoryFactory::instance().categoryExists(categoryID))) { + m_Categories.insert(categoryID); + if (iter == categories.begin()) { + m_PrimaryCategory = categoryID; + } + } + } + + int numFiles = metaFile.beginReadArray("installedFiles"); + for (int i = 0; i < numFiles; ++i) { + metaFile.setArrayIndex(i); + m_InstalledFileIDs.insert(std::make_pair(metaFile.value("modid").toInt(), + metaFile.value("fileid").toInt())); + } + metaFile.endArray(); + + // Plugin settings: + metaFile.beginGroup("Plugins"); + for (auto pluginName : metaFile.childGroups()) { + metaFile.beginGroup(pluginName); + for (auto settingKey : metaFile.childKeys()) { + m_PluginSettings[pluginName][settingKey] = metaFile.value(settingKey); + } + metaFile.endGroup(); + } + metaFile.endGroup(); + + m_MetaInfoChanged = false; +} + +void ModInfoRegular::saveMeta() +{ + // only write meta data if the mod directory exists + if (m_MetaInfoChanged && QFile::exists(absolutePath())) { + QSettings metaFile(absolutePath().append("/meta.ini"), QSettings::IniFormat); + if (metaFile.status() == QSettings::NoError) { + std::set temp = m_Categories; + temp.erase(m_PrimaryCategory); + metaFile.setValue("category", QString("%1").arg(m_PrimaryCategory) + "," + + SetJoin(temp, ",")); + metaFile.setValue("newestVersion", m_NewestVersion.canonicalString()); + metaFile.setValue("ignoredVersion", m_IgnoredVersion.canonicalString()); + metaFile.setValue("version", m_Version.canonicalString()); + metaFile.setValue("installationFile", storeMetaPath(m_InstallationFile)); + metaFile.setValue("repository", m_Repository); + metaFile.setValue("gameName", m_GameName); + metaFile.setValue("modid", m_NexusID); + metaFile.setValue("comments", m_Comments); + metaFile.setValue("notes", m_Notes); + metaFile.setValue("nexusDescription", m_NexusDescription); + metaFile.setValue("url", m_CustomURL); + metaFile.setValue("hasCustomURL", m_HasCustomURL); + metaFile.setValue("nexusFileStatus", m_NexusFileStatus); + metaFile.setValue("lastNexusQuery", m_LastNexusQuery.toString(Qt::ISODate)); + metaFile.setValue("lastNexusUpdate", m_LastNexusUpdate.toString(Qt::ISODate)); + metaFile.setValue("nexusLastModified", m_NexusLastModified.toString(Qt::ISODate)); + metaFile.setValue("nexusCategory", m_NexusCategory); + metaFile.setValue("author", m_Author); + metaFile.setValue("uploader", m_Uploader); + metaFile.setValue("uploaderUrl", m_UploaderUrl); + metaFile.setValue("converted", m_Converted); + metaFile.setValue("validated", m_Validated); + metaFile.setValue("color", m_Color); + if (m_EndorsedState != EndorsedState::ENDORSED_UNKNOWN) { + metaFile.setValue( + "endorsed", + static_cast>(m_EndorsedState)); + } + if (m_TrackedState != TrackedState::TRACKED_UNKNOWN) { + metaFile.setValue("tracked", static_cast>( + m_TrackedState)); + } + + metaFile.remove("installedFiles"); + metaFile.beginWriteArray("installedFiles"); + int idx = 0; + for (auto iter = m_InstalledFileIDs.begin(); iter != m_InstalledFileIDs.end(); + ++iter) { + metaFile.setArrayIndex(idx++); + metaFile.setValue("modid", iter->first); + metaFile.setValue("fileid", iter->second); + } + metaFile.endArray(); + + // Plugin settings: + metaFile.remove("Plugins"); + metaFile.beginGroup("Plugins"); + for (const auto& [pluginName, pluginSettings] : m_PluginSettings) { + metaFile.beginGroup(pluginName); + for (const auto& [settingName, settingValue] : pluginSettings) { + metaFile.setValue(settingName, settingValue); + } + metaFile.endGroup(); + } + metaFile.endGroup(); + + metaFile.sync(); // sync needs to be called to ensure the file is created + + if (metaFile.status() == QSettings::NoError) { + m_MetaInfoChanged = false; + } else { + log::error("failed to write {}/meta.ini: error {}", absolutePath(), + metaFile.status()); + } + } else { + log::error("failed to write {}/meta.ini: error {}", absolutePath(), + metaFile.status()); + } + } +} + +bool ModInfoRegular::updateAvailable() const +{ + if (m_IgnoredVersion.isValid() && (m_IgnoredVersion == m_NewestVersion)) { + return false; + } + if (m_NexusFileStatus == 4 || m_NexusFileStatus == 6) { + return true; + } + return m_NewestVersion.isValid() && (m_Version < m_NewestVersion); +} + +bool ModInfoRegular::downgradeAvailable() const +{ + if (m_IgnoredVersion.isValid() && (m_IgnoredVersion == m_NewestVersion)) { + return false; + } + return m_NewestVersion.isValid() && (m_NewestVersion < m_Version); +} + +void ModInfoRegular::nxmDescriptionAvailable(QString, int, QVariant, + QVariant resultData) +{ + QVariantMap result = resultData.toMap(); + setNexusDescription(result["description"].toString()); + + if ((m_EndorsedState != EndorsedState::ENDORSED_NEVER) && + (result.contains("endorsement"))) { + QVariantMap endorsement = result["endorsement"].toMap(); + QString endorsementStatus = endorsement["endorse_status"].toString(); + if (endorsementStatus.compare("Endorsed", Qt::CaseInsensitive) == 00) + setEndorsedState(EndorsedState::ENDORSED_TRUE); + else if (endorsementStatus.compare("Abstained", Qt::CaseInsensitive) == 00) + setEndorsedState(EndorsedState::ENDORSED_NEVER); + else + setEndorsedState(EndorsedState::ENDORSED_FALSE); + } + m_LastNexusQuery = QDateTime::currentDateTimeUtc(); + m_NexusLastModified = + QDateTime::fromSecsSinceEpoch(result["updated_timestamp"].toInt(), Qt::UTC); + m_MetaInfoChanged = true; + saveMeta(); + disconnect(sender(), SIGNAL(descriptionAvailable(QString, int, QVariant, QVariant))); + emit modDetailsUpdated(true); +} + +void ModInfoRegular::nxmEndorsementToggled(QString, int, QVariant, QVariant resultData) +{ + QMap results = resultData.toMap(); + QMutexLocker locker(&s_Mutex); + for (auto& mod : s_Collection) { + if (mod->gameName().compare(m_GameName, Qt::CaseInsensitive) == 0 && + mod->nexusId() == m_NexusID) { + if (results["status"].toString().compare("Endorsed") == 0) { + mod->setIsEndorsed(true); + } else if (results["status"].toString().compare("Abstained") == 0) { + mod->setNeverEndorse(); + } else { + mod->setIsEndorsed(false); + } + mod->saveMeta(); + } + } + emit modDetailsUpdated(true); +} + +void ModInfoRegular::nxmTrackingToggled(QString, int, QVariant, bool tracked) +{ + QMutexLocker locker(&s_Mutex); + for (auto& mod : s_Collection) { + if (mod->gameName().compare(m_GameName, Qt::CaseInsensitive) == 0 && + mod->nexusId() == m_NexusID) { + mod->setIsTracked(tracked); + mod->saveMeta(); + } + } + emit modDetailsUpdated(true); +} + +void ModInfoRegular::nxmRequestFailed(QString, int, int, QVariant userData, + int errorCode, const QString& errorMessage) +{ + QString fullMessage = errorMessage; + if (userData.canConvert() && (userData.toInt() == 1)) { + fullMessage += "\nNexus will reject endorsements within 15 Minutes of a failed " + "attempt, the error message may be misleading."; + } + if (QApplication::activeWindow() != nullptr) { + MessageDialog::showMessage(fullMessage, QApplication::activeWindow()); + } + emit modDetailsUpdated(false); +} + +bool ModInfoRegular::updateNXMInfo() +{ + if (needsDescriptionUpdate()) { + m_NexusBridge.requestDescription(m_GameName, m_NexusID, QVariant()); + return true; + } + + return false; +} + +bool ModInfoRegular::needsDescriptionUpdate() const +{ + if (m_NexusID > 0) { + QDateTime time = QDateTime::currentDateTimeUtc(); + QDateTime target = m_LastNexusQuery.addDays(1); + + if (time >= target) { + return true; + } + } + + return false; +} + +void ModInfoRegular::setCategory(int categoryID, bool active) +{ + m_MetaInfoChanged = true; + + if (active) { + m_Categories.insert(categoryID); + if (m_PrimaryCategory == -1) { + m_PrimaryCategory = categoryID; + } + } else { + std::set::iterator iter = m_Categories.find(categoryID); + if (iter != m_Categories.end()) { + m_Categories.erase(iter); + } + if (categoryID == m_PrimaryCategory) { + if (m_Categories.size() == 0) { + m_PrimaryCategory = -1; + } else { + m_PrimaryCategory = *(m_Categories.begin()); + } + } + } +} + +bool ModInfoRegular::setName(const QString& name) +{ + if (name.contains('/') || name.contains('\\')) { + return false; + } + + QString newPath = + m_Path.mid(0).replace(m_Path.length() - m_Name.length(), m_Name.length(), name); + QDir modDir(m_Path.mid(0, m_Path.length() - m_Name.length())); + + if (m_Name.compare(name, Qt::CaseInsensitive) == 0) { + QString tempName = name; + tempName.append("_temp"); + while (modDir.exists(tempName)) { + tempName.append("_"); + } + if (!modDir.rename(m_Name, tempName)) { + return false; + } + if (!modDir.rename(tempName, name)) { + log::error( + "rename to final name failed after successful rename to intermediate name"); + modDir.rename(tempName, m_Name); + return false; + } + } else { + if (!shellRename(modDir.absoluteFilePath(m_Name), modDir.absoluteFilePath(name))) { + log::error("failed to rename mod {} (errorcode {})", name, ::GetLastError()); + return false; + } + } + + std::map::iterator nameIter = s_ModsByName.find(m_Name); + if (nameIter != s_ModsByName.end()) { + QMutexLocker locker(&s_Mutex); + + unsigned int index = nameIter->second; + s_ModsByName.erase(nameIter); + + m_Name = name; + m_Path = newPath; + + s_ModsByName[m_Name] = index; + + std::sort(s_Collection.begin(), s_Collection.end(), ModInfo::ByName); + updateIndices(); + } else { // otherwise mod isn't registered yet? + m_Name = name; + m_Path = newPath; + } + + return true; +} + +void ModInfoRegular::setComments(const QString& comments) +{ + m_Comments = comments; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::setNotes(const QString& notes) +{ + m_Notes = notes; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::setGameName(const QString& gameName) +{ + m_GameName = gameName; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::setNexusID(int modID) +{ + m_NexusID = modID; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::setVersion(const VersionInfo& version) +{ + m_Version = version; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::setNewestVersion(const VersionInfo& version) +{ + if (version != m_NewestVersion) { + m_NewestVersion = version; + m_MetaInfoChanged = true; + } +} + +void ModInfoRegular::setNexusDescription(const QString& description) +{ + if (qHash(description) != qHash(m_NexusDescription)) { + m_NexusDescription = description; + m_MetaInfoChanged = true; + } +} + +void ModInfoRegular::setEndorsedState(EndorsedState endorsedState) +{ + if (endorsedState != m_EndorsedState) { + m_EndorsedState = endorsedState; + m_MetaInfoChanged = true; + } +} + +void ModInfoRegular::setTrackedState(TrackedState trackedState) +{ + if (trackedState != m_TrackedState) { + m_TrackedState = trackedState; + m_MetaInfoChanged = true; + } +} + +void ModInfoRegular::setInstallationFile(const QString& fileName) +{ + m_InstallationFile = fileName; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::addNexusCategory(int categoryID) +{ + m_Categories.insert(CategoryFactory::instance().resolveNexusID(categoryID)); +} + +void ModInfoRegular::setIsEndorsed(bool endorsed) +{ + m_EndorsedState = + endorsed ? EndorsedState::ENDORSED_TRUE : EndorsedState::ENDORSED_FALSE; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::setNeverEndorse() +{ + m_EndorsedState = EndorsedState::ENDORSED_NEVER; + m_MetaInfoChanged = true; +} + +void ModInfoRegular::setIsTracked(bool tracked) +{ + if (tracked != (m_TrackedState == TrackedState::TRACKED_TRUE)) { + m_TrackedState = tracked ? TrackedState::TRACKED_TRUE : TrackedState::TRACKED_FALSE; + m_MetaInfoChanged = true; + } +} + +void ModInfoRegular::setColor(QColor color) +{ + m_Color = color; + m_MetaInfoChanged = true; +} + +QColor ModInfoRegular::color() const +{ + return m_Color; +} + +void ModInfoRegular::endorse(bool doEndorse) +{ + if (doEndorse != (m_EndorsedState == EndorsedState::ENDORSED_TRUE)) { + m_NexusBridge.requestToggleEndorsement( + m_GameName, nexusId(), m_Version.canonicalString(), doEndorse, QVariant(1)); + } +} + +void ModInfoRegular::track(bool doTrack) +{ + if (doTrack != (m_TrackedState == TrackedState::TRACKED_TRUE)) { + m_NexusBridge.requestToggleTracking(m_GameName, nexusId(), doTrack, QVariant(1)); + } +} + +void ModInfoRegular::markConverted(bool converted) +{ + m_Converted = converted; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +void ModInfoRegular::markValidated(bool validated) +{ + m_Validated = validated; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +QString ModInfoRegular::absolutePath() const +{ + return m_Path; +} + +void ModInfoRegular::ignoreUpdate(bool ignore) +{ + if (ignore) { + m_IgnoredVersion = m_NewestVersion; + } else { + m_IgnoredVersion.clear(); + } + m_MetaInfoChanged = true; +} + +bool ModInfoRegular::canBeUpdated() const +{ + QDateTime now = QDateTime::currentDateTimeUtc(); + QDateTime target = getExpires(); + if (now >= target) + return m_NexusID > 0; + return false; +} + +QDateTime ModInfoRegular::getExpires() const +{ + return m_LastNexusUpdate.addSecs(300); +} + +std::vector ModInfoRegular::getFlags() const +{ + std::vector result = ModInfoWithConflictInfo::getFlags(); + if ((m_NexusID > 0) && (endorsedState() == EndorsedState::ENDORSED_FALSE) && + Settings::instance().nexus().endorsementIntegration()) { + result.push_back(ModInfo::FLAG_NOTENDORSED); + } + if ((m_NexusID > 0) && (trackedState() == TrackedState::TRACKED_TRUE) && + Settings::instance().nexus().trackedIntegration()) { + result.push_back(ModInfo::FLAG_TRACKED); + } + if (!isValid() && !m_Validated) { + result.push_back(ModInfo::FLAG_INVALID); + } + if (m_Notes.length() != 0) { + result.push_back(ModInfo::FLAG_NOTES); + } + if (m_PluginSelected) { + result.push_back(ModInfo::FLAG_PLUGIN_SELECTED); + } + if (m_IsAlternate && !m_Converted) { + result.push_back(ModInfo::FLAG_ALTERNATE_GAME); + } + return result; +} + +std::set ModInfoRegular::doGetContents() const +{ + auto contentFeature = + m_Core.pluginContainer().gameFeatures().gameFeature(); + + if (contentFeature) { + auto result = contentFeature->getContentsFor(fileTree()); + return std::set(std::begin(result), std::end(result)); + } + + return {}; +} + +int ModInfoRegular::getHighlight() const +{ + if (!isValid() && !m_Validated) + return HIGHLIGHT_INVALID; + auto flags = getFlags(); + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_PLUGIN_SELECTED) != + flags.end()) + return HIGHLIGHT_PLUGIN; + return HIGHLIGHT_NONE; +} + +QString ModInfoRegular::getDescription() const +{ + if (!isValid() && !m_Validated) { + return tr("%1 contains no esp/esm/esl and no asset (textures, meshes, interface, " + "...) directory") + .arg(name()); + } else { + const std::set& categories = getCategories(); + std::wostringstream categoryString; + categoryString << ToWString(tr("Categories:
    ")); + CategoryFactory& categoryFactory = CategoryFactory::instance(); + for (std::set::const_iterator catIter = categories.begin(); + catIter != categories.end(); ++catIter) { + if (catIter != categories.begin()) { + categoryString << " , "; + } + categoryString << "" + << ToWString(categoryFactory.getCategoryName( + categoryFactory.getCategoryIndex(*catIter))) + << ""; + } + + return ToQString(categoryString.str()); + } +} + +int ModInfoRegular::getNexusFileStatus() const +{ + return m_NexusFileStatus; +} + +void ModInfoRegular::setNexusFileStatus(int status) +{ + m_NexusFileStatus = status; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +QString ModInfoRegular::comments() const +{ + return m_Comments; +} + +QString ModInfoRegular::notes() const +{ + return m_Notes; +} + +QDateTime ModInfoRegular::creationTime() const +{ + return m_CreationTime; +} + +QString ModInfoRegular::getNexusDescription() const +{ + return m_NexusDescription; +} + +QString ModInfoRegular::repository() const +{ + return m_Repository; +} + +EndorsedState ModInfoRegular::endorsedState() const +{ + return m_EndorsedState; +} + +TrackedState ModInfoRegular::trackedState() const +{ + return m_TrackedState; +} + +QDateTime ModInfoRegular::getLastNexusUpdate() const +{ + return m_LastNexusUpdate; +} + +void ModInfoRegular::setLastNexusUpdate(QDateTime time) +{ + m_LastNexusUpdate = time; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +QDateTime ModInfoRegular::getLastNexusQuery() const +{ + return m_LastNexusQuery; +} + +void ModInfoRegular::setLastNexusQuery(QDateTime time) +{ + m_LastNexusQuery = time; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +QDateTime ModInfoRegular::getNexusLastModified() const +{ + return m_NexusLastModified; +} + +void ModInfoRegular::setNexusLastModified(QDateTime time) +{ + m_NexusLastModified = time; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +int ModInfoRegular::getNexusCategory() const +{ + return m_NexusCategory; +} + +void ModInfoRegular::setNexusCategory(int category) +{ + m_NexusCategory = category; + m_MetaInfoChanged = true; + saveMeta(); +} + +QString ModInfoRegular::author() const +{ + return m_Author; +} + +void ModInfoRegular::setAuthor(const QString& author) +{ + m_Author = author; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +QString ModInfoRegular::uploader() const +{ + return m_Uploader; +} + +void ModInfoRegular::setUploader(const QString& uploader) +{ + m_Uploader = uploader; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +QString ModInfoRegular::uploaderUrl() const +{ + return m_UploaderUrl; +} + +void ModInfoRegular::setUploaderUrl(const QString& uploaderUrl) +{ + m_UploaderUrl = uploaderUrl; + m_MetaInfoChanged = true; + saveMeta(); + emit modDetailsUpdated(true); +} + +void ModInfoRegular::setCustomURL(QString const& url) +{ + m_CustomURL = url; + m_MetaInfoChanged = true; +} + +QString ModInfoRegular::url() const +{ + return m_CustomURL; +} + +void ModInfoRegular::setHasCustomURL(bool b) +{ + m_HasCustomURL = b; + m_MetaInfoChanged = true; +} + +bool ModInfoRegular::hasCustomURL() const +{ + return m_HasCustomURL; +} + +QStringList ModInfoRegular::archives(bool checkOnDisk) +{ + if (checkOnDisk) { + QStringList result; + QDir dir(this->absolutePath()); + QStringList bsaList = dir.entryList(QStringList({"*.bsa", "*.ba2"})); + for (const QString& archive : bsaList) { + result.append(this->absolutePath() + "/" + archive); + } + m_Archives = result; + } + return m_Archives; +} + +void ModInfoRegular::addInstalledFile(int modId, int fileId) +{ + m_InstalledFileIDs.insert(std::make_pair(modId, fileId)); + m_MetaInfoChanged = true; +} + +std::vector ModInfoRegular::getIniTweaks() const +{ + QString metaFileName = absolutePath().append("/meta.ini"); + QSettings metaFile(metaFileName, QSettings::IniFormat); + + std::vector result; + + int numTweaks = metaFile.beginReadArray("INI Tweaks"); + + if (numTweaks != 0) { + log::debug("{} active ini tweaks in {}", numTweaks, + QDir::toNativeSeparators(metaFileName)); + } + + for (int i = 0; i < numTweaks; ++i) { + metaFile.setArrayIndex(i); + QString filename = + absolutePath().append("/INI Tweaks/").append(metaFile.value("name").toString()); + result.push_back(filename); + } + metaFile.endArray(); + return result; +} + +std::map +ModInfoRegular::pluginSettings(const QString& pluginName) const +{ + auto itp = m_PluginSettings.find(pluginName); + if (itp == std::end(m_PluginSettings)) { + return {}; + } + return itp->second; +} + +QVariant ModInfoRegular::pluginSetting(const QString& pluginName, const QString& key, + const QVariant& defaultValue) const +{ + auto itp = m_PluginSettings.find(pluginName); + if (itp == std::end(m_PluginSettings)) { + return defaultValue; + } + + auto its = itp->second.find(key); + if (its == std::end(itp->second)) { + return defaultValue; + } + + return its->second; +} + +bool ModInfoRegular::setPluginSetting(const QString& pluginName, const QString& key, + const QVariant& value) +{ + m_PluginSettings[pluginName][key] = value; + m_MetaInfoChanged = true; + saveMeta(); + return true; +} + +std::map +ModInfoRegular::clearPluginSettings(const QString& pluginName) +{ + auto itp = m_PluginSettings.find(pluginName); + if (itp == std::end(m_PluginSettings)) { + return {}; + } + auto settings = itp->second; + m_PluginSettings.erase(itp); + saveMeta(); + return settings; +} diff --git a/src/src/modinforegular.h b/src/src/modinforegular.h new file mode 100644 index 0000000..f2df4fc --- /dev/null +++ b/src/src/modinforegular.h @@ -0,0 +1,529 @@ +#ifndef MODINFOREGULAR_H +#define MODINFOREGULAR_H + +#include + +#include "modinfowithconflictinfo.h" +#include "nexusinterface.h" + +/** + * @brief Represents meta information about a single mod. + * + * Represents meta information about a single mod. The class interface is used + * to manage the mod collection + * + **/ +class ModInfoRegular : public ModInfoWithConflictInfo +{ + + Q_OBJECT + + friend class ModInfo; + +public: + ~ModInfoRegular(); + + virtual bool isRegular() const override { return true; } + + virtual bool isEmpty() const override; + + bool isAlternate() { return m_IsAlternate; } + bool isConverted() { return m_Converted; } + bool isValidated() { return m_Validated; } + + /** + * @brief test if there is a newer version of the mod + * + * test if there is a newer version of the mod. This does NOT cause + * information to be retrieved from the nexus, it will only test version information + *already available locally. Use checkAllForUpdate() to update this version + *information + * + * @return true if there is a newer version + **/ + bool updateAvailable() const override; + + /** + * @return true if the current update is being ignored + */ + virtual bool updateIgnored() const override + { + return m_IgnoredVersion.isValid() && m_IgnoredVersion == m_NewestVersion; + } + + /** + * @brief test if there is a newer version of the mod + * + * test if there is a newer version of the mod. This does NOT cause + * information to be retrieved from the nexus, it will only test version information + *already available locally. Use checkAllForUpdate() to update this version + *information + * + * @return true if there is a newer version + **/ + bool downgradeAvailable() const override; + + /** + * @brief request an update of nexus description for this mod. + * + * This requests mod information from the nexus. This is an asynchronous request, + * so there is no immediate effect of this call. + * + * @return returns true if information for this mod will be updated, false if there is + *no nexus mod id to use + **/ + bool updateNXMInfo() override; + + /** + * @brief assign or unassign the specified category + * + * Every mod can have an arbitrary number of categories assigned to it + * + * @param categoryID id of the category to set + * @param active determines wheter the category is assigned or unassigned + * @note this function does not test whether categoryID actually identifies a valid + *category + **/ + void setCategory(int categoryID, bool active) override; + + /** + * @brief set the name of this mod + * + * set the name of this mod. This will also update the name of the + * directory that contains this mod + * + * @param name new name of the mod + * @return true on success, false if the new name can't be used (i.e. because the new + * directory name wouldn't be valid) + **/ + bool setName(const QString& name) override; + + /** + * @brief changes the comments (manually set information displayed in the mod list) + * for this mod + * @param comments new comments + */ + void setComments(const QString& comments) override; + + /** + * @brief change the notes (manually set information) for this mod + * @param notes new notes + */ + void setNotes(const QString& notes) override; + + /** + * @brief set/change the source game of this mod + * + * @param gameName the source game shortName + */ + virtual void setGameName(const QString& gameName) override; + + /** + * @brief set/change the nexus mod id of this mod + * + * @param modID the nexus mod id + **/ + void setNexusID(int modID) override; + + /** + * @brief set the version of this mod + * + * this can be used to overwrite the version of a mod without actually + * updating the mod + * + * @param version the new version to use + **/ + void setVersion(const MOBase::VersionInfo& version) override; + + /** + * @brief set the newest version of this mod on the nexus + * + * this can be used to overwrite the version of a mod without actually + * updating the mod + * + * @param version the new version to use + * @todo this function should be made obsolete. All queries for mod information should + *go through this class so no public function for this change is required + **/ + void setNewestVersion(const MOBase::VersionInfo& version) override; + + /** + * @brief changes/updates the nexus description text + * @param description the current description text + */ + virtual void setNexusDescription(const QString& description) override; + + virtual void setInstallationFile(const QString& fileName) override; + + /** + * @brief sets the category id from a nexus category id. Conversion to MO id happens + * internally + * @param categoryID the nexus category id + * @note if a mapping is not possible, the category is set to the default value + */ + virtual void addNexusCategory(int categoryID) override; + + /** + * @brief sets the new primary category of the mod + * @param categoryID the category to set + */ + virtual void setPrimaryCategory(int categoryID) override + { + m_PrimaryCategory = categoryID; + m_MetaInfoChanged = true; + } + + /** + * @brief sets the download repository + * @param repository + */ + virtual void setRepository(const QString& repository) override + { + m_Repository = repository; + } + + /** + * update the endorsement state for the mod. This only changes the + * buffered state, it does not sync with Nexus + * @param endorsed the new endorsement state + */ + virtual void setIsEndorsed(bool endorsed) override; + + /** + * set the mod to "i don't intend to endorse". The mod will not show as unendorsed but + * can still be endorsed + */ + virtual void setNeverEndorse() override; + + /** + * update the tracked state for the mod. This only changes the + * buffered state. It does not sync with Nexus + * @param tracked the new tracked state + */ + virtual void setIsTracked(bool tracked) override; + + /** + * @brief endorse or un-endorse the mod + * @param doEndorse if true, the mod is endorsed, if false, it's un-endorsed. + * @note if doEndorse doesn't differ from the current value, nothing happens. + */ + virtual void endorse(bool doEndorse) override; + + /** + * @brief track or untrack the mod. This will sync with nexus! + * @param doTrack if true, the mod is tracked, if false, it's untracked. + * @note if doTrack doesn't differ from the current value, nothing happens. + */ + virtual void track(bool doTrack) override; + + /** + * @brief updates the mod to flag it as converted in order to ignore the alternate + * game warning + */ + virtual void markConverted(bool converted) override; + + /** + * @brief updates the mod to flag it as valid in order to ignore the invalid game data + * flag + */ + virtual void markValidated(bool validated) override; + + /** + * @brief getter for the mod name + * + * @return the mod name + **/ + QString name() const override { return m_Name; } + + /** + * @brief getter for the mod path + * + * @return the (absolute) path to the mod + **/ + QString absolutePath() const override; + + /** + * @brief getter for the newest version number of this mod + * + * @return newest version of the mod + **/ + MOBase::VersionInfo newestVersion() const override { return m_NewestVersion; } + + /** + * @brief getter for the newest version number of this mod + * + * @return newest version of the mod + **/ + MOBase::VersionInfo ignoredVersion() const override { return m_IgnoredVersion; } + + /** + * @brief ignore the newest version for updates + */ + void ignoreUpdate(bool ignore) override; + + /** + * @brief getter for the nexus mod id + * + * @return the nexus mod id. may be 0 if the mod id isn't known or doesn't exist + **/ + int nexusId() const override { return m_NexusID; } + + /** + * @return true if the mod can be updated + */ + virtual bool canBeUpdated() const override; + + /** + * @return the update expiration date based on the last updated date from Nexus + */ + virtual QDateTime getExpires() const override; + + /** + * @return true if the mod can be enabled/disabled + */ + virtual bool canBeEnabled() const override { return true; } + + /** + * @return a list of flags for this mod + */ + virtual std::vector getFlags() const override; + + /** + * @return an indicator if and how this mod should be highlighted by the UI + */ + virtual int getHighlight() const override; + + /** + * @return list of names of ini tweaks + **/ + std::vector getIniTweaks() const override; + + /** + * @return a description about the mod, to be displayed in the ui + */ + virtual QString getDescription() const override; + + /** + * @return the nexus file status (aka category ID) + */ + virtual int getNexusFileStatus() const override; + + /** + * @brief sets the file status (category ID) from Nexus + * @param status the status id of the installed file + */ + virtual void setNexusFileStatus(int status) override; + + /** + * @return comments for this mod + */ + virtual QString comments() const override; + + /** + * @return manually set notes for this mod + */ + virtual QString notes() const override; + + /** + * @return time this mod was created (file time of the directory) + */ + virtual QDateTime creationTime() const override; + + /** + * @return nexus description of the mod (html) + */ + QString getNexusDescription() const override; + + /** + * @return repository from which the file was downloaded + */ + virtual QString repository() const override; + + /** + * @return true if the file has been endorsed on nexus + */ + virtual MOBase::EndorsedState endorsedState() const override; + + /** + * @return true if the file is being tracked on nexus + */ + virtual MOBase::TrackedState trackedState() const override; + + /** + * @brief get the last time nexus was checked for file updates on this mod + */ + virtual QDateTime getLastNexusUpdate() const override; + + /** + * @brief set the last time nexus was checked for file updates on this mod + */ + virtual void setLastNexusUpdate(QDateTime time) override; + + /** + * @return last time nexus was queried for infos on this mod + */ + virtual QDateTime getLastNexusQuery() const override; + + /** + * @brief set the last time nexus was queried for info on this mod + */ + virtual void setLastNexusQuery(QDateTime time) override; + + /** + * @return last time the mod was updated on Nexus + */ + virtual QDateTime getNexusLastModified() const override; + + /** + * @brief set the last time the mod was updated on Nexus + */ + virtual void setNexusLastModified(QDateTime time) override; + + /** + * @return the assigned nexus category ID + */ + virtual int getNexusCategory() const override; + + /** + * @brief Assigns the given Nexus category ID + */ + virtual void setNexusCategory(int category) override; + + /** + * @return the author of the mod. + */ + virtual QString author() const override; + + /** + * @brief Set the author of the mod. + */ + virtual void setAuthor(const QString&) override; + + /** + * @return the name of the uploader of this mod. + */ + virtual QString uploader() const override; + + /** + * @brief Set the name of the uploader of this mod. + */ + virtual void setUploader(const QString&) override; + + /** + * @return the URL of the uploader of this mod's profile. + */ + virtual QString uploaderUrl() const override; + + /** + * @brief Set the URL of the uploader of this mod's profile. + */ + virtual void setUploaderUrl(const QString&) override; + + virtual QStringList archives(bool checkOnDisk = false) override; + + virtual void setColor(QColor color) override; + + virtual QColor color() const override; + + virtual void addInstalledFile(int modId, int fileId) override; + + /** + * @brief stores meta information back to disk + */ + virtual void saveMeta() override; + + void readMeta() override; + + virtual void setHasCustomURL(bool b) override; + virtual bool hasCustomURL() const override; + virtual void setCustomURL(QString const&) override; + virtual QString url() const override; + + virtual QString gameName() const override { return m_GameName; } + virtual QString installationFile() const override { return m_InstallationFile; } + virtual bool converted() const override { return m_Converted; } + virtual bool validated() const override { return m_Validated; } + virtual std::set> installedFiles() const override + { + return m_InstalledFileIDs; + } + +public: // Plugin operations: + virtual QVariant pluginSetting(const QString& pluginName, const QString& key, + const QVariant& defaultValue) const override; + virtual std::map + pluginSettings(const QString& pluginName) const override; + virtual bool setPluginSetting(const QString& pluginName, const QString& key, + const QVariant& value) override; + virtual std::map + clearPluginSettings(const QString& pluginName) override; + +private: + void setEndorsedState(MOBase::EndorsedState endorsedState); + void setTrackedState(MOBase::TrackedState trackedState); + +private slots: + + void nxmDescriptionAvailable(QString, int modID, QVariant userData, + QVariant resultData); + void nxmEndorsementToggled(QString, int, QVariant userData, QVariant resultData); + void nxmTrackingToggled(QString, int, QVariant userData, bool tracked); + void nxmRequestFailed(QString, int modID, int fileID, QVariant userData, + int errorCode, const QString& errorMessage); + +protected: + virtual std::set doGetContents() const override; + + ModInfoRegular(const QDir& path, OrganizerCore& core); + +private: + QString m_Name; + QString m_Path; + QString m_InstallationFile; + QString m_Comments; + QString m_Notes; + QString m_NexusDescription; + QString m_Repository; + QString m_CustomURL; + bool m_HasCustomURL; + + // Game name for the mod, can be different from the actual game running in MO2 + // e.g., for Skyrim / Skyrim SE. + QString m_GameName; + + mutable QStringList m_Archives; + + QDateTime m_CreationTime; + QDateTime m_LastNexusQuery; + QDateTime m_LastNexusUpdate; + QDateTime m_NexusLastModified; + int m_NexusCategory; + QString m_Author; + QString m_Uploader; + QString m_UploaderUrl; + + QColor m_Color; + + int m_NexusID; + std::set> m_InstalledFileIDs; + + // List of plugin settings: + std::map> m_PluginSettings; + + bool m_MetaInfoChanged; + bool m_IsAlternate; + bool m_Converted; + bool m_Validated; + int m_NexusFileStatus; + MOBase::VersionInfo m_NewestVersion; + MOBase::VersionInfo m_IgnoredVersion; + + MOBase::EndorsedState m_EndorsedState; + MOBase::TrackedState m_TrackedState; + + NexusBridge m_NexusBridge; + + bool needsDescriptionUpdate() const; +}; + +#endif // MODINFOREGULAR_H diff --git a/src/src/modinfoseparator.cpp b/src/src/modinfoseparator.cpp new file mode 100644 index 0000000..90a0d35 --- /dev/null +++ b/src/src/modinfoseparator.cpp @@ -0,0 +1,34 @@ +#include "modinfoseparator.h" + +bool ModInfoSeparator::setName(const QString& name) +{ + return ModInfoRegular::setName(name); +} + +std::vector ModInfoSeparator::getFlags() const +{ + { + auto result = ModInfoRegular::getFlags(); + result.push_back(FLAG_SEPARATOR); + return result; + } +} + +int ModInfoSeparator::getHighlight() const +{ + return HIGHLIGHT_CENTER; +} + +QString ModInfoSeparator::getDescription() const +{ + return tr("This is a Separator"); +} + +QString ModInfoSeparator::name() const +{ + return ModInfoRegular::name(); +} + +ModInfoSeparator::ModInfoSeparator(const QDir& path, OrganizerCore& core) + : ModInfoRegular(path, core) +{} diff --git a/src/src/modinfoseparator.h b/src/src/modinfoseparator.h new file mode 100644 index 0000000..394869a --- /dev/null +++ b/src/src/modinfoseparator.h @@ -0,0 +1,68 @@ +#ifndef MODINFOSEPARATOR_H +#define MODINFOSEPARATOR_H + +#include "modinforegular.h" + +class ModInfoSeparator : public ModInfoRegular +{ + Q_OBJECT; + + friend class ModInfo; + +public: + virtual bool updateAvailable() const override { return false; } + virtual bool updateIgnored() const override { return false; } + virtual bool downgradeAvailable() const override { return false; } + virtual bool updateNXMInfo() override { return false; } + virtual bool isValid() const override { return true; } + // TODO: Fix renaming method to avoid priority reset + virtual bool setName(const QString& name); + + virtual int nexusId() const override { return -1; } + virtual void setGameName(const QString& gameName) override {} + virtual void setNexusID(int /*modID*/) override {} + virtual void endorse(bool /*doEndorse*/) override {} + virtual void ignoreUpdate(bool /*ignore*/) override {} + virtual bool canBeUpdated() const override { return false; } + virtual QDateTime getExpires() const override { return QDateTime(); } + virtual bool canBeEnabled() const override { return false; } + virtual std::vector getIniTweaks() const override + { + return std::vector(); + } + virtual std::vector getFlags() const override; + virtual int getHighlight() const override; + virtual QString getDescription() const override; + virtual QString name() const override; + virtual QString gameName() const override { return ""; } + virtual QString installationFile() const override { return ""; } + virtual QString repository() const override { return ""; } + virtual int getNexusFileStatus() const override { return 0; } + virtual void setNexusFileStatus(int) override {} + virtual QDateTime getLastNexusUpdate() const override { return QDateTime(); } + virtual void setLastNexusUpdate(QDateTime) override {} + virtual QDateTime getLastNexusQuery() const override { return QDateTime(); } + virtual void setLastNexusQuery(QDateTime) override {} + virtual QDateTime getNexusLastModified() const override { return QDateTime(); } + virtual void setNexusLastModified(QDateTime) override {} + virtual int getNexusCategory() const override { return 0; } + virtual void setNexusCategory(int) override {} + virtual QDateTime creationTime() const override { return QDateTime(); } + virtual QString getNexusDescription() const override { return QString(); } + virtual QString author() const override { return QString(); } + virtual void setAuthor(const QString&) override {} + virtual QString uploader() const override { return QString(); } + virtual void setUploader(const QString&) override {} + virtual QString uploaderUrl() const override { return QString(); } + virtual void setUploaderUrl(const QString&) override {} + virtual void addInstalledFile(int /*modId*/, int /*fileId*/) override {} + virtual bool isSeparator() const override { return true; } + +protected: + virtual bool doIsValid() const override { return true; } + +private: + ModInfoSeparator(const QDir& path, OrganizerCore& core); +}; + +#endif diff --git a/src/src/modinfowithconflictinfo.cpp b/src/src/modinfowithconflictinfo.cpp new file mode 100644 index 0000000..4ff0b46 --- /dev/null +++ b/src/src/modinfowithconflictinfo.cpp @@ -0,0 +1,348 @@ +#include "modinfowithconflictinfo.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "utility.h" +#include + +#include "iplugingame.h" +#include "moddatachecker.h" +#include "organizercore.h" +#include "qdirfiletree.h" + +using namespace MOBase; +using namespace MOShared; +namespace fs = std::filesystem; + +ModInfoWithConflictInfo::ModInfoWithConflictInfo(OrganizerCore& core) + : ModInfo(core), m_FileTree([this]() { + return QDirFileTree::makeTree(absolutePath()); + }), + m_Valid([this]() { + return doIsValid(); + }), + m_Contents([this]() { + return doGetContents(); + }), + m_Conflicts([this]() { + return doConflictCheck(); + }) +{} + +void ModInfoWithConflictInfo::clearCaches() +{ + m_Conflicts.invalidate(); +} + +std::vector ModInfoWithConflictInfo::getFlags() const +{ + std::vector result = std::vector(); + if (hasHiddenFiles()) { + result.push_back(ModInfo::FLAG_HIDDEN_FILES); + } + return result; +} + +std::vector ModInfoWithConflictInfo::getConflictFlags() const +{ + std::vector result; + switch (isConflicted()) { + case CONFLICT_MIXED: { + result.push_back(ModInfo::FLAG_CONFLICT_MIXED); + } break; + case CONFLICT_OVERWRITE: { + result.push_back(ModInfo::FLAG_CONFLICT_OVERWRITE); + } break; + case CONFLICT_OVERWRITTEN: { + result.push_back(ModInfo::FLAG_CONFLICT_OVERWRITTEN); + } break; + case CONFLICT_REDUNDANT: { + result.push_back(ModInfo::FLAG_CONFLICT_REDUNDANT); + } break; + default: { /* NOP */ + } + } + switch (isLooseArchiveConflicted()) { + case CONFLICT_MIXED: { + result.push_back(ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE); + result.push_back(ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN); + } break; + case CONFLICT_OVERWRITE: { + result.push_back(ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE); + } break; + case CONFLICT_OVERWRITTEN: { + result.push_back(ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN); + } break; + default: { /* NOP */ + } + } + switch (isArchiveConflicted()) { + case CONFLICT_MIXED: { + result.push_back(ModInfo::FLAG_ARCHIVE_CONFLICT_MIXED); + } break; + case CONFLICT_OVERWRITE: { + result.push_back(ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITE); + } break; + case CONFLICT_OVERWRITTEN: { + result.push_back(ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITTEN); + } break; + default: { /* NOP */ + } + } + return result; +} + +ModInfoWithConflictInfo::Conflicts ModInfoWithConflictInfo::doConflictCheck() const +{ + Conflicts conflicts; + + bool providesAnything = false; + bool hasHiddenFiles = false; + bool hasVisibleFiles = false; + + std::vector dataIDs; + if (m_Core.directoryStructure()->originExists(L"data")) { + dataIDs.push_back(m_Core.directoryStructure()->getOriginByName(L"data").getID()); + } + for (const auto& origin : m_Core.managedGame()->secondaryDataDirectories().keys()) { + if (m_Core.directoryStructure()->originExists(origin.toStdWString())) { + dataIDs.push_back( + m_Core.directoryStructure()->getOriginByName(origin.toStdWString()).getID()); + } + } + + std::wstring name = ToWString(this->name()); + + if (m_Core.directoryStructure()->originExists(name)) { + FilesOrigin& origin = m_Core.directoryStructure()->getOriginByName(name); + std::vector files = origin.getFiles(); + std::set checkedDirs; + + // for all files in this origin + for (FileEntryPtr file : files) { + if (QString::fromStdWString(file->getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + hasHiddenFiles = true; + // skip hidden file conflicts + continue; + } else { + const DirectoryEntry* parent = file->getParent(); + auto hidden = false; + + // iterate on all parent directory entries to check for .mohiddden + while (parent != nullptr) { + auto insertResult = checkedDirs.insert(parent); + + if (insertResult.second == false) { + // if already present break as we can assume to have checked the parents as + // well + break; + } else { + if (QString::fromStdWString(parent->getName()) + .endsWith(ModInfo::s_HiddenExt, Qt::CaseInsensitive)) { + hasHiddenFiles = hidden = true; + break; + } + parent = parent->getParent(); + } + } + if (hidden) { + // skip hidden file conflicts + continue; + } + } + + hasVisibleFiles = true; + auto alternatives = file->getAlternatives(); + if ((alternatives.size() == 0) || + std::find(dataIDs.begin(), dataIDs.end(), alternatives.back().originID()) != + dataIDs.end()) { + // no alternatives -> no conflict + providesAnything = true; + } else { + // Get the archive data for the current mod + DataArchiveOrigin archiveData; + if (file->getOrigin() == origin.getID()) + archiveData = file->getArchive(); + else { + for (const auto& alt : alternatives) { + if (alt.originID() == origin.getID()) { + archiveData = alt.archive(); + break; + } + } + } + + // If this is not the origin then determine the correct overwrite + if (file->getOrigin() != origin.getID()) { + FilesOrigin& altOrigin = + m_Core.directoryStructure()->getOriginByID(file->getOrigin()); + unsigned int altIndex = ModInfo::getIndex(ToQString(altOrigin.getName())); + if (!file->isFromArchive()) { + if (!archiveData.isValid()) + conflicts.m_OverwrittenList.insert(altIndex); + else + conflicts.m_ArchiveLooseOverwrittenList.insert(altIndex); + } else { + conflicts.m_ArchiveOverwrittenList.insert(altIndex); + } + } else { + providesAnything = true; + } + + // Sort out the alternatives + for (const auto& altInfo : alternatives) { + if (!(std::find(dataIDs.begin(), dataIDs.end(), + alternatives.back().originID()) != dataIDs.end()) && + (altInfo.originID() != origin.getID())) { + FilesOrigin& altOrigin = + m_Core.directoryStructure()->getOriginByID(altInfo.originID()); + QString altOriginName = ToQString(altOrigin.getName()); + unsigned int altIndex = ModInfo::getIndex(altOriginName); + if (!altInfo.isFromArchive()) { + if (!archiveData.isValid()) { + if (origin.getPriority() > altOrigin.getPriority()) { + conflicts.m_OverwriteList.insert(altIndex); + } else { + conflicts.m_OverwrittenList.insert(altIndex); + } + } else { + conflicts.m_ArchiveLooseOverwrittenList.insert(altIndex); + } + } else { + if (!archiveData.isValid()) { + conflicts.m_ArchiveLooseOverwriteList.insert(altIndex); + } else { + if (archiveData.order() > altInfo.archive().order()) { + conflicts.m_ArchiveOverwriteList.insert(altIndex); + } else if (archiveData.order() < altInfo.archive().order()) { + conflicts.m_ArchiveOverwrittenList.insert(altIndex); + } + } + } + } + } + } + } + + if (files.size() != 0) { + if (hasVisibleFiles && !providesAnything) + conflicts.m_CurrentConflictState = CONFLICT_REDUNDANT; + else if (!conflicts.m_OverwriteList.empty() && + !conflicts.m_OverwrittenList.empty()) + conflicts.m_CurrentConflictState = CONFLICT_MIXED; + else if (!conflicts.m_OverwriteList.empty()) + conflicts.m_CurrentConflictState = CONFLICT_OVERWRITE; + else if (!conflicts.m_OverwrittenList.empty()) + conflicts.m_CurrentConflictState = CONFLICT_OVERWRITTEN; + + if (!conflicts.m_ArchiveOverwriteList.empty() && + !conflicts.m_ArchiveOverwrittenList.empty()) + conflicts.m_ArchiveConflictState = CONFLICT_MIXED; + else if (!conflicts.m_ArchiveOverwriteList.empty()) + conflicts.m_ArchiveConflictState = CONFLICT_OVERWRITE; + else if (!conflicts.m_ArchiveOverwrittenList.empty()) + conflicts.m_ArchiveConflictState = CONFLICT_OVERWRITTEN; + + if (!conflicts.m_ArchiveLooseOverwrittenList.empty() && + !conflicts.m_ArchiveLooseOverwriteList.empty()) + conflicts.m_ArchiveConflictLooseState = CONFLICT_MIXED; + else if (!conflicts.m_ArchiveLooseOverwrittenList.empty()) + conflicts.m_ArchiveConflictLooseState = CONFLICT_OVERWRITTEN; + else if (!conflicts.m_ArchiveLooseOverwriteList.empty()) + conflicts.m_ArchiveConflictLooseState = CONFLICT_OVERWRITE; + + conflicts.m_HasHiddenFiles = hasHiddenFiles; + } + } + + return conflicts; +} + +ModInfoWithConflictInfo::EConflictType ModInfoWithConflictInfo::isConflicted() const +{ + return m_Conflicts.value().m_CurrentConflictState; +} + +ModInfoWithConflictInfo::EConflictType +ModInfoWithConflictInfo::isArchiveConflicted() const +{ + return m_Conflicts.value().m_ArchiveConflictState; +} + +ModInfoWithConflictInfo::EConflictType +ModInfoWithConflictInfo::isLooseArchiveConflicted() const +{ + return m_Conflicts.value().m_ArchiveConflictLooseState; +} + +bool ModInfoWithConflictInfo::isRedundant() const +{ + std::wstring name = ToWString(this->name()); + if (m_Core.directoryStructure()->originExists(name)) { + FilesOrigin& origin = m_Core.directoryStructure()->getOriginByName(name); + std::vector files = origin.getFiles(); + bool ignore = false; + for (auto iter = files.begin(); iter != files.end(); ++iter) { + if ((*iter)->getOrigin(ignore) == origin.getID()) { + return false; + } + } + return true; + } else { + return false; + } +} + +bool ModInfoWithConflictInfo::hasHiddenFiles() const +{ + return m_Conflicts.value().m_HasHiddenFiles; +} + +void ModInfoWithConflictInfo::diskContentModified() +{ + m_FileTree.invalidate(); + m_Valid.invalidate(); + m_Contents.invalidate(); +} + +void ModInfoWithConflictInfo::prefetch() +{ + // Populating the tree to 1-depth (IFileTree is lazy, so size() forces the + // tree to populate the first level): + fileTree()->size(); +} + +bool ModInfoWithConflictInfo::doIsValid() const +{ + auto mdc = m_Core.gameFeatures().gameFeature(); + + if (mdc) { + auto qdirfiletree = fileTree(); + return mdc->dataLooksValid(qdirfiletree) == ModDataChecker::CheckReturn::VALID; + } + + return true; +} + +std::shared_ptr ModInfoWithConflictInfo::fileTree() const +{ + return m_FileTree.value(); +} + +bool ModInfoWithConflictInfo::isValid() const +{ + return m_Valid.value(); +} + +const std::set& ModInfoWithConflictInfo::getContents() const +{ + return m_Contents.value(); +} + +bool ModInfoWithConflictInfo::hasContent(int content) const +{ + auto& contents = m_Contents.value(); + return std::find(std::begin(contents), std::end(contents), content) != + std::end(contents); +} diff --git a/src/src/modinfowithconflictinfo.h b/src/src/modinfowithconflictinfo.h new file mode 100644 index 0000000..168432e --- /dev/null +++ b/src/src/modinfowithconflictinfo.h @@ -0,0 +1,177 @@ +#ifndef MODINFOWITHCONFLICTINFO_H +#define MODINFOWITHCONFLICTINFO_H + +#include + +#include "memoizedlock.h" +#include "modinfo.h" + +#include +#include + +class ModInfoWithConflictInfo : public ModInfo +{ + +public: + std::vector getConflictFlags() const override; + virtual std::vector getFlags() const override; + + /** + * @return true if this mod is considered "valid", that is: it contains data used by + *the game + **/ + virtual bool isValid() const override; + + /** + * @return a list of content types contained in a mod + */ + virtual const std::set& getContents() const override; + + /** + * @brief Test if the mod contains the specified content. + * + * @param content ID of the content to test. + * + * @return true if the content is there, false otherwise. + */ + virtual bool hasContent(int content) const override; + + /** + * @brief Retrieve a file tree corresponding to the underlying disk content + * of this mod. + * + * The file tree should not be cached since it is already cached and updated when + * required. + * + * @return a file tree representing the content of this mod. + */ + std::shared_ptr fileTree() const override; + +public: + /** + * @brief clear all caches held for this mod + */ + void clearCaches() override; + + const std::set& getModOverwrite() const override + { + return m_Conflicts.value().m_OverwriteList; + } + const std::set& getModOverwritten() const override + { + return m_Conflicts.value().m_OverwrittenList; + } + const std::set& getModArchiveOverwrite() const override + { + return m_Conflicts.value().m_ArchiveOverwriteList; + } + const std::set& getModArchiveOverwritten() const override + { + return m_Conflicts.value().m_ArchiveOverwrittenList; + } + const std::set& getModArchiveLooseOverwrite() const override + { + return m_Conflicts.value().m_ArchiveLooseOverwriteList; + } + const std::set& getModArchiveLooseOverwritten() const override + { + return m_Conflicts.value().m_ArchiveLooseOverwrittenList; + } + +public slots: + + /** + * @brief Notify this mod that the content of the disk may have changed. + */ + virtual void diskContentModified(); + +protected: + // check if the content of this mod is valid + // + virtual bool doIsValid() const; + + /** + * @brief Compute the contents for this mod. + * + * @return the contents for this mod. + **/ + virtual std::set doGetContents() const { return {}; } + + ModInfoWithConflictInfo(OrganizerCore& core); + +private: + enum EConflictType + { + CONFLICT_NONE, + CONFLICT_OVERWRITE, + CONFLICT_OVERWRITTEN, + CONFLICT_MIXED, + CONFLICT_REDUNDANT, + CONFLICT_CROSS + }; + +private: + /** + * @return true if there is a conflict for files in this mod + */ + EConflictType isConflicted() const; + + /** + * @return true if there are archive conflicts for files in this mod + */ + EConflictType isArchiveConflicted() const; + + /** + * @return true if there are archive conflicts with loose files in this mod + */ + EConflictType isLooseArchiveConflicted() const; + + /** + * @return true if this mod is completely replaced by others + */ + bool isRedundant() const; + + bool hasHiddenFiles() const; + +protected: + /** + * @brief Prefetch content for this mod. + * + * This method can be used to prefetch content from the mod, e.g., for isValid() + * or getContents(). This method will only be called when first creating the mod + * using multiple threads for all the mods. + */ + virtual void prefetch() override; + +private: + struct Conflicts + { + EConflictType m_CurrentConflictState = CONFLICT_NONE; + EConflictType m_ArchiveConflictState = CONFLICT_NONE; + EConflictType m_ArchiveConflictLooseState = CONFLICT_NONE; + bool m_HasLooseOverwrite = false; + bool m_HasHiddenFiles = false; + + std::set m_OverwriteList; // indices of mods overritten by this mod + std::set m_OverwrittenList; // indices of mods overwriting this mod + std::set m_ArchiveOverwriteList; // indices of mods with archive + // files overritten by this mod + std::set m_ArchiveOverwrittenList; // indices of mods with archive + // files overwriting this mod + std::set + m_ArchiveLooseOverwriteList; // indices of mods with archives being overwritten + // by this mod's loose files + std::set + m_ArchiveLooseOverwrittenList; // indices of mods with loose files overwriting + // this mod's archive files + }; + + Conflicts doConflictCheck() const; + + MOBase::MemoizedLocked> m_FileTree; + MOBase::MemoizedLocked m_Valid; + MOBase::MemoizedLocked> m_Contents; + MOBase::MemoizedLocked m_Conflicts; +}; + +#endif // MODINFOWITHCONFLICTINFO_H diff --git a/src/src/modlist.cpp b/src/src/modlist.cpp new file mode 100644 index 0000000..9f695bd --- /dev/null +++ b/src/src/modlist.cpp @@ -0,0 +1,1514 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "modlist.h" + +#include "messagedialog.h" +#include "modinforegular.h" +#include "modlistdropinfo.h" +#include "modlistsortproxy.h" +#include "organizercore.h" +#include "pluginlist.h" +#include "qtgroupingproxy.h" +#include "settings.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "viewmarkingscrollbar.h" +#include "widgetutility.h" + +#include "filesystemutilities.h" +#include "shared/appconfig.h" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace MOBase; + +ModList::ModList(PluginContainer* pluginContainer, OrganizerCore* organizer) + : QAbstractItemModel(organizer), m_Organizer(organizer), m_Profile(nullptr), + m_NexusInterface(nullptr), m_Modified(false), m_InNotifyChange(false), + m_FontMetrics(QFont()), m_PluginContainer(pluginContainer) +{ + m_LastCheck.start(); +} + +ModList::~ModList() +{ + m_ModInstalled.disconnect_all_slots(); + m_ModRemoved.disconnect_all_slots(); + m_ModStateChanged.disconnect_all_slots(); + m_ModMoved.disconnect_all_slots(); +} + +void ModList::setProfile(Profile* profile) +{ + m_Profile = profile; +} + +int ModList::rowCount(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return ModInfo::getNumMods(); + } else { + return 0; + } +} + +bool ModList::hasChildren(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return ModInfo::getNumMods() > 0; + } else { + return false; + } +} + +int ModList::columnCount(const QModelIndex&) const +{ + return COL_LASTCOLUMN + 1; +} + +QString ModList::getDisplayName(ModInfo::Ptr info) const +{ + QString name = info->name(); + if (info->isSeparator()) { + name = name.replace("_separator", ""); + } + return name; +} + +QString ModList::makeInternalName(ModInfo::Ptr info, QString name) const +{ + if (info->isSeparator()) { + name += "_separator"; + } + return name; +} + +QString ModList::getFlagText(ModInfo::EFlag flag, ModInfo::Ptr modInfo) const +{ + switch (flag) { + case ModInfo::FLAG_BACKUP: + return tr("Backup"); + case ModInfo::FLAG_SEPARATOR: + return tr("Separator"); + case ModInfo::FLAG_INVALID: + return tr("No valid game data"); + case ModInfo::FLAG_NOTENDORSED: + return tr("Not endorsed yet"); + case ModInfo::FLAG_NOTES: { + QStringList output; + if (!modInfo->comments().isEmpty()) + output << QString("%1").arg(modInfo->comments()); + if (!modInfo->notes().isEmpty()) + output << QString("%1").arg(modInfo->notes()); + return output.join(""); + } + case ModInfo::FLAG_ALTERNATE_GAME: + return tr("This mod is for a different
    game, " + "make sure it's compatible or it could cause crashes."); + case ModInfo::FLAG_TRACKED: + return tr("Mod is being tracked on the website"); + case ModInfo::FLAG_HIDDEN_FILES: + return tr("Contains hidden files"); + default: + return ""; + } +} + +QString ModList::getConflictFlagText(ModInfo::EConflictFlag flag, + ModInfo::Ptr modInfo) const +{ + switch (flag) { + case ModInfo::FLAG_CONFLICT_OVERWRITE: + return tr("Overwrites loose files"); + case ModInfo::FLAG_CONFLICT_OVERWRITTEN: + return tr("Overwritten loose files"); + case ModInfo::FLAG_CONFLICT_MIXED: + return tr("Loose files Overwrites & Overwritten"); + case ModInfo::FLAG_CONFLICT_REDUNDANT: + return tr("Redundant"); + case ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE: + return tr("Overwrites an archive with loose files"); + case ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN: + return tr("Archive is overwritten by loose files"); + case ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITE: + return tr("Overwrites another archive file"); + case ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITTEN: + return tr("Overwritten by another archive file"); + case ModInfo::FLAG_ARCHIVE_CONFLICT_MIXED: + return tr("Archive files overwrites & overwritten"); + default: + return ""; + } +} + +QVariant ModList::data(const QModelIndex& modelIndex, int role) const +{ + if (m_Profile == nullptr) + return QVariant(); + if (!modelIndex.isValid()) + return QVariant(); + unsigned int modIndex = modelIndex.row(); + int column = modelIndex.column(); + + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); + if ((role == Qt::DisplayRole) || (role == Qt::EditRole)) { + if ((column == COL_FLAGS) || (column == COL_CONTENT) || + (column == COL_CONFLICTFLAGS)) { + return QVariant(); + } else if (column == COL_NAME) { + return getDisplayName(modInfo); + } else if (column == COL_VERSION) { + VersionInfo verInfo = modInfo->version(); + QString version = verInfo.displayString(); + if (role != Qt::EditRole) { + if (version.isEmpty() && modInfo->canBeUpdated()) { + version = "?"; + } + } + return version; + } else if (column == COL_PRIORITY) { + if (modInfo->hasAutomaticPriority()) { + return QVariant(); // hide priority for mods where it's fixed + } else { + return QString::number(m_Profile->getModPriority(modIndex)); + } + } else if (column == COL_MODID) { + int modID = modInfo->nexusId(); + if (modID > 0) { + return modID; + } else { + return QVariant(); + } + } else if (column == COL_GAME) { + if (m_PluginContainer != nullptr) { + for (auto game : m_PluginContainer->plugins()) { + if (game->gameShortName().compare(modInfo->gameName(), Qt::CaseInsensitive) == + 0) + return game->gameName(); + } + } + return modInfo->gameName(); + } else if (column == COL_CATEGORY) { + if (modInfo->hasFlag(ModInfo::FLAG_FOREIGN)) { + return tr("Non-MO"); + } else { + int category = modInfo->primaryCategory(); + if (category != -1) { + CategoryFactory& categoryFactory = CategoryFactory::instance(); + if (categoryFactory.categoryExists(category)) { + try { + int categoryIdx = categoryFactory.getCategoryIndex(category); + return categoryFactory.getCategoryName(categoryIdx); + } catch (const std::exception& e) { + log::error("failed to retrieve category name: {}", e.what()); + return QString(); + } + } else { + log::warn("category {} doesn't exist (may have been removed)", category); + modInfo->setCategory(category, false); + return QString(); + } + } else { + return QVariant(); + } + } + } else if (column == COL_AUTHOR) { + return modInfo->author(); + } else if (column == COL_UPLOADER) { + return modInfo->uploader(); + } else if (column == COL_INSTALLTIME) { + // display installation time for mods that can be updated + if (modInfo->creationTime().isValid()) { + return modInfo->creationTime(); + } else { + return QVariant(); + } + } else if (column == COL_NOTES) { + return modInfo->comments(); + } else { + return tr("invalid"); + } + } else if ((role == Qt::CheckStateRole) && (column == 0)) { + if (modInfo->canBeEnabled()) { + return m_Profile->modEnabled(modIndex) ? Qt::Checked : Qt::Unchecked; + } else { + return QVariant(); + } + } else if (role == Qt::TextAlignmentRole) { + if (column == COL_NAME) { + if (modInfo->getHighlight() & ModInfo::HIGHLIGHT_CENTER) { + return QVariant(Qt::AlignCenter | Qt::AlignVCenter); + } else { + return QVariant(Qt::AlignLeft | Qt::AlignVCenter); + } + } else if (column == COL_VERSION) { + return QVariant(Qt::AlignRight | Qt::AlignVCenter); + } else if (column == COL_NOTES) { + return QVariant(Qt::AlignLeft | Qt::AlignVCenter); + } else { + return QVariant(Qt::AlignCenter | Qt::AlignVCenter); + } + } else if (role == GroupingRole) { + if (column == COL_CATEGORY) { + QVariantList categoryNames; + std::set categories = modInfo->getCategories(); + CategoryFactory& categoryFactory = CategoryFactory::instance(); + for (auto iter = categories.begin(); iter != categories.end(); ++iter) { + categoryNames.append( + categoryFactory.getCategoryName(categoryFactory.getCategoryIndex(*iter))); + } + if (categoryNames.count() != 0) { + return categoryNames; + } else { + return QVariant(); + } + } else { + return modInfo->nexusId(); + } + } else if (role == IndexRole) { + return modIndex; + } else if (role == AggrRole) { + switch (column) { + case COL_MODID: + return QtGroupingProxy::AGGR_FIRST; + case COL_VERSION: + return QtGroupingProxy::AGGR_MAX; + case COL_CATEGORY: + return QtGroupingProxy::AGGR_FIRST; + case COL_PRIORITY: + return QtGroupingProxy::AGGR_MIN; + default: + return QtGroupingProxy::AGGR_NONE; + } + } else if (role == GameNameRole) { + return modInfo->gameName(); + } else if (role == PriorityRole) { + return m_Profile->getModPriority(modIndex); + } else if (role == Qt::FontRole) { + QFont result; + if (column == COL_NAME) { + if (modInfo->isSeparator()) { + result.setItalic(true); + result.setBold(true); + } else if (modInfo->getHighlight() & ModInfo::HIGHLIGHT_INVALID) { + result.setItalic(true); + } + } else if (column == COL_CATEGORY && modInfo->isForeign()) { + result.setItalic(true); + } else if (column == COL_VERSION) { + if (modInfo->updateAvailable() || modInfo->downgradeAvailable()) { + result.setWeight(QFont::Bold); + } + if (modInfo->canBeUpdated()) { + result.setItalic(true); + } + } + return result; + } else if (role == Qt::DecorationRole) { + if (column == COL_VERSION) { + if (modInfo->updateAvailable()) { + return QIcon(":/MO/gui/update_available"); + } else if (modInfo->downgradeAvailable()) { + return QIcon(":/MO/gui/warning"); + } else if (modInfo->version().scheme() == VersionInfo::SCHEME_DATE) { + return QIcon(":/MO/gui/version_date"); + } + } + return QVariant(); + } else if (role == Qt::ForegroundRole) { + if (column == COL_NAME) { + int highlight = modInfo->getHighlight(); + if (highlight & ModInfo::HIGHLIGHT_IMPORTANT) { + return QBrush(Qt::darkRed); + } else if (highlight & ModInfo::HIGHLIGHT_INVALID) { + return QBrush(Qt::darkGray); + } + } else if (column == COL_VERSION) { + if (!modInfo->newestVersion().isValid()) { + return QVariant(); + } else if (modInfo->updateAvailable() || modInfo->downgradeAvailable()) { + return QBrush(Qt::red); + } else { + return QBrush(Qt::darkGreen); + } + } + return QVariant(); + } else if (role == Qt::BackgroundRole || role == ScrollMarkRole) { + if (column == COL_NOTES && modInfo->color().isValid()) { + return modInfo->color(); + } else if (modInfo->isSeparator() && modInfo->color().isValid() && + (role != ScrollMarkRole || + Settings::instance().colors().colorSeparatorScrollbar())) { + return modInfo->color(); + } else { + return QVariant(); + } + } else if (role == Qt::ToolTipRole) { + if (column == COL_FLAGS) { + QString result; + + for (ModInfo::EFlag flag : modInfo->getFlags()) { + if (result.length() != 0) + result += "
    "; + result += getFlagText(flag, modInfo); + } + + return result; + } else if (column == COL_CONFLICTFLAGS) { + QString result; + + for (ModInfo::EConflictFlag flag : modInfo->getConflictFlags()) { + if (result.length() != 0) + result += "
    "; + result += getConflictFlagText(flag, modInfo); + } + + return result; + } else if (column == COL_NAME) { + try { + return modInfo->getDescription(); + } catch (const std::exception& e) { + log::error("invalid mod description: {}", e.what()); + return QString(); + } + } else if (column == COL_VERSION) { + QString text = tr("installed version: \"%1\", newest version: \"%2\"") + .arg(modInfo->version().displayString(3)) + .arg(modInfo->newestVersion().displayString(3)); + if (modInfo->downgradeAvailable()) { + text += + "
    " + tr("The newest version on Nexus seems to be older than the one " + "you have installed. This could either mean the version you " + "have has been withdrawn " + "(i.e. due to a bug) or the author uses a non-standard " + "versioning scheme and that newest version is actually newer. " + "Either way you may want to \"upgrade\"."); + } + if (modInfo->getNexusFileStatus() == NexusInterface::FileStatus::OLD_VERSION) { + text += "
    " + tr("This file has been marked as \"Old\". There is most " + "likely an updated version of this file available."); + } else if (modInfo->getNexusFileStatus() == NexusInterface::FileStatus::REMOVED || + modInfo->getNexusFileStatus() == + NexusInterface::FileStatus::ARCHIVED || + modInfo->getNexusFileStatus() == + NexusInterface::FileStatus::ARCHIVED_HIDDEN) { + text += + "
    " + tr("This file has been marked as \"Deleted\"! You may want to " + "check for an update or remove the nexus ID from this mod!"); + } + if (modInfo->nexusId() > 0) { + if (!modInfo->canBeUpdated()) { + qint64 remains = + QDateTime::currentDateTimeUtc().secsTo(modInfo->getExpires()); + qint64 minutes = remains / 60; + qint64 seconds = remains % 60; + QString remainsStr( + tr("%1 minute(s) and %2 second(s)").arg(minutes).arg(seconds)); + text += + "
    " + tr("This mod will be available to check in %2.").arg(remainsStr); + } + } + return text; + } else if (column == COL_CATEGORY) { + const std::set& categories = modInfo->getCategories(); + std::wostringstream categoryString; + categoryString << ToWString(tr("Categories:
    ")); + CategoryFactory& categoryFactory = CategoryFactory::instance(); + for (std::set::const_iterator catIter = categories.begin(); + catIter != categories.end(); ++catIter) { + if (catIter != categories.begin()) { + categoryString << " , "; + } + try { + categoryString << "" + << ToWString(categoryFactory.getCategoryName( + categoryFactory.getCategoryIndex(*catIter))) + << ""; + } catch (const std::exception& e) { + log::error("failed to generate tooltip: {}", e.what()); + return QString(); + } + } + + return ToQString(categoryString.str()); + } else if (column == COL_NOTES) { + return getFlagText(ModInfo::FLAG_NOTES, modInfo); + } else { + return QVariant(); + } + } else { + return QVariant(); + } +} + +bool ModList::renameMod(int index, const QString& newName) +{ + QString nameFixed = newName; + if (!fixDirectoryName(nameFixed) || nameFixed.isEmpty()) { + MessageDialog::showMessage(tr("Invalid name"), nullptr); + return false; + } + + if (ModList::allMods().contains(nameFixed, Qt::CaseInsensitive) && + nameFixed.toLower() != ModInfo::getByIndex(index)->name().toLower()) { + MessageDialog::showMessage(tr("Name is already in use by another mod"), nullptr); + return false; + } + + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + QString oldName = modInfo->name(); + if (nameFixed != oldName) { + // before we rename, ensure there is no scheduled asynchronous to rewrite + m_Profile->cancelModlistWrite(); + + if (modInfo->setName(nameFixed)) { + // Notice there is a good chance that setName() updated the modinfo indexes + // the modRenamed() call will refresh the indexes in the current profile + // and update the modlists in all profiles + emit modRenamed(oldName, nameFixed); + } + + // invalidate the currently displayed state of this list + notifyChange(-1); + } + return true; +} + +bool ModList::setData(const QModelIndex& index, const QVariant& value, int role) +{ + if (m_Profile == nullptr) + return false; + + if (static_cast(index.row()) >= ModInfo::getNumMods()) { + return false; + } + + int modID = index.row(); + + ModInfo::Ptr info = ModInfo::getByIndex(modID); + IModList::ModStates oldState = state(modID); + + bool result = false; + + emit aboutToChangeData(); + + if (role == Qt::CheckStateRole) { + bool enabled = value.toInt() == Qt::Checked; + if (m_Profile->modEnabled(modID) != enabled) { + m_Profile->setModEnabled(modID, enabled); + m_Modified = true; + m_LastCheck.restart(); + emit tutorialModlistUpdate(); + } + result = true; + emit dataChanged(index, index); + } else if (role == Qt::EditRole) { + switch (index.column()) { + case COL_NAME: { + result = renameMod(modID, makeInternalName(info, value.toString())); + } break; + case COL_PRIORITY: { + bool ok = false; + int newPriority = value.toInt(&ok); + if (ok) { + changeModPriority(modID, newPriority); + result = true; + } else { + result = false; + } + } break; + case COL_MODID: { + bool ok = false; + int newID = value.toInt(&ok); + if (ok) { + info->setNexusID(newID); + emit tutorialModlistUpdate(); + result = true; + } else { + result = false; + } + } break; + case COL_VERSION: { + VersionInfo::VersionScheme scheme = info->version().scheme(); + VersionInfo version(value.toString(), scheme, true); + if (version.isValid()) { + info->setVersion(version); + result = true; + } else { + result = false; + } + } break; + case COL_NOTES: { + info->setComments(value.toString()); + result = true; + } break; + default: { + log::warn("edit on column \"{}\" not supported", + getColumnName(index.column()).toUtf8().constData()); + result = false; + } break; + } + if (result) { + emit dataChanged(index, index); + } + } + + emit postDataChanged(); + return result; +} + +QVariant ModList::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation == Qt::Horizontal) { + if (role == Qt::DisplayRole) { + return getColumnName(section); + } else if (role == Qt::ToolTipRole) { + return getColumnToolTip(section); + } else if (role == Qt::TextAlignmentRole) { + return QVariant(Qt::AlignCenter); + } else if (role == MOBase::EnabledColumnRole) { + if (section == COL_CONTENT) { + return !m_Organizer->modDataContents().empty(); + } else { + return true; + } + } + } + return QAbstractItemModel::headerData(section, orientation, role); +} + +Qt::ItemFlags ModList::flags(const QModelIndex& modelIndex) const +{ + Qt::ItemFlags result = QAbstractItemModel::flags(modelIndex); + if (modelIndex.internalId() < 0) { + return Qt::ItemIsEnabled; + } + if (modelIndex.isValid()) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(modelIndex.row()); + if (!modInfo->hasAutomaticPriority()) { + result |= Qt::ItemIsDragEnabled; + result |= Qt::ItemIsUserCheckable; + if ((modelIndex.column() == COL_PRIORITY) || + (modelIndex.column() == COL_VERSION) || (modelIndex.column() == COL_MODID)) { + result |= Qt::ItemIsEditable; + } + if ((modelIndex.column() == COL_NAME || modelIndex.column() == COL_NOTES) && + !modInfo->isForeign()) { + result |= Qt::ItemIsEditable; + } + } + if (modInfo->isSeparator() || m_DropOnMod) { + result |= Qt::ItemIsDropEnabled; + } + } else if (!m_DropOnMod) { + result |= Qt::ItemIsDropEnabled; + } + + return result; +} + +QStringList ModList::mimeTypes() const +{ + QStringList result = QAbstractItemModel::mimeTypes(); + result.append("text/uri-list"); + return result; +} + +QMimeData* ModList::mimeData(const QModelIndexList& indexes) const +{ + QMimeData* result = QAbstractItemModel::mimeData(indexes); + result->setData("text/plain", ModListDropInfo::ModText); + return result; +} + +void ModList::changeModPriority(std::vector sourceIndices, int newPriority) +{ + if (m_Profile == nullptr) + return; + + sourceIndices.erase( + std::remove_if(sourceIndices.begin(), sourceIndices.end(), [](int index) { + return index < 0 || static_cast(index) >= ModInfo::getNumMods(); + }), + sourceIndices.end()); + + if (sourceIndices.empty()) { + return; + } + + const bool anyNeedsMove = std::any_of( + sourceIndices.begin(), sourceIndices.end(), + [this, newPriority](int index) { return m_Profile->getModPriority(index) != newPriority; }); + if (!anyNeedsMove) { + return; + } + + emit layoutAboutToBeChanged(); + + // sort the moving mods by ascending priorities + std::sort(sourceIndices.begin(), sourceIndices.end(), + [=](const int& LHS, const int& RHS) { + return m_Profile->getModPriority(LHS) > m_Profile->getModPriority(RHS); + }); + + // move mods that are decreasing in priority + for (const auto& index : sourceIndices) { + int oldPriority = m_Profile->getModPriority(index); + if (oldPriority > newPriority) { + if (m_Profile->setModPriority(index, newPriority)) { + m_ModMoved(ModInfo::getByIndex(index)->name(), oldPriority, newPriority); + } + } + } + + // sort the moving mods by descending priorities + std::sort(sourceIndices.begin(), sourceIndices.end(), + [=](const int& LHS, const int& RHS) { + return m_Profile->getModPriority(LHS) < m_Profile->getModPriority(RHS); + }); + + // if at least one mod is increasing in priority, the target index is + // that of the row BELOW the dropped location, otherwise it's the one above + for (const auto& index : sourceIndices) { + int oldPriority = m_Profile->getModPriority(index); + if (oldPriority < newPriority) { + --newPriority; + break; + } + } + + // move mods that are increasing in priority + for (const auto& index : sourceIndices) { + int oldPriority = m_Profile->getModPriority(index); + if (oldPriority < newPriority) { + if (m_Profile->setModPriority(index, newPriority)) { + m_ModMoved(ModInfo::getByIndex(index)->name(), oldPriority, newPriority); + } + } + } + + emit layoutChanged(); + + QModelIndexList indices; + for (auto& idx : sourceIndices) { + indices.append(index(idx, 0, QModelIndex())); + } + + emit modPrioritiesChanged(indices); +} + +void ModList::changeModPriority(int sourceIndex, int newPriority) +{ + if (m_Profile == nullptr) + return; + emit layoutAboutToBeChanged(); + + m_Profile->setModPriority(sourceIndex, newPriority); + + emit layoutChanged(); + emit modPrioritiesChanged({index(sourceIndex, 0)}); +} + +void ModList::setPluginContainer(PluginContainer* pluginContianer) +{ + m_PluginContainer = pluginContianer; +} + +bool ModList::modInfoAboutToChange(ModInfo::Ptr info) +{ + if (m_ChangeInfo.name.isEmpty()) { + m_ChangeInfo.name = info->name(); + m_ChangeInfo.state = state(info->name()); + return true; + } else { + return false; + } +} + +void ModList::modInfoChanged(ModInfo::Ptr info) +{ + if (info->name() == m_ChangeInfo.name) { + IModList::ModStates newState = state(info->name()); + if (m_ChangeInfo.state != newState) { + m_ModStateChanged({{info->name(), newState}}); + } + + int row = ModInfo::getIndex(info->name()); + info->diskContentModified(); + emit aboutToChangeData(); + emit dataChanged(index(row, 0), index(row, columnCount())); + emit postDataChanged(); + } else { + log::error("modInfoChanged not called after modInfoAboutToChange"); + } + m_ChangeInfo.name = QString(); +} + +void ModList::disconnectSlots() +{ + m_ModMoved.disconnect_all_slots(); + m_ModStateChanged.disconnect_all_slots(); +} + +int ModList::timeElapsedSinceLastChecked() const +{ + return m_LastCheck.elapsed(); +} + +IModList::ModStates ModList::state(unsigned int modIndex) const +{ + IModList::ModStates result; + if (modIndex != UINT_MAX) { + result |= IModList::STATE_EXISTS; + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); + if (modInfo->isEmpty()) { + result |= IModList::STATE_EMPTY; + } + if (modInfo->endorsedState() == EndorsedState::ENDORSED_TRUE) { + result |= IModList::STATE_ENDORSED; + } + if (modInfo->isValid()) { + result |= IModList::STATE_VALID; + } + if (modInfo->isRegular()) { + QSharedPointer modInfoRegular = + modInfo.staticCast(); + if (modInfoRegular->isAlternate() && !modInfoRegular->isConverted()) + result |= IModList::STATE_ALTERNATE; + if (!modInfo->isValid() && modInfoRegular->isValidated()) + result |= IModList::STATE_VALID; + } + if (modInfo->canBeEnabled()) { + if (m_Profile->modEnabled(modIndex)) { + result |= IModList::STATE_ACTIVE; + } + } else { + result |= IModList::STATE_ESSENTIAL; + } + } + return result; +} + +QString ModList::displayName(const QString& internalName) const +{ + unsigned int modIndex = ModInfo::getIndex(internalName); + if (modIndex == UINT_MAX) { + // might be better to throw an exception? + return internalName; + } else { + return ModInfo::getByIndex(modIndex)->name(); + } +} + +QStringList ModList::allMods() const +{ + QStringList result; + for (unsigned int i = 0; i < ModInfo::getNumMods(); ++i) { + result.append(ModInfo::getByIndex(i)->internalName()); + } + return result; +} + +QStringList ModList::allModsByProfilePriority(MOBase::IProfile* profile) const +{ + Profile* mo2Profile = profile == nullptr ? m_Organizer->currentProfile().get() + : static_cast(profile); + + QStringList res; + for (auto& [priority, index] : mo2Profile->getAllIndexesByPriority()) { + auto modInfo = ModInfo::getByIndex(index); + if (!modInfo->isBackup() && !modInfo->isOverwrite()) { + res.push_back(modInfo->internalName()); + } + } + return res; +} + +MOBase::IModInterface* ModList::getMod(const QString& name) const +{ + unsigned int index = ModInfo::getIndex(name); + return index == UINT_MAX ? nullptr : ModInfo::getByIndex(index).data(); +} + +bool ModList::removeMod(MOBase::IModInterface* mod) +{ + bool result = ModInfo::removeMod(ModInfo::getIndex(mod->name())); + if (result) { + notifyModRemoved(mod->name()); + } + return result; +} + +MOBase::IModInterface* ModList::renameMod(MOBase::IModInterface* mod, + const QString& name) +{ + unsigned int index = ModInfo::getIndex(mod->name()); + if (index == UINT_MAX) { + if (auto* p = dynamic_cast(mod)) { + p->setName(name); + return p; + } else { + return nullptr; + } + } else { + if (renameMod(index, name)) { + return ModInfo::getByName(name).get(); + } else { + return nullptr; + } + } +} + +IModList::ModStates ModList::state(const QString& name) const +{ + unsigned int modIndex = ModInfo::getIndex(name); + + return state(modIndex); +} + +bool ModList::setActive(const QString& name, bool active) +{ + unsigned int modIndex = ModInfo::getIndex(name); + if (modIndex == UINT_MAX) { + log::debug("Trying to {} mod {} which does not exist.", + active ? "enable" : "disable", name); + return false; + } else { + m_Profile->setModEnabled(modIndex, active); + return true; + } +} + +int ModList::setActive(const QStringList& names, bool active) +{ + + // We only add indices for mods that exist (modIndex != UINT_MAX) + // and that can be enabled / disabled. + QList indices; + for (const auto& name : names) { + auto modIndex = ModInfo::getIndex(name); + if (modIndex != UINT_MAX) { + indices.append(modIndex); + } else { + log::debug("Trying to {} mod {} which does not exist.", + active ? "enable" : "disable", name); + } + } + + if (active) { + m_Profile->setModsEnabled(indices, {}); + } else { + m_Profile->setModsEnabled({}, indices); + } + + return indices.size(); +} + +int ModList::priority(const QString& name) const +{ + unsigned int modIndex = ModInfo::getIndex(name); + if (modIndex == UINT_MAX) { + return -1; + } else { + return m_Profile->getModPriority(modIndex); + } +} + +bool ModList::setPriority(const QString& name, int newPriority) +{ + unsigned int index = ModInfo::getIndex(name); + if (index == UINT_MAX) { + return false; + } else { + if (m_Profile->setModPriority(index, newPriority)) { + notifyChange(index); + } + return true; + } +} + +boost::signals2::connection +ModList::onModInstalled(const std::function& func) +{ + return m_ModInstalled.connect(func); +} + +boost::signals2::connection +ModList::onModRemoved(const std::function& func) +{ + return m_ModRemoved.connect(func); +} + +boost::signals2::connection ModList::onModStateChanged( + const std::function&)>& func) +{ + return m_ModStateChanged.connect(func); +} + +void ModList::notifyModInstalled(MOBase::IModInterface* mod) const +{ + m_ModInstalled(mod); +} + +void ModList::notifyModRemoved(QString const& modName) const +{ + m_ModRemoved(modName); +} + +void ModList::notifyModStateChanged(QList modIndices) const +{ + QModelIndexList indices; + std::map mods; + for (auto modIndex : modIndices) { + indices.append(index(modIndex, 0)); + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); + mods.emplace(modInfo->name(), state(modIndex)); + } + + emit modStatesChanged(indices); + m_ModStateChanged(mods); +} + +boost::signals2::connection +ModList::onModMoved(const std::function& func) +{ + return m_ModMoved.connect(func); +} + +int ModList::dropPriority(int row, const QModelIndex& parent) const +{ + if (row == -1) { + row = parent.row(); + } + + if ((row < 0) || (static_cast(row) >= ModInfo::getNumMods())) { + return -1; + } + + int newPriority = 0; + { + if (row < 0 || row >= rowCount()) { + newPriority = Profile::MaximumPriority; + } else { + newPriority = m_Profile->getModPriority(row); + } + } + + return newPriority; +} + +bool ModList::dropLocalFiles(const ModListDropInfo& dropInfo, int row, + const QModelIndex& parent) +{ + if (row == -1) { + row = parent.row(); + } + ModInfo::Ptr modInfo = ModInfo::getByIndex(row); + QDir modDir = QDir(modInfo->absolutePath()); + + QStringList sourceList; + QStringList targetList; + QList> relativePathList; + + for (auto localUrl : dropInfo.localUrls()) { + QFileInfo sourceInfo(localUrl.url.toLocalFile()); + if (localUrl.originName.compare("overwrite", Qt::CaseInsensitive) == 0) { + bool needsMove = true; + if (sourceInfo.isDir()) { + for (auto dir : m_Organizer->managedGame()->getModMappings().keys()) { + QDir overDir(m_Organizer->overwritePath()); + if (sourceInfo.canonicalFilePath().compare(overDir.absoluteFilePath(dir), + Qt::CaseInsensitive) == 0) { + needsMove = false; + + QDirIterator dirIter(overDir.absoluteFilePath(dir), + QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot); + while (dirIter.hasNext()) { + auto entry = dirIter.nextFileInfo(); + QString sourceFile = entry.canonicalFilePath(); + + QFileInfo targetInfo(modDir.absoluteFilePath( + overDir.relativeFilePath(entry.absoluteFilePath()))); + sourceList << sourceFile; + targetList << targetInfo.absoluteFilePath(); + relativePathList << QPair(localUrl.relativePath, + localUrl.originName); + } + } + } + } + if (needsMove) { + QString sourceFile = sourceInfo.canonicalFilePath(); + + QFileInfo targetInfo(modDir.absoluteFilePath(localUrl.relativePath)); + sourceList << sourceFile; + targetList << targetInfo.absoluteFilePath(); + relativePathList << QPair(localUrl.relativePath, + localUrl.originName); + } + } else { + QString sourceFile = sourceInfo.canonicalFilePath(); + + QFileInfo targetInfo(modDir.absoluteFilePath(localUrl.relativePath)); + sourceList << sourceFile; + targetList << targetInfo.absoluteFilePath(); + relativePathList << QPair(localUrl.relativePath, + localUrl.originName); + } + } + + if (sourceList.count()) { + if (!shellMove(sourceList, targetList)) { + log::debug("Failed to move file (error {})", ::GetLastError()); + return false; + } + } + + for (auto iter : relativePathList) { + emit fileMoved(iter.first, iter.second, modInfo->name()); + } + + if (!modInfo->isValid()) { + modInfo->diskContentModified(); + } + + return true; +} + +void ModList::onDragEnter(const QMimeData* mimeData) +{ + m_DropOnMod = ModListDropInfo(mimeData, *m_Organizer).isLocalFileDrop(); +} + +bool ModList::canDropMimeData(const QMimeData* mimeData, Qt::DropAction action, int row, + int column, const QModelIndex& parent) const +{ + if (action == Qt::IgnoreAction) { + return false; + } + + ModListDropInfo dropInfo(mimeData, *m_Organizer); + + if (dropInfo.isLocalFileDrop()) { + if (row == -1 && parent.isValid()) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(parent.row()); + return modInfo->isRegular() && !modInfo->isSeparator(); + } + } else if (dropInfo.isValid()) { + // drop on item + if (row == -1 && parent.isValid()) { + return true; + } else if (hasIndex(row, column, parent)) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(row); + return !modInfo->isBackup() && (modInfo->isSeparator() || !parent.isValid()); + } else { + return true; + } + } + + return false; +} + +bool ModList::dropMimeData(const QMimeData* mimeData, Qt::DropAction action, int row, + int, const QModelIndex& parent) +{ + if (action == Qt::IgnoreAction) { + return true; + } + + ModListDropInfo dropInfo(mimeData, *m_Organizer); + + if (!m_Profile || !dropInfo.isValid()) { + return false; + } + + int dropPriority = this->dropPriority(row, parent); + if (dropPriority == -1) { + return false; + } + + if (dropInfo.isLocalFileDrop()) { + return dropLocalFiles(dropInfo, row, parent); + } else { + if (dropInfo.isModDrop()) { + std::vector sourceRows = dropInfo.rows(); + sourceRows.erase( + std::remove_if(sourceRows.begin(), sourceRows.end(), [](int index) { + return index < 0 || static_cast(index) >= ModInfo::getNumMods(); + }), + sourceRows.end()); + + if (sourceRows.empty()) { + return true; + } + + changeModPriority(sourceRows, dropPriority); + return true; + } else if (dropInfo.isDownloadDrop()) { + emit downloadArchiveDropped(dropInfo.download(), dropPriority); + return true; + } else if (dropInfo.isExternalArchiveDrop()) { + emit externalArchiveDropped(dropInfo.externalUrl(), dropPriority); + return true; + } else if (dropInfo.isExternalFolderDrop()) { + emit externalFolderDropped(dropInfo.externalUrl(), dropPriority); + return true; + } else { + return false; + } + } + return true; +} + +void ModList::removeRowForce(int row, const QModelIndex& parent) +{ + if (static_cast(row) >= ModInfo::getNumMods()) { + return; + } + if (m_Profile == nullptr) + return; + + ModInfo::Ptr modInfo = ModInfo::getByIndex(row); + + bool wasEnabled = m_Profile->modEnabled(row); + + m_Profile->setModEnabled(row, false); + + m_Profile->cancelModlistWrite(); + beginRemoveRows(parent, row, row); + ModInfo::removeMod(row); + m_Profile->refreshModStatus(); // removes the mod from the status list + endRemoveRows(); + m_Profile->writeModlist(); // this ensures the modified list gets written back before + // new mods can be installed + + notifyModRemoved(modInfo->name()); + + if (wasEnabled) { + emit removeOrigin(modInfo->name()); + } + if (!modInfo->isBackup()) { + emit modUninstalled(modInfo->installationFile()); + } +} + +bool ModList::removeRows(int row, int count, const QModelIndex& parent) +{ + if (static_cast(row) >= ModInfo::getNumMods()) { + return false; + } + if (m_Profile == nullptr) { + return false; + } + + bool success = false; + + if (count == 1) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(row); + if (modInfo->isOverwrite() && QDir(modInfo->absolutePath()).count() > 2) { + emit clearOverwrite(); + success = true; + } + } + + for (int i = 0; i < count; ++i) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(row + i); + if (!modInfo->isRegular()) { + continue; + } + + success = true; + + QMessageBox confirmBox( + QMessageBox::Question, tr("Confirm"), + tr("Are you sure you want to remove \"%1\"?").arg(getDisplayName(modInfo)), + QMessageBox::Yes | QMessageBox::No); + + if (confirmBox.exec() == QMessageBox::Yes) { + m_Profile->setModEnabled(row + i, false); + removeRowForce(row + i, parent); + } + } + + return success; +} + +void ModList::notifyChange(int rowStart, int rowEnd) +{ + // this function can emit dataChanged(), which can eventually recurse back + // here; for example: + // + // - a filter is active in the mod list, such as "no categories" + // - mods are selected and a category is set on them + // - these mods get updated here and disappear from the list because they're + // not in "no categories" anymore + // - dataChanged() is emitted + // - it's picked up in MainWindow::modlistSelectionsChanged() because the + // selected mods are gone + // - it calls setOverwriteMarkers(), which calls notifyChange() again and + // ends up here + // - dataChanged() is emitted again + // + // at this point, MO crashes because dataChanged() is not reentrant: it's in + // the middle of modifying internal data and crashes when trying to change an + // internal vector + // + // long story short, this prevents reentrancy + if (m_InNotifyChange) { + return; + } + + m_InNotifyChange = true; + Guard g([&] { + m_InNotifyChange = false; + }); + + if (rowStart < 0) { + beginResetModel(); + endResetModel(); + } else { + if (rowEnd == -1) { + rowEnd = rowStart; + } + emit dataChanged(this->index(rowStart, 0), + this->index(rowEnd, this->columnCount() - 1)); + } +} + +QModelIndex ModList::index(int row, int column, const QModelIndex&) const +{ + if ((row < 0) || (row >= rowCount()) || (column < 0) || (column >= columnCount())) { + return QModelIndex(); + } + QModelIndex res = createIndex(row, column, row); + return res; +} + +QModelIndex ModList::parent(const QModelIndex&) const +{ + return QModelIndex(); +} + +QMap ModList::itemData(const QModelIndex& index) const +{ + QMap result = QAbstractItemModel::itemData(index); + for (int role = Qt::UserRole; role < ModUserRole; ++role) { + result[role] = data(index, role); + } + return result; +} + +QString ModList::getColumnName(int column) +{ + switch (column) { + case COL_CONFLICTFLAGS: + return tr("Conflicts"); + case COL_FLAGS: + return tr("Flags"); + case COL_CONTENT: + return tr("Content"); + case COL_NAME: + return tr("Mod Name"); + case COL_VERSION: + return tr("Version"); + case COL_PRIORITY: + return tr("Priority"); + case COL_CATEGORY: + return tr("Category"); + case COL_AUTHOR: + return tr("Author"); + case COL_UPLOADER: + return tr("Uploader"); + case COL_GAME: + return tr("Source Game"); + case COL_MODID: + return tr("Nexus ID"); + case COL_INSTALLTIME: + return tr("Installation"); + case COL_NOTES: + return tr("Notes"); + default: + return tr("unknown"); + } +} + +QString ModList::getColumnToolTip(int column) const +{ + switch (column) { + case COL_NAME: + return tr("Name of your mods"); + case COL_VERSION: + return tr("Version of the mod (if available)"); + case COL_PRIORITY: + return tr("Installation priority of your mod. The higher, the more \"important\" " + "it is and thus " + "overwrites files from mods with lower priority."); + case COL_CATEGORY: + return tr("Primary category of the mod."); + case COL_AUTHOR: + return tr("Author(s) of the mod."); + case COL_UPLOADER: + return tr("Uploader of the mod. This is not necessarily the same as the author."); + case COL_GAME: + return tr("The source game which was the origin of this mod."); + case COL_MODID: + return tr("Id of the mod as used on Nexus."); + case COL_CONFLICTFLAGS: + return tr("Indicators of file conflicts between mods."); + case COL_FLAGS: + return tr("Emblems to highlight things that might require attention."); + case COL_CONTENT: { + auto& contents = m_Organizer->modDataContents(); + if (m_Organizer->modDataContents().empty()) { + return QString(); + } + QString result = + tr("Depicts the content of the mod:") + "
    " + ""; + m_Organizer->modDataContents().forEachContent([&result](auto const& content) { + result += QString("") + .arg(content.icon()) + .arg(content.name()); + }); + return result + "
    %2
    "; + }; + case COL_INSTALLTIME: + return tr("Time this mod was installed"); + case COL_NOTES: + return tr("User notes about the mod"); + default: + return tr("unknown"); + } +} + +void ModList::shiftModsPriority(const QModelIndexList& indices, int offset) +{ + // retrieve the mod index and sort them by priority to avoid issue + // when moving them + std::vector allIndex; + for (auto& idx : indices) { + auto index = idx.data(IndexRole).toInt(); + allIndex.push_back(index); + } + std::sort(allIndex.begin(), allIndex.end(), [=](int lhs, int rhs) { + bool cmp = m_Profile->getModPriority(lhs) < m_Profile->getModPriority(rhs); + return offset > 0 ? !cmp : cmp; + }); + + emit layoutAboutToBeChanged(); + + std::vector notify; + for (auto index : allIndex) { + int newPriority = m_Profile->getModPriority(index) + offset; + if (m_Profile->setModPriority(index, newPriority)) { + notify.push_back(index); + } + } + + emit layoutChanged(); + + for (auto index : notify) { + notifyChange(index); + } + + emit modPrioritiesChanged(indices); +} + +void ModList::changeModsPriority(const QModelIndexList& indices, int priority) +{ + if (indices.isEmpty()) { + return; + } + + std::vector allIndex; + for (auto& idx : indices) { + auto index = idx.data(IndexRole).toInt(); + allIndex.push_back(index); + } + + if (allIndex.size() == 1) { + changeModPriority(allIndex[0], priority); + } else { + changeModPriority(allIndex, priority); + } +} + +bool ModList::toggleState(const QModelIndexList& indices) +{ + emit aboutToChangeData(); + + QList modsToEnable; + QList modsToDisable; + for (auto index : indices) { + auto idx = index.data(IndexRole).toInt(); + if (m_Profile->modEnabled(idx)) { + modsToDisable.append(idx); + } else { + modsToEnable.append(idx); + } + } + + m_Profile->setModsEnabled(modsToEnable, modsToDisable); + + emit tutorialModlistUpdate(); + + m_Modified = true; + m_LastCheck.restart(); + + emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1)); + + emit postDataChanged(); + + return true; +} + +void ModList::setActive(const QModelIndexList& indices, bool active) +{ + QList mods; + for (auto& index : indices) { + mods.append(index.data(IndexRole).toInt()); + } + + if (active) { + m_Profile->setModsEnabled(mods, {}); + } else { + m_Profile->setModsEnabled({}, mods); + } +} diff --git a/src/src/modlist.h b/src/src/modlist.h new file mode 100644 index 0000000..e28a182 --- /dev/null +++ b/src/src/modlist.h @@ -0,0 +1,422 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef MODLIST_H +#define MODLIST_H + +#include "categories.h" +#include "moddatacontent.h" +#include "modinfo.h" +#include "nexusinterface.h" +#include "profile.h" + +#include + +#include +#include +#include +#include +#include +#ifndef Q_MOC_RUN +#include +#endif +#include +#include +#include + +class QSortFilterProxyModel; +class PluginContainer; +class OrganizerCore; +class ModListDropInfo; + +/** + * Model presenting an overview of the installed mod + * This is used in a view in the main window of MO. It combines general information + *about the mods from ModInfo with status information from the Profile + **/ +class ModList : public QAbstractItemModel +{ + Q_OBJECT + +public: + enum ModListRole + { + + // data(GroupingRole) contains the "group" role - This is used by the + // category and Nexus ID grouping proxy (but not the ByPriority proxy) + GroupingRole = Qt::UserRole, + + IndexRole = Qt::UserRole + 1, + + // data(AggrRole) contains aggregation information (for + // grouping I assume?) + AggrRole = Qt::UserRole + 2, + + GameNameRole = Qt::UserRole + 3, + PriorityRole = Qt::UserRole + 4, + + // marking role for the scrollbar + ScrollMarkRole = Qt::UserRole + 5, + + // this is the first available role + ModUserRole = Qt::UserRole + 6 + }; + + enum EColumn + { + COL_NAME, + COL_CONFLICTFLAGS, + COL_FLAGS, + COL_CONTENT, + COL_CATEGORY, + COL_AUTHOR, + COL_UPLOADER, + COL_MODID, + COL_GAME, + COL_VERSION, + COL_INSTALLTIME, + COL_PRIORITY, + COL_NOTES, + COL_LASTCOLUMN = COL_NOTES, + }; + + using SignalModInstalled = boost::signals2::signal; + using SignalModRemoved = boost::signals2::signal; + using SignalModStateChanged = boost::signals2::signal&)>; + using SignalModMoved = boost::signals2::signal; + +public: + /** + * @brief constructor + * @todo ensure this view works without a profile set, otherwise there are + *intransparent dependencies on the initialisation order + **/ + ModList(PluginContainer* pluginContainer, OrganizerCore* parent); + + ~ModList(); + + /** + * @brief set the profile used for status information + * + * @param profile the profile to use + **/ + void setProfile(Profile* profile); + + /** + * @brief retrieve the current sorting mode + * @note this is used to store the sorting mode between sessions + * @return current sorting mode, encoded to be compatible to previous versions + **/ + int getCurrentSortingMode() const; + + /** + * @brief remove the specified mod without asking for confirmation + * @param row the row to remove + */ + void removeRowForce(int row, const QModelIndex& parent); + + void notifyChange(int rowStart, int rowEnd = -1); + static QString getColumnName(int column); + + void changeModPriority(int sourceIndex, int newPriority); + void changeModPriority(std::vector sourceIndices, int newPriority); + + void setPluginContainer(PluginContainer* pluginContainer); + + bool modInfoAboutToChange(ModInfo::Ptr info); + void modInfoChanged(ModInfo::Ptr info); + + void disconnectSlots(); + + int timeElapsedSinceLastChecked() const; + +public: + /** + * @brief Notify the mod list that the given mod has been installed. This is used + * to notify the plugin that registered through onModInstalled(). + * + * @param mod The installed mod. + */ + void notifyModInstalled(MOBase::IModInterface* mod) const; + + /** + * @brief Notify the mod list that a mod has been removed. This is used + * to notify the plugin that registered through onModRemoved(). + * + * @param modName Name of the removed mod. + */ + void notifyModRemoved(QString const& modName) const; + + /** + * @brief Notify the mod list that the state of the specified mods has changed. This + * is used to notify the plugin that registered through onModStateChanged(). + * + * @param modIndices Indices of the mods that changed. + */ + void notifyModStateChanged(QList modIndices) const; + +public: + /// \copydoc MOBase::IModList::displayName + QString displayName(const QString& internalName) const; + + /// \copydoc MOBase::IModList::allMods + QStringList allMods() const; + QStringList allModsByProfilePriority(MOBase::IProfile* profile = nullptr) const; + + // \copydoc MOBase::IModList::getMod + MOBase::IModInterface* getMod(const QString& name) const; + + // \copydoc MOBase::IModList::remove + bool removeMod(MOBase::IModInterface* mod); + + // \copydoc MOBase::IModList::renameMod + MOBase::IModInterface* renameMod(MOBase::IModInterface* mod, const QString& name); + + /// \copydoc MOBase::IModList::state + MOBase::IModList::ModStates state(const QString& name) const; + + /// \copydoc MOBase::IModList::setActive + bool setActive(const QString& name, bool active); + + /// \copydoc MOBase::IModList::setActive + int setActive(const QStringList& names, bool active); + + /// \copydoc MOBase::IModList::priority + int priority(const QString& name) const; + + /// \copydoc MOBase::IModList::setPriority + bool setPriority(const QString& name, int newPriority); + + boost::signals2::connection + onModInstalled(const std::function& func); + boost::signals2::connection + onModRemoved(const std::function& func); + boost::signals2::connection onModStateChanged( + const std::function&)>& + func); + boost::signals2::connection + onModMoved(const std::function& func); + +public: // implementation of virtual functions of QAbstractItemModel + virtual int rowCount(const QModelIndex& parent = QModelIndex()) const; + virtual bool hasChildren(const QModelIndex& parent = QModelIndex()) const; + virtual int columnCount(const QModelIndex& parent = QModelIndex()) const; + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const; + virtual bool setData(const QModelIndex& index, const QVariant& value, + int role = Qt::EditRole); + virtual QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const; + virtual Qt::ItemFlags flags(const QModelIndex& modelIndex) const; + virtual bool removeRows(int row, int count, const QModelIndex& parent); + + Qt::DropActions supportedDropActions() const override + { + return Qt::MoveAction | Qt::CopyAction; + } + QStringList mimeTypes() const override; + QMimeData* mimeData(const QModelIndexList& indexes) const override; + bool canDropMimeData(const QMimeData* data, Qt::DropAction action, int row, + int column, const QModelIndex& parent) const override; + bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column, + const QModelIndex& parent) override; + + virtual QModelIndex index(int row, int column, + const QModelIndex& parent = QModelIndex()) const; + virtual QModelIndex parent(const QModelIndex& child) const; + + virtual QMap itemData(const QModelIndex& index) const; + +public slots: + + void onDragEnter(const QMimeData* data); + + // enable/disable mods at the given indices. + // + void setActive(const QModelIndexList& indices, bool active); + + // shift the priority of mods at the given indices by the given offset + // + void shiftModsPriority(const QModelIndexList& indices, int offset); + + // change the priority of the mods specified by the given indices + // + void changeModsPriority(const QModelIndexList& indices, int priority); + + // toggle the active state of mods at the given indices + // + bool toggleState(const QModelIndexList& indices); + +signals: + + // emitted when the priority of one or multiple mods have changed + // + // the sorting of the list can only be manually changed if the list is sorted by + // priority in which case the move is intended to change the priority of a mod. + // + void modPrioritiesChanged(const QModelIndexList& indices) const; + + // emitted when the state (active/inactive) of one or multiple mods have changed + // + void modStatesChanged(const QModelIndexList& indices) const; + + /** + * @brief emitted when the model wants a text to be displayed by the UI + * + * @param message the message to display + **/ + void showMessage(const QString& message); + + /** + * @brief signals change to the count of headers + */ + void resizeHeaders(); + + /** + * @brief emitted to remove a file origin + * @param name name of the orign to remove + */ + void removeOrigin(const QString& name); + + /** + * @brief emitted after a mod has been renamed + * This signal MUST be used to fix the mod names in profiles (except the active one) + * and to invalidate/refresh other structures that may have become invalid with the + * rename + * + * @param oldName the old name of the mod + * @param newName new name of the mod + */ + void modRenamed(const QString& oldName, const QString& newName); + + /** + * @brief emitted after a mod has been uninstalled + * @param fileName filename of the mod being uninstalled + */ + void modUninstalled(const QString& fileName); + + /** + * @brief QML seems to handle overloaded signals poorly - create unique signal for + * tutorials + */ + void tutorialModlistUpdate(); + + /** + * @brief fileMoved emitted when a file is moved from one mod to another + * @param relativePath relative path of the file moved + * @param oldOriginName name of the origin that previously contained the file + * @param newOriginName name of the origin that now contains the file + */ + void fileMoved(const QString& relativePath, const QString& oldOriginName, + const QString& newOriginName); + + /** + * @brief emitted to have the overwrite folder cleared + */ + void clearOverwrite(); + + void aboutToChangeData(); + + void postDataChanged(); + + // emitted when an item is dropped from the download list, the row is from the + // download list + // + void downloadArchiveDropped(int row, int priority); + + // emitted when an external archive is dropped on the mod list + // + void externalArchiveDropped(const QUrl& url, int priority); + + // emitted when an external folder is dropped on the mod list + // + void externalFolderDropped(const QUrl& url, int priority); + +private: + // retrieve the display name of a mod or convert from a user-provided + // name to internal name + // + QString getDisplayName(ModInfo::Ptr info) const; + QString makeInternalName(ModInfo::Ptr info, QString name) const; + + QString getFlagText(ModInfo::EFlag flag, ModInfo::Ptr modInfo) const; + + QString getConflictFlagText(ModInfo::EConflictFlag flag, ModInfo::Ptr modInfo) const; + + QString getColumnToolTip(int column) const; + + bool renameMod(int index, const QString& newName); + + MOBase::IModList::ModStates state(unsigned int modIndex) const; + + // handle dropping of local URLs files + // + bool dropLocalFiles(const ModListDropInfo& dropInfo, int row, + const QModelIndex& parent); + + // return the priority of the mod for a drop event + // + int dropPriority(int row, const QModelIndex& parent) const; + +private: + struct TModInfo + { + TModInfo(unsigned int index, ModInfo::Ptr modInfo) + : modInfo(modInfo), nameOrder(index), priorityOrder(0), modIDOrder(0), + categoryOrder(0) + {} + ModInfo::Ptr modInfo; + unsigned int nameOrder; + unsigned int priorityOrder; + unsigned int modIDOrder; + unsigned int categoryOrder; + }; + + struct TModInfoChange + { + QString name; + QFlags state; + }; + +private: + OrganizerCore* m_Organizer; + Profile* m_Profile; + + NexusInterface* m_NexusInterface; + std::set m_RequestIDs; + + mutable bool m_Modified; + bool m_InNotifyChange; + bool m_DropOnMod = false; + + QFontMetrics m_FontMetrics; + + TModInfoChange m_ChangeInfo; + + SignalModInstalled m_ModInstalled; + SignalModMoved m_ModMoved; + SignalModRemoved m_ModRemoved; + SignalModStateChanged m_ModStateChanged; + + QElapsedTimer m_LastCheck; + + PluginContainer* m_PluginContainer; +}; + +#endif // MODLIST_H diff --git a/src/src/modlistbypriorityproxy.cpp b/src/src/modlistbypriorityproxy.cpp new file mode 100644 index 0000000..559378b --- /dev/null +++ b/src/src/modlistbypriorityproxy.cpp @@ -0,0 +1,416 @@ +#include "modlistbypriorityproxy.h" + +#include "log.h" +#include "modinfo.h" +#include "modlist.h" +#include "modlistdropinfo.h" +#include "organizercore.h" +#include "profile.h" + +ModListByPriorityProxy::ModListByPriorityProxy(Profile* profile, OrganizerCore& core, + QObject* parent) + : QAbstractProxyModel(parent), m_core(core), m_profile(profile) +{} + +ModListByPriorityProxy::~ModListByPriorityProxy() {} + +void ModListByPriorityProxy::setSourceModel(QAbstractItemModel* model) +{ + if (sourceModel()) { + disconnect(sourceModel(), nullptr, this, nullptr); + } + + QAbstractProxyModel::setSourceModel(model); + + if (sourceModel()) { + connect(sourceModel(), &QAbstractItemModel::layoutChanged, this, + &ModListByPriorityProxy::onModelLayoutChanged, Qt::UniqueConnection); + connect(sourceModel(), &QAbstractItemModel::rowsRemoved, this, + &ModListByPriorityProxy::onModelRowsRemoved, Qt::UniqueConnection); + connect(sourceModel(), &QAbstractItemModel::modelReset, this, + &ModListByPriorityProxy::onModelReset, Qt::UniqueConnection); + connect(sourceModel(), &QAbstractItemModel::dataChanged, this, + &ModListByPriorityProxy::onModelDataChanged, Qt::UniqueConnection); + + onModelReset(); + } +} + +void ModListByPriorityProxy::setProfile(Profile* profile) +{ + m_profile = profile; +} + +void ModListByPriorityProxy::setSortOrder(Qt::SortOrder order) +{ + if (m_sortOrder != order) { + m_sortOrder = order; + onModelLayoutChanged(); + } +} + +void ModListByPriorityProxy::buildMapping() +{ + m_IndexToItem.clear(); + for (unsigned int index = 0; index < ModInfo::getNumMods(); ++index) { + m_IndexToItem[index] = + std::make_unique(ModInfo::getByIndex(index), index); + } +} + +void ModListByPriorityProxy::buildTree() +{ + if (!sourceModel()) + return; + + // reset the root + m_Root = {}; + + // clear all children + for (auto& [index, item] : m_IndexToItem) { + item->children.clear(); + } + + TreeItem* root = &m_Root; + TreeItem* overwrite; + std::vector backups; + + auto fn = [&](const auto& p) { + auto& [priority, index] = p; + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + TreeItem* item = m_IndexToItem[index].get(); + + if (modInfo->isSeparator()) { + item->parent = &m_Root; + m_Root.children.push_back(item); + root = item; + } else if (modInfo->isOverwrite()) { + // do not push here, because the overwrite is usually not at the right position + item->parent = &m_Root; + overwrite = item; + } else if (modInfo->isBackup()) { + // do not push here, because backups are usually not at the right position + item->parent = &m_Root; + backups.push_back(item); + } else { + item->parent = root; + root->children.push_back(item); + } + }; + + auto& ibp = m_profile->getAllIndexesByPriority(); + if (m_sortOrder == Qt::AscendingOrder) { + std::for_each(ibp.begin(), ibp.end(), fn); + m_Root.children.insert(m_Root.children.begin(), + std::make_move_iterator(backups.begin()), + std::make_move_iterator(backups.end())); + m_Root.children.push_back(std::move(overwrite)); + } else { + std::for_each(ibp.rbegin(), ibp.rend(), fn); + m_Root.children.insert(m_Root.children.begin(), std::move(overwrite)); + m_Root.children.insert(m_Root.children.end(), + std::make_move_iterator(backups.begin()), + std::make_move_iterator(backups.end())); + } +} + +void ModListByPriorityProxy::onModelRowsRemoved(const QModelIndex& parent, int first, + int last) +{ + onModelReset(); +} + +void ModListByPriorityProxy::onModelLayoutChanged(const QList&, + LayoutChangeHint hint) +{ + emit layoutAboutToBeChanged(); + auto persistent = persistentIndexList(); + buildTree(); + + QModelIndexList toPersistent; + for (auto& idx : persistent) { + // we can still access the TreeItem* because we did not destroy them + auto* item = static_cast(idx.internalPointer()); + toPersistent.append( + createIndex(item->parent->childIndex(item), idx.column(), item)); + } + changePersistentIndexList(persistent, toPersistent); + + emit layoutChanged({}, hint); +} + +void ModListByPriorityProxy::onModelReset() +{ + beginResetModel(); + buildMapping(); + buildTree(); + endResetModel(); +} + +void ModListByPriorityProxy::onModelDataChanged(const QModelIndex& topLeft, + const QModelIndex& bottomRight, + const QVector& roles) +{ + QModelIndex proxyTopLeft = mapFromSource(topLeft); + if (!proxyTopLeft.isValid()) { + return; + } + + if (topLeft == bottomRight) { + emit dataChanged(proxyTopLeft, proxyTopLeft); + } else { + QModelIndex proxyBottomRight = mapFromSource(bottomRight); + emit dataChanged(proxyTopLeft, proxyBottomRight); + } +} + +QModelIndex ModListByPriorityProxy::mapFromSource(const QModelIndex& sourceIndex) const +{ + if (!sourceIndex.isValid()) { + return QModelIndex(); + } + + auto* item = m_IndexToItem.at(sourceIndex.row()).get(); + return createIndex(item->parent->childIndex(item), sourceIndex.column(), item); +} + +QModelIndex ModListByPriorityProxy::mapToSource(const QModelIndex& proxyIndex) const +{ + if (!proxyIndex.isValid()) { + return QModelIndex(); + } + auto* item = static_cast(proxyIndex.internalPointer()); + + return sourceModel()->index(item->index, proxyIndex.column()); +} + +int ModListByPriorityProxy::rowCount(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return m_Root.children.size(); + } + + auto* item = static_cast(parent.internalPointer()); + + if (item->mod->isSeparator()) { + return item->children.size(); + } + + return 0; +} + +int ModListByPriorityProxy::columnCount(const QModelIndex& index) const +{ + return sourceModel()->columnCount(mapToSource(index)); +} + +QModelIndex ModListByPriorityProxy::parent(const QModelIndex& child) const +{ + if (!child.isValid()) { + return QModelIndex(); + } + + auto* item = static_cast(child.internalPointer()); + + if (!item->parent || item->parent == &m_Root) { + return QModelIndex(); + } + + return createIndex(item->parent->parent->childIndex(item->parent), 0, item->parent); +} + +bool ModListByPriorityProxy::hasChildren(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return m_Root.children.size() > 0; + } + auto* item = static_cast(parent.internalPointer()); + return item->children.size() > 0; +} + +bool ModListByPriorityProxy::canDropMimeData(const QMimeData* data, + Qt::DropAction action, int row, int column, + const QModelIndex& parent) const +{ + ModListDropInfo dropInfo(data, m_core); + + if (!dropInfo.isValid() || dropInfo.isLocalFileDrop()) { + return QAbstractProxyModel::canDropMimeData(data, action, row, column, parent); + } + + if (dropInfo.isModDrop()) { + bool hasSeparator = false; + unsigned int firstRowIndex = -1; + + int firstRowPriority = Profile::MaximumPriority; + for (auto sourceRow : dropInfo.rows()) { + hasSeparator = hasSeparator || ModInfo::getByIndex(sourceRow)->isSeparator(); + if (m_sortOrder == Qt::AscendingOrder && + m_profile->getModPriority(sourceRow) < firstRowPriority || + m_sortOrder == Qt::DescendingOrder && + m_profile->getModPriority(sourceRow) > firstRowPriority) { + firstRowIndex = sourceRow; + firstRowPriority = m_profile->getModPriority(sourceRow); + } + } + + bool firstRowSeparator = + firstRowIndex != -1 && ModInfo::getByIndex(firstRowIndex)->isSeparator(); + + // row = -1 and valid parent means we're dropping onto an item, we don't want to + // drop separators onto items or items into their own separator + if (row == -1 && parent.isValid()) { + auto* parentItem = static_cast(parent.internalPointer()); + if (hasSeparator) { + return !parentItem->mod->isSeparator(); + } + + for (auto row : dropInfo.rows()) { + auto it = m_IndexToItem.find(row); + if (it != m_IndexToItem.end() && it->second->parent == parentItem) { + return false; + } + } + } + + // first row is a separator, we can drop it anywhere + if (firstRowSeparator) { + return QAbstractProxyModel::canDropMimeData(data, action, row, column, parent); + } + } + + // the row may be outside of the children list if we insert at the end + if (!parent.isValid() && row >= m_Root.children.size()) { + return false; + } + + return QAbstractProxyModel::canDropMimeData(data, action, row, column, parent); +} + +bool ModListByPriorityProxy::dropMimeData(const QMimeData* data, Qt::DropAction action, + int row, int column, + const QModelIndex& parent) +{ + // we need to fix the source model row if we are dropping at a + // given priority (not a local file) + const ModListDropInfo dropInfo(data, m_core); + int sourceRow = -1; + + if (dropInfo.isLocalFileDrop()) { + if (parent.isValid()) { + sourceRow = static_cast(parent.internalPointer())->index; + } + } else { + + if (row >= 0) { + if (!parent.isValid()) { + if (row < m_Root.children.size()) { + + if (m_sortOrder == Qt::AscendingOrder) { + sourceRow = m_Root.children[row]->index; + + // fix bug when dropping a mod just below an expanded separator + // + // by default, Qt consider it's a drop at the end of that separator + // but we want to make it a drop at the beginning + if (row > 0 && m_sortOrder == Qt::AscendingOrder && + m_Root.children[row - 1]->mod->isSeparator() && + !m_Root.children[row - 1]->children.empty() && m_dropExpanded && + m_dropPosition == ModListView::DropPosition::BelowItem) { + sourceRow = m_Root.children[row - 1]->children[0]->index; + } + } else { + sourceRow = (row == 0) ? m_Root.children[0]->index + : m_Root.children[row - 1]->index; + + // fix drop below a collapsed separator or at the end of an expanded + // separator, above the next item + if (row > 0 && m_sortOrder == Qt::DescendingOrder && + m_Root.children[row - 1]->mod->isSeparator() && + !m_Root.children[row - 1]->children.empty() && + (!m_dropExpanded || + m_dropPosition == ModListView::DropPosition::AboveItem)) { + sourceRow = m_Root.children[row - 1]->children.back()->index; + } + } + } else { + sourceRow = ModInfo::getNumMods(); + } + } + + // the parent is valid, we are dropping in a separator + else { + auto* item = static_cast(parent.internalPointer()); + + // we usually need to decrement the row in descending priority, but if + // it's the first row, we need to go back to the separator itself + if (m_sortOrder == Qt::DescendingOrder && row == 0 && + m_dropPosition == ModListView::DropPosition::AboveItem) { + sourceRow = item->index; + } else { + + // in descending priority, we decrement the row to fix the drop position + // because this is not done by the sort proxy for us + if (m_sortOrder == Qt::DescendingOrder) { + row--; + } + + if (row < item->children.size()) { + sourceRow = item->children[row]->index; + } else if (parent.row() + 1 < m_Root.children.size()) { + sourceRow = m_Root.children[parent.row() + 1]->index; + } + } + } + } + + // row < 0 and valid parent means we are dropping into an item, + // which can only be a separator since dropping into non-separators + // is disabled + // + // we want to drop at the end of the separator, so we need to find + // the right priority + else if (parent.isValid()) { + + // in ascending priority, we take the priority of the next top-level + // item, which can be a separator or the overwrite mod, but is guaranteed + // to exist + if (m_sortOrder == Qt::AscendingOrder) { + sourceRow = m_Root.children[parent.row() + 1]->index; + } + + // in descending priority, we take the separator itself if it's empty or + // its last children + else { + auto* item = m_Root.children[parent.row()]; + sourceRow = item->children.empty() ? item->index : item->children.back()->index; + } + } + } + + return sourceModel()->dropMimeData(data, action, sourceRow, column, QModelIndex()); +} + +QModelIndex ModListByPriorityProxy::index(int row, int column, + const QModelIndex& parent) const +{ + if (!hasIndex(row, column, parent)) { + return QModelIndex(); + } + + const TreeItem* parentItem; + if (!parent.isValid()) { + parentItem = &m_Root; + } else { + parentItem = static_cast(parent.internalPointer()); + } + + return createIndex(row, column, parentItem->children[row]); +} + +void ModListByPriorityProxy::onDropEnter(const QMimeData*, bool dropExpanded, + ModListView::DropPosition dropPosition) +{ + m_dropExpanded = dropExpanded; + m_dropPosition = dropPosition; +} diff --git a/src/src/modlistbypriorityproxy.h b/src/src/modlistbypriorityproxy.h new file mode 100644 index 0000000..e861ba9 --- /dev/null +++ b/src/src/modlistbypriorityproxy.h @@ -0,0 +1,116 @@ +#ifndef MODLISBYPRIORITYPROXY_H +#define MODLISBYPRIORITYPROXY_H + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "modinfo.h" +#include "modlistview.h" + +class ModList; +class ModListDropInfo; +class Profile; + +class ModListByPriorityProxy : public QAbstractProxyModel +{ + Q_OBJECT + +public: + explicit ModListByPriorityProxy(Profile* profile, OrganizerCore& core, + QObject* parent = nullptr); + ~ModListByPriorityProxy(); + + void setProfile(Profile* profile); + + // set the sort order but does not refresh the proxy + // + void setSortOrder(Qt::SortOrder order); + + void setSourceModel(QAbstractItemModel* sourceModel) override; + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QModelIndex parent(const QModelIndex& child) const override; + QModelIndex index(int row, int column, + const QModelIndex& parent = QModelIndex()) const override; + int columnCount(const QModelIndex& index) const override; + bool hasChildren(const QModelIndex& parent) const override; + + bool canDropMimeData(const QMimeData* data, Qt::DropAction action, int row, + int column, const QModelIndex& parent) const override; + bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column, + const QModelIndex& parent) override; + + QModelIndex mapFromSource(const QModelIndex& sourceIndex) const override; + QModelIndex mapToSource(const QModelIndex& proxyIndex) const override; + +public slots: + + void onDropEnter(const QMimeData* data, bool dropExpanded, + ModListView::DropPosition dropPosition); + +protected slots: + + void onModelRowsRemoved(const QModelIndex& parent, int first, int last); + void + onModelLayoutChanged(const QList& parents = {}, + LayoutChangeHint hint = LayoutChangeHint::NoLayoutChangeHint); + void onModelReset(); + void onModelDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight, + const QVector& roles = QVector()); + +private: + // fill the mapping from index to item (required by buildTree), should + // only be used for full model reset because it destroys existing references + // to tree items + // + void buildMapping(); + + // create the actual tree by creating parent/child associations, requires + // the mapping to be created (by a previous buildMapping call) + // + void buildTree(); + + struct TreeItem + { + ModInfo::Ptr mod; + unsigned int index; + std::vector children; + TreeItem* parent; + + std::size_t childIndex(TreeItem* child) const + { + for (std::size_t i = 0; i < children.size(); ++i) { + if (children[i] == child) { + return i; + } + } + return -1; + } + + TreeItem() : TreeItem(nullptr, -1) {} + TreeItem(ModInfo::Ptr mod, unsigned int index, TreeItem* parent = nullptr) + : mod(mod), index(index), parent(parent) + {} + }; + + TreeItem m_Root; + std::map> m_IndexToItem; + +private: + OrganizerCore& m_core; + Profile* m_profile; + + Qt::SortOrder m_sortOrder = Qt::AscendingOrder; + bool m_dropExpanded = false; + ModListView::DropPosition m_dropPosition = ModListView::DropPosition::OnItem; +}; + +#endif // GROUPINGPROXY_H diff --git a/src/src/modlistcontextmenu.cpp b/src/src/modlistcontextmenu.cpp new file mode 100644 index 0000000..d32d6cd --- /dev/null +++ b/src/src/modlistcontextmenu.cpp @@ -0,0 +1,637 @@ +#include "modlistcontextmenu.h" + +#include + +#include "modlist.h" +#include "modlistview.h" +#include "modlistviewactions.h" +#include "organizercore.h" + +using namespace MOBase; + +ModListGlobalContextMenu::ModListGlobalContextMenu(OrganizerCore& core, + ModListView* view, QWidget* parent) + : ModListGlobalContextMenu(core, view, QModelIndex(), parent) +{} + +ModListGlobalContextMenu::ModListGlobalContextMenu(OrganizerCore& core, + ModListView* view, + const QModelIndex& index, + QWidget* parent) + : QMenu(parent) +{ + connect(this, &QMenu::aboutToShow, [=, &core] { + populate(core, view, index); + }); +} + +void ModListGlobalContextMenu::populate(OrganizerCore& core, ModListView* view, + const QModelIndex& index) +{ + clear(); + + auto modIndex = index.data(ModList::IndexRole); + if (modIndex.isValid() && view->sortColumn() == ModList::COL_PRIORITY) { + auto info = ModInfo::getByIndex(modIndex.toInt()); + if (!info->isBackup()) { + + // the mod are not created/installed at the same position depending + // on the clicked mod and the sort order + QString installText = tr("Install mod above... "); + QString createText = tr("Create empty mod above"); + if (info->isSeparator()) { + installText = tr("Install mod inside... "); + createText = tr("Create empty mod inside"); + } else if (view->sortOrder() == Qt::DescendingOrder) { + installText = tr("Install mod below... "); + createText = tr("Create empty mod below"); + } + + addAction(installText, [=]() { + view->actions().installMod("", index); + }); + addAction(createText, [=]() { + view->actions().createEmptyMod(index); + }); + addAction(tr("Create separator above"), [=]() { + view->actions().createSeparator(index); + }); + } + } else { + addAction(tr("Install mod..."), [=]() { + view->actions().installMod(); + }); + addAction(tr("Create empty mod"), [=]() { + view->actions().createEmptyMod(); + }); + addAction(tr("Create separator"), [=]() { + view->actions().createSeparator(); + }); + } + + if (view->hasCollapsibleSeparators()) { + addSeparator(); + addAction(tr("Collapse all"), view, &QTreeView::collapseAll); + addAction(tr("Expand all"), view, &QTreeView::expandAll); + } + + addSeparator(); + + QString enableTxt = tr("Enable all"), disableTxt = tr("Disable all"); + + if (view->isFilterActive()) { + enableTxt = tr("Enable all matching mods"); + disableTxt = tr("Disable all matching mods"); + } + + addAction(enableTxt, [=] { + view->actions().setAllMatchingModsEnabled(true); + }); + addAction(disableTxt, [=] { + view->actions().setAllMatchingModsEnabled(false); + }); + + addAction(tr("Check for updates"), [=]() { + view->actions().checkModsForUpdates(); + }); + addAction(tr("Auto assign categories"), [=]() { + view->actions().assignCategories(); + }); + addAction(tr("Refresh"), &core, &OrganizerCore::refresh); + addAction(tr("Export to csv..."), [=]() { + view->actions().exportModListCSV(); + }); +} + +ModListChangeCategoryMenu::ModListChangeCategoryMenu(CategoryFactory* categories, + ModInfo::Ptr mod, QMenu* parent) + : QMenu(tr("Change Categories"), parent) +{ + populate(this, categories, mod); +} + +std::vector> ModListChangeCategoryMenu::categories() const +{ + return categories(this); +} + +std::vector> +ModListChangeCategoryMenu::categories(const QMenu* menu) const +{ + std::vector> cats; + for (QAction* action : menu->actions()) { + if (action->menu() != nullptr) { + auto pcats = categories(action->menu()); + cats.insert(cats.end(), pcats.begin(), pcats.end()); + } else { + QWidgetAction* widgetAction = qobject_cast(action); + if (widgetAction != nullptr) { + QCheckBox* checkbox = qobject_cast(widgetAction->defaultWidget()); + cats.emplace_back(widgetAction->data().toInt(), checkbox->isChecked()); + } + } + } + return cats; +} + +bool ModListChangeCategoryMenu::populate(QMenu* menu, CategoryFactory* factory, + ModInfo::Ptr mod, int targetId) +{ + const std::set& categories = mod->getCategories(); + + bool childEnabled = false; + + for (unsigned int i = 1; i < factory->numCategories(); ++i) { + if (factory->getParentID(i) == targetId) { + QMenu* targetMenu = menu; + if (factory->hasChildren(i)) { + targetMenu = menu->addMenu(factory->getCategoryName(i).replace('&', "&&")); + } + + int id = factory->getCategoryID(i); + QScopedPointer checkBox(new QCheckBox(targetMenu)); + bool enabled = categories.find(id) != categories.end(); + checkBox->setText(factory->getCategoryName(i).replace('&', "&&")); + if (enabled) { + childEnabled = true; + } + checkBox->setChecked(enabled ? Qt::Checked : Qt::Unchecked); + + QScopedPointer checkableAction(new QWidgetAction(targetMenu)); + checkableAction->setDefaultWidget(checkBox.take()); + checkableAction->setData(id); + targetMenu->addAction(checkableAction.take()); + + if (factory->hasChildren(i)) { + if (populate(targetMenu, factory, mod, factory->getCategoryID(i)) || enabled) { + targetMenu->setIcon(QIcon(":/MO/gui/resources/check.png")); + } + } + } + } + return childEnabled; +} + +ModListPrimaryCategoryMenu::ModListPrimaryCategoryMenu(CategoryFactory* categories, + ModInfo::Ptr mod, QMenu* parent) + : QMenu(tr("Primary Category"), parent) +{ + connect(this, &QMenu::aboutToShow, [=]() { + populate(categories, mod); + }); +} + +void ModListPrimaryCategoryMenu::populate(const CategoryFactory* factory, + ModInfo::Ptr mod) +{ + clear(); + const std::set& categories = mod->getCategories(); + for (int categoryID : categories) { + int catIdx = factory->getCategoryIndex(categoryID); + QWidgetAction* action = new QWidgetAction(this); + try { + QRadioButton* categoryBox = + new QRadioButton(factory->getCategoryName(catIdx).replace('&', "&&"), this); + categoryBox->setChecked(categoryID == mod->primaryCategory()); + action->setDefaultWidget(categoryBox); + action->setData(categoryID); + } catch (const std::exception& e) { + log::error("failed to create category checkbox: {}", e.what()); + } + + action->setData(categoryID); + addAction(action); + } +} + +int ModListPrimaryCategoryMenu::primaryCategory() const +{ + for (QAction* action : actions()) { + QWidgetAction* widgetAction = qobject_cast(action); + if (widgetAction) { + QRadioButton* button = qobject_cast(widgetAction->defaultWidget()); + if (button && button->isChecked()) { + return widgetAction->data().toInt(); + } + } + } + return -1; +} + +ModListContextMenu::ModListContextMenu(const QModelIndex& index, OrganizerCore& core, + CategoryFactory* categories, ModListView* view) + : QMenu(view), m_core(core), m_categories(categories), + m_index(index.model() == view->model() ? view->indexViewToModel(index) : index), + m_view(view), m_actions(view->actions()) +{ + if (view->selectionModel()->hasSelection()) { + m_selected = view->indexViewToModel(view->selectionModel()->selectedRows()); + } else { + m_selected = {index}; + } + + ModInfo::Ptr info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + + QMenu* allMods = + new ModListGlobalContextMenu(core, view, m_index, view->topLevelWidget()); + allMods->setTitle(tr("All Mods")); + addMenu(allMods); + + auto viewIndex = view->indexModelToView(m_index); + if (view->model()->hasChildren(viewIndex)) { + bool expanded = view->isExpanded(viewIndex); + addSeparator(); + addAction(tr("Collapse all"), view, &QTreeView::collapseAll); + addAction(tr("Collapse others"), [=]() { + m_view->collapseAll(); + m_view->setExpanded(viewIndex, expanded); + }); + addAction(tr("Expand all"), view, &QTreeView::expandAll); + } + + addSeparator(); + + // Add type-specific items + if (info->isOverwrite()) { + addOverwriteActions(info); + } else if (info->isBackup()) { + addBackupActions(info); + } else if (info->isSeparator()) { + addSeparatorActions(info); + } else if (info->isForeign()) { + addForeignActions(info); + } else { + addRegularActions(info); + } + + // add information for all except foreign + if (!info->isForeign()) { + QAction* infoAction = addAction(tr("Information..."), [=]() { + view->actions().displayModInformation(m_index.data(ModList::IndexRole).toInt()); + }); + setDefaultAction(infoAction); + } +} + +void ModListContextMenu::addMenuAsPushButton(QMenu* menu) +{ + QPushButton* pushBtn = new QPushButton(menu->title()); + pushBtn->setMenu(menu); + QWidgetAction* action = new QWidgetAction(this); + action->setDefaultWidget(pushBtn); + addAction(action); +} + +void ModListContextMenu::addSendToContextMenu() +{ + static const std::vector overwritten_flags{ + ModInfo::EConflictFlag::FLAG_CONFLICT_MIXED, + ModInfo::EConflictFlag::FLAG_CONFLICT_OVERWRITTEN, + ModInfo::EConflictFlag::FLAG_CONFLICT_REDUNDANT}; + + static const std::vector overwrite_flags{ + ModInfo::EConflictFlag::FLAG_CONFLICT_MIXED, + ModInfo::EConflictFlag::FLAG_CONFLICT_OVERWRITE}; + + bool overwrite = false, overwritten = false; + for (auto& idx : m_selected) { + auto index = idx.data(ModList::IndexRole); + if (index.isValid()) { + auto info = ModInfo::getByIndex(index.toInt()); + auto flags = info->getConflictFlags(); + if (std::find_first_of(flags.begin(), flags.end(), overwritten_flags.begin(), + overwritten_flags.end()) != flags.end()) { + overwritten = true; + } + if (std::find_first_of(flags.begin(), flags.end(), overwrite_flags.begin(), + overwrite_flags.end()) != flags.end()) { + overwrite = true; + } + } + } + + QMenu* menu = new QMenu(m_view); + menu->setTitle(tr("Send to... ")); + menu->addAction(tr("Lowest priority"), [this] { + m_actions.sendModsToTop(m_selected); + }); + menu->addAction(tr("Highest priority"), [this] { + m_actions.sendModsToBottom(m_selected); + }); + menu->addAction(tr("Priority..."), [this] { + m_actions.sendModsToPriority(m_selected); + }); + menu->addAction(tr("Separator..."), [this] { + m_actions.sendModsToSeparator(m_selected); + }); + if (overwrite) { + menu->addAction(tr("First conflict"), [this] { + m_actions.sendModsToFirstConflict(m_selected); + }); + } + if (overwritten) { + menu->addAction(tr("Last conflict"), [this] { + m_actions.sendModsToLastConflict(m_selected); + }); + } + addMenu(menu); +} + +void ModListContextMenu::addCategoryContextMenus(ModInfo::Ptr mod) +{ + ModListChangeCategoryMenu* categoriesMenu = + new ModListChangeCategoryMenu(m_categories, mod, this); + connect(categoriesMenu, &QMenu::aboutToHide, [=]() { + m_actions.setCategories(m_selected, m_index, categoriesMenu->categories()); + }); + addMenuAsPushButton(categoriesMenu); + + ModListPrimaryCategoryMenu* primaryCategoryMenu = + new ModListPrimaryCategoryMenu(m_categories, mod, this); + connect(primaryCategoryMenu, &QMenu::aboutToHide, [=]() { + int category = primaryCategoryMenu->primaryCategory(); + if (category != -1) { + m_actions.setPrimaryCategory(m_selected, category); + } + }); + addMenuAsPushButton(primaryCategoryMenu); +} + +void ModListContextMenu::addOverwriteActions(ModInfo::Ptr mod) +{ + if (QDir(mod->absolutePath()).count() > 2) { + addAction(tr("Sync to Mods..."), [=]() { + m_core.syncOverwrite(); + }); + addAction(tr("Create Mod..."), [=]() { + m_actions.createModFromOverwrite(); + }); + addAction(tr("Move content to Mod..."), [=]() { + m_actions.moveOverwriteContentToExistingMod(); + }); + addAction(tr("Clear Overwrite..."), [=]() { + m_actions.clearOverwrite(); + }); + } + addAction(tr("Open in Explorer"), [=]() { + m_actions.openExplorer(m_selected); + }); +} + +void ModListContextMenu::addSeparatorActions(ModInfo::Ptr mod) +{ + addCategoryContextMenus(mod); + addSeparator(); + + addAction(tr("Rename Separator..."), [=]() { + m_actions.renameMod(m_index); + }); + addAction(tr("Remove Separator..."), [=]() { + m_actions.removeMods(m_selected); + }); + addSeparator(); + + if (m_view->sortColumn() == ModList::COL_PRIORITY) { + addSendToContextMenu(); + addSeparator(); + } + addAction(tr("Select Color..."), [=]() { + m_actions.setColor(m_selected, m_index); + }); + + if (mod->color().isValid()) { + addAction(tr("Reset Color"), [=]() { + m_actions.resetColor(m_selected); + }); + } + + addSeparator(); +} + +void ModListContextMenu::addForeignActions(ModInfo::Ptr mod) +{ + if (m_view->sortColumn() == ModList::COL_PRIORITY) { + addSendToContextMenu(); + } +} + +void ModListContextMenu::addBackupActions(ModInfo::Ptr mod) +{ + auto flags = mod->getFlags(); + addAction(tr("Restore Backup"), [=]() { + m_actions.restoreBackup(m_index); + }); + addAction(tr("Remove Backup..."), [=]() { + m_actions.removeMods(m_selected); + }); + addSeparator(); + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_INVALID) != flags.end()) { + addAction(tr("Ignore missing data"), [=]() { + m_actions.ignoreMissingData(m_selected); + }); + } + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_ALTERNATE_GAME) != + flags.end()) { + addAction(tr("Mark as converted/working"), [=]() { + m_actions.markConverted(m_selected); + }); + } + addSeparator(); + if (mod->nexusId() > 0) { + addAction(tr("Visit on Nexus"), [=]() { + m_actions.visitOnNexus(m_selected); + }); + } + + if (!mod->uploaderUrl().isEmpty()) { + addAction(tr("Visit the uploader's profile"), [=]() { + m_actions.visitUploaderProfile(m_selected); + }); + } + + const auto url = mod->parseCustomURL(); + if (url.isValid()) { + addAction(tr("Visit on %1").arg(url.host()), [=]() { + m_actions.visitWebPage(m_selected); + }); + } + + addAction(tr("Open in Explorer"), [=]() { + m_actions.openExplorer(m_selected); + }); +} + +void ModListContextMenu::addRegularActions(ModInfo::Ptr mod) +{ + auto flags = mod->getFlags(); + + addCategoryContextMenus(mod); + addSeparator(); + + if (mod->downgradeAvailable()) { + addAction(tr("Change versioning scheme"), [=]() { + m_actions.changeVersioningScheme(m_index); + }); + } + + if (mod->nexusId() > 0) + addAction(tr("Force-check updates"), [=]() { + m_actions.checkModsForUpdates(m_selected); + }); + if (mod->updateIgnored()) { + addAction(tr("Un-ignore update"), [=]() { + m_actions.setIgnoreUpdate(m_selected, false); + }); + } else { + if (mod->updateAvailable() || mod->downgradeAvailable()) { + addAction(tr("Ignore update"), [=]() { + m_actions.setIgnoreUpdate(m_selected, true); + }); + } + } + addSeparator(); + + addAction(tr("Enable selected"), [=]() { + m_core.modList()->setActive(m_selected, true); + }); + addAction(tr("Disable selected"), [=]() { + m_core.modList()->setActive(m_selected, false); + }); + + addSeparator(); + + if (m_view->sortColumn() == ModList::COL_PRIORITY) { + addSendToContextMenu(); + addSeparator(); + } + + addAction(tr("Rename Mod..."), [=]() { + m_actions.renameMod(m_index); + }); + addAction(tr("Reinstall Mod"), [=]() { + m_actions.reinstallMod(m_index); + }); + addAction(tr("Remove Mod..."), [=]() { + m_actions.removeMods(m_selected); + }); + addAction(tr("Create Backup"), [=]() { + m_actions.createBackup(m_index); + }); + + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_HIDDEN_FILES) != + flags.end()) { + addAction(tr("Restore hidden files"), [=]() { + m_actions.restoreHiddenFiles(m_selected); + }); + } + + addSeparator(); + + if (m_index.column() == ModList::COL_NOTES) { + addAction(tr("Select Color..."), [=]() { + m_actions.setColor(m_selected, m_index); + }); + if (mod->color().isValid()) { + addAction(tr("Reset Color"), [=]() { + m_actions.resetColor(m_selected); + }); + } + addSeparator(); + } + + if (mod->nexusId() > 0 && Settings::instance().nexus().endorsementIntegration()) { + switch (mod->endorsedState()) { + case EndorsedState::ENDORSED_TRUE: { + addAction(tr("Un-Endorse"), [=]() { + m_actions.setEndorsed(m_selected, false); + }); + } break; + case EndorsedState::ENDORSED_FALSE: { + addAction(tr("Endorse"), [=]() { + m_actions.setEndorsed(m_selected, true); + }); + addAction(tr("Won't endorse"), [=]() { + m_actions.willNotEndorsed(m_selected); + }); + } break; + case EndorsedState::ENDORSED_NEVER: { + addAction(tr("Endorse"), [=]() { + m_actions.setEndorsed(m_selected, true); + }); + } break; + default: { + QAction* action = new QAction(tr("Endorsement state unknown"), this); + action->setEnabled(false); + addAction(action); + } break; + } + } + + if (mod->nexusId() > 0 && + (mod->getNexusCategory() > 0 || !mod->installationFile().isEmpty()) && + !mod->isSeparator()) { + addAction(tr("Remap Category (From Nexus)"), [=]() { + m_actions.remapCategory(m_selected); + }); + } + + if (mod->nexusId() > 0 && Settings::instance().nexus().trackedIntegration()) { + switch (mod->trackedState()) { + case TrackedState::TRACKED_FALSE: { + addAction(tr("Start tracking"), [=]() { + m_actions.setTracked(m_selected, true); + }); + } break; + case TrackedState::TRACKED_TRUE: { + addAction(tr("Stop tracking"), [=]() { + m_actions.setTracked(m_selected, false); + }); + } break; + default: { + QAction* action = new QAction(tr("Tracked state unknown"), this); + action->setEnabled(false); + addAction(action); + } break; + } + } + + addSeparator(); + + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_INVALID) != flags.end()) { + addAction(tr("Ignore missing data"), [=]() { + m_actions.ignoreMissingData(m_selected); + }); + } + + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_ALTERNATE_GAME) != + flags.end()) { + addAction(tr("Mark as converted/working"), [=]() { + m_actions.markConverted(m_selected); + }); + } + + addSeparator(); + + if (mod->nexusId() > 0) { + addAction(tr("Visit on Nexus"), [=]() { + m_actions.visitOnNexus(m_selected); + }); + } + + if (!mod->uploaderUrl().isEmpty()) { + addAction(tr("Visit the uploader's profile"), [=]() { + m_actions.visitUploaderProfile(m_selected); + }); + } + + const auto url = mod->parseCustomURL(); + if (url.isValid()) { + addAction(tr("Visit on %1").arg(url.host()), [=]() { + m_actions.visitWebPage(m_selected); + }); + } + + addAction(tr("Open in Explorer"), [=]() { + m_actions.openExplorer(m_selected); + }); +} diff --git a/src/src/modlistcontextmenu.h b/src/src/modlistcontextmenu.h new file mode 100644 index 0000000..ff3cfe6 --- /dev/null +++ b/src/src/modlistcontextmenu.h @@ -0,0 +1,115 @@ +#ifndef MODLISTCONTEXTMENU_H +#define MODLISTCONTEXTMENU_H + +#include + +#include +#include +#include + +#include "modinfo.h" + +class CategoryFactory; +class ModListView; +class ModListViewActions; +class OrganizerCore; + +class ModListGlobalContextMenu : public QMenu +{ + Q_OBJECT +public: + ModListGlobalContextMenu(OrganizerCore& core, ModListView* view, + QWidget* parent = nullptr); + +protected: + friend class ModListContextMenu; + + // populate the menu + void populate(OrganizerCore& core, ModListView* view, const QModelIndex& index); + + // creates a "All mods" context menu for the given index (can be invalid). + ModListGlobalContextMenu(OrganizerCore& core, ModListView* view, + const QModelIndex& index, QWidget* parent = nullptr); +}; + +class ModListChangeCategoryMenu : public QMenu +{ + Q_OBJECT +public: + ModListChangeCategoryMenu(CategoryFactory* categories, ModInfo::Ptr mod, + QMenu* parent = nullptr); + + // return a list of pair from the menu + // + std::vector> categories() const; + +private: + // populate the tree with the category, using the enabled/disabled state from the + // given mod + // + bool populate(QMenu* menu, CategoryFactory* categories, ModInfo::Ptr mod, + int targetId = 0); + + // internal implementation of categories() for recursion + // + std::vector> categories(const QMenu* menu) const; +}; + +class ModListPrimaryCategoryMenu : public QMenu +{ + Q_OBJECT +public: + ModListPrimaryCategoryMenu(CategoryFactory* categories, ModInfo::Ptr mod, + QMenu* parent = nullptr); + + // return the selected primary category + // + int primaryCategory() const; + +private: + // populate the categories + // + void populate(const CategoryFactory* categories, ModInfo::Ptr mod); +}; + +class ModListContextMenu : public QMenu +{ + Q_OBJECT + +public: + // creates a new context menu, the given index is the one for the click and should be + // valid + // + ModListContextMenu(const QModelIndex& index, OrganizerCore& core, + CategoryFactory* categories, ModListView* modListView); + +private: + // adds the "Send to... " context menu + // + void addSendToContextMenu(); + + // adds the categories menu (change/primary) + // + void addCategoryContextMenus(ModInfo::Ptr mod); + + // special menu for categories + // + void addMenuAsPushButton(QMenu* menu); + + // add actions/menus to this menu for each type of mod + // + void addOverwriteActions(ModInfo::Ptr mod); + void addSeparatorActions(ModInfo::Ptr mod); + void addForeignActions(ModInfo::Ptr mod); + void addBackupActions(ModInfo::Ptr mod); + void addRegularActions(ModInfo::Ptr mod); + + OrganizerCore& m_core; + CategoryFactory* m_categories; + QModelIndex m_index; + QModelIndexList m_selected; + ModListView* m_view; + ModListViewActions& m_actions; // shortcut for m_view->actions() +}; + +#endif diff --git a/src/src/modlistdropinfo.cpp b/src/src/modlistdropinfo.cpp new file mode 100644 index 0000000..dff9bb7 --- /dev/null +++ b/src/src/modlistdropinfo.cpp @@ -0,0 +1,124 @@ +#include "modlistdropinfo.h" + +#include "organizercore.h" + +ModListDropInfo::ModListDropInfo(const QMimeData* mimeData, OrganizerCore& core) + : m_rows{}, m_download{-1}, m_localUrls{}, m_url{} +{ + // this only check if the drop is valid, not if the content of the drop + // matches the target, a drop is valid if either + // 1. it contains items from another model (drag&drop in modlist or from download + // list) + // 2. it contains URLs from MO2 (overwrite or from another mod) + // 3. it contains a single URL to an external folder + // 4. it contains a single URL to a valid archive for MO2 + try { + if (mimeData->hasUrls()) { + for (auto& url : mimeData->urls()) { + auto p = relativeUrl(url); + if (p) { + m_localUrls.push_back(*p); + } + } + + // external drop + if (m_localUrls.empty() && mimeData->urls().size() == 1) { + auto url = mimeData->urls()[0]; + if (url.isLocalFile() && !relativeUrl(url)) { + QFileInfo info(url.toLocalFile()); + if (info.isDir()) { + m_url = url; + } else if (core.installationManager()->getSupportedExtensions().contains( + info.suffix(), Qt::CaseInsensitive)) { + m_url = url; + } + } + } + + } else if (mimeData->hasText()) { + QByteArray encoded = mimeData->data("application/x-qabstractitemmodeldatalist"); + QDataStream stream(&encoded, QIODevice::ReadOnly); + + while (!stream.atEnd()) { + int sourceRow, col; + QMap roleDataMap; + stream >> sourceRow >> col >> roleDataMap; + if (col == 0 && sourceRow >= 0) { + m_rows.push_back(sourceRow); + } + } + + if (mimeData->text() != ModListDropInfo::ModText) { + if (mimeData->text() == ModListDropInfo::DownloadText && m_rows.size() == 1) { + m_download = m_rows[0]; + } + m_rows = {}; + } + } + } catch (std::exception const&) { + m_rows = {}; + m_download = -1; + m_localUrls.clear(); + m_url = QUrl(); + } +} + +std::optional +ModListDropInfo::relativeUrl(const QUrl& url) const +{ + if (!url.isLocalFile()) { + return {}; + } + + QDir allModsDir(Settings::instance().paths().mods()); + QDir overwriteDir(Settings::instance().paths().overwrite()); + + QFileInfo sourceInfo(url.toLocalFile()); + QString sourceFile = sourceInfo.canonicalFilePath(); + + QString relativePath; + QString originName; + + if (sourceFile.startsWith(allModsDir.canonicalPath())) { + QDir relativeDir(allModsDir.relativeFilePath(sourceFile)); + QStringList splitPath = relativeDir.path().split("/"); + originName = splitPath[0]; + splitPath.pop_front(); + return {{url, splitPath.join("/"), originName}}; + } else if (sourceFile.startsWith(overwriteDir.canonicalPath())) { + return {{url, overwriteDir.relativeFilePath(sourceFile), + ModInfo::getOverwrite()->name()}}; + } + + return {}; +} + +bool ModListDropInfo::isValid() const +{ + return isLocalFileDrop() || isModDrop() || isDownloadDrop() || m_url.isLocalFile(); +} + +bool ModListDropInfo::isLocalFileDrop() const +{ + return !m_localUrls.empty(); +} + +bool ModListDropInfo::isModDrop() const +{ + return !m_rows.empty(); +} + +bool ModListDropInfo::isDownloadDrop() const +{ + return m_download != -1; +} + +bool ModListDropInfo::isExternalArchiveDrop() const +{ + return m_url.isLocalFile() && QFileInfo(m_url.toLocalFile()).isFile(); +} + +bool ModListDropInfo::isExternalFolderDrop() const +{ + return m_url.isLocalFile() && QFileInfo(m_url.toLocalFile()).isDir(); +} diff --git a/src/src/modlistdropinfo.h b/src/src/modlistdropinfo.h new file mode 100644 index 0000000..279d29a --- /dev/null +++ b/src/src/modlistdropinfo.h @@ -0,0 +1,89 @@ +#ifndef MODLISTDROPINFO_H +#define MODLISTDROPINFO_H + +#include +#include + +#include +#include +#include + +class OrganizerCore; + +// small class that extract information from mimeData +// +class ModListDropInfo +{ +public: + // text value for the mime-data for the various possible + // origin (not for external drops) + // + static constexpr const char* ModText = "mod"; + static constexpr const char* DownloadText = "download"; + +public: + struct RelativeUrl + { + const QUrl url; + const QString relativePath; + const QString originName; + }; + +public: + ModListDropInfo(const QMimeData* mimeData, OrganizerCore& core); + + // returns true if this drop is valid + // + bool isValid() const; + + // returns true if these data corresponds to drag&drop + // of local files (e.g. from overwrite) + // + bool isLocalFileDrop() const; + + // returns true if these data corresponds to drag&drop + // of mod in the list + // + bool isModDrop() const; + + // returns true if these data corresponds to drag&drop + // from the download list + // + bool isDownloadDrop() const; + + // returns true if these data corresponds to dropping + // an archive for installation + // + bool isExternalArchiveDrop() const; + + // returns true if these data corresponds to dropping + // a folder for copy + // + bool isExternalFolderDrop() const; + + const auto& rows() const { return m_rows; } + const auto& download() const { return m_download; } + const auto& localUrls() const { return m_localUrls; } + const auto& externalUrl() const { return m_url; } + +private: + friend class ModList; + + // retrieve the relative path of file and its origin given a URL from Mime data + // returns an empty optional if the URL is not a valid file for dropping + // + std::optional relativeUrl(const QUrl&) const; + +private: + // rows for drag&drop between views + std::vector m_rows; + int m_download; // -1 if invalid + + // local URLs from the data (relative path + origin name) + std::vector m_localUrls; + + // external URL + QUrl m_url; +}; + +#endif diff --git a/src/src/modlistproxy.cpp b/src/src/modlistproxy.cpp new file mode 100644 index 0000000..44b9c1a --- /dev/null +++ b/src/src/modlistproxy.cpp @@ -0,0 +1,113 @@ +#include "modlistproxy.h" +#include "modlist.h" +#include "organizerproxy.h" +#include "proxyutils.h" + +using namespace MOBase; +using namespace MOShared; + +ModListProxy::ModListProxy(OrganizerProxy* oproxy, ModList* modlist) + : m_OrganizerProxy(oproxy), m_Proxied(modlist) +{} + +ModListProxy::~ModListProxy() +{ + disconnectSignals(); +} + +void ModListProxy::connectSignals() +{ + m_Connections.push_back(m_Proxied->onModInstalled( + callSignalIfPluginActive(m_OrganizerProxy, m_ModInstalled))); + m_Connections.push_back( + m_Proxied->onModMoved(callSignalIfPluginActive(m_OrganizerProxy, m_ModMoved))); + m_Connections.push_back(m_Proxied->onModRemoved( + callSignalIfPluginActive(m_OrganizerProxy, m_ModRemoved))); + m_Connections.push_back(m_Proxied->onModStateChanged( + callSignalIfPluginActive(m_OrganizerProxy, m_ModStateChanged))); +} + +void ModListProxy::disconnectSignals() +{ + for (auto& conn : m_Connections) { + conn.disconnect(); + } + m_Connections.clear(); +} + +QString ModListProxy::displayName(const QString& internalName) const +{ + return m_Proxied->displayName(internalName); +} + +QStringList ModListProxy::allMods() const +{ + return m_Proxied->allMods(); +} + +QStringList ModListProxy::allModsByProfilePriority(MOBase::IProfile* profile) const +{ + return m_Proxied->allModsByProfilePriority(profile); +} + +IModInterface* ModListProxy::getMod(const QString& name) const +{ + return m_Proxied->getMod(name); +} + +bool ModListProxy::removeMod(MOBase::IModInterface* mod) +{ + return m_Proxied->removeMod(mod); +} + +MOBase::IModInterface* ModListProxy::renameMod(MOBase::IModInterface* mod, + const QString& name) +{ + return m_Proxied->renameMod(mod, name); +} + +IModList::ModStates ModListProxy::state(const QString& name) const +{ + return m_Proxied->state(name); +} + +bool ModListProxy::setActive(const QString& name, bool active) +{ + return m_Proxied->setActive(name, active); +} + +int ModListProxy::setActive(const QStringList& names, bool active) +{ + return m_Proxied->setActive(names, active); +} + +int ModListProxy::priority(const QString& name) const +{ + return m_Proxied->priority(name); +} + +bool ModListProxy::setPriority(const QString& name, int newPriority) +{ + return m_Proxied->setPriority(name, newPriority); +} + +bool ModListProxy::onModInstalled(const std::function& func) +{ + return m_ModInstalled.connect(func).connected(); +} + +bool ModListProxy::onModRemoved(const std::function& func) +{ + return m_ModRemoved.connect(func).connected(); +} + +bool ModListProxy::onModStateChanged( + const std::function&)>& func) +{ + return m_ModStateChanged.connect(func).connected(); +} + +bool ModListProxy::onModMoved(const std::function& func) +{ + return m_ModMoved.connect(func).connected(); +} diff --git a/src/src/modlistproxy.h b/src/src/modlistproxy.h new file mode 100644 index 0000000..7fa5c70 --- /dev/null +++ b/src/src/modlistproxy.h @@ -0,0 +1,53 @@ +#ifndef MODLISTPROXY_H +#define MODLISTPROXY_H + +#include "modlist.h" +#include + +class OrganizerProxy; + +class ModListProxy : public MOBase::IModList +{ + +public: + ModListProxy(OrganizerProxy* oproxy, ModList* modlist); + virtual ~ModListProxy(); + + QString displayName(const QString& internalName) const override; + QStringList allMods() const override; + QStringList + allModsByProfilePriority(MOBase::IProfile* profile = nullptr) const override; + MOBase::IModInterface* getMod(const QString& name) const override; + bool removeMod(MOBase::IModInterface* mod) override; + MOBase::IModInterface* renameMod(MOBase::IModInterface* mod, + const QString& name) override; + ModStates state(const QString& name) const override; + bool setActive(const QString& name, bool active) override; + int setActive(const QStringList& names, bool active) override; + int priority(const QString& name) const override; + bool setPriority(const QString& name, int newPriority) override; + bool onModInstalled(const std::function& func) override; + bool onModRemoved(const std::function& func) override; + bool onModStateChanged( + const std::function&)>& func) override; + bool onModMoved(const std::function& func) override; + +private: + friend class OrganizerProxy; + + // See OrganizerProxy::connectSignals(). + void connectSignals(); + void disconnectSignals(); + + OrganizerProxy* m_OrganizerProxy; + ModList* m_Proxied; + + ModList::SignalModInstalled m_ModInstalled; + ModList::SignalModMoved m_ModMoved; + ModList::SignalModRemoved m_ModRemoved; + ModList::SignalModStateChanged m_ModStateChanged; + + std::vector m_Connections; +}; + +#endif // ORGANIZERPROXY_H diff --git a/src/src/modlistsortproxy.cpp b/src/src/modlistsortproxy.cpp new file mode 100644 index 0000000..4ffeec9 --- /dev/null +++ b/src/src/modlistsortproxy.cpp @@ -0,0 +1,716 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "modlistsortproxy.h" +#include "messagedialog.h" +#include "modinfo.h" +#include "modlistbypriorityproxy.h" +#include "modlistdropinfo.h" +#include "organizercore.h" +#include "profile.h" +#include "qtgroupingproxy.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; + +ModListSortProxy::ModListSortProxy(Profile* profile, OrganizerCore* organizer) + : QSortFilterProxyModel(organizer), m_Organizer(organizer), m_Profile(profile), + m_FilterActive(false), m_FilterMode(FilterAnd), + m_FilterSeparators(SeparatorFilter) +{ + setDynamicSortFilter(true); // this seems to work without dynamicsortfilter + // but I don't know why. This should be necessary +} + +void ModListSortProxy::setProfile(Profile* profile) +{ + m_Profile = profile; +} + +void ModListSortProxy::updateFilterActive() +{ + m_FilterActive = (!m_Criteria.empty() || !m_Filter.isEmpty()); + emit filterActive(m_FilterActive); +} + +void ModListSortProxy::setCriteria(const std::vector& criteria) +{ + // avoid refreshing the filter unless we are checking all mods for update. + const bool changed = (criteria != m_Criteria); + const bool isForUpdates = + (!criteria.empty() && criteria[0].id == CategoryFactory::UpdateAvailable); + + if (changed || isForUpdates) { + m_Criteria = criteria; + updateFilterActive(); + invalidateFilter(); + emit filterInvalidated(); + } +} + +unsigned long ModListSortProxy::flagsId(const std::vector& flags) const +{ + unsigned long result = 0; + for (ModInfo::EFlag flag : flags) { + if ((flag != ModInfo::FLAG_FOREIGN) && (flag != ModInfo::FLAG_OVERWRITE)) { + result += 1 << (int)flag; + } + } + return result; +} + +unsigned long ModListSortProxy::conflictFlagsId( + const std::vector& flags) const +{ + unsigned long result = 0; + for (ModInfo::EConflictFlag flag : flags) { + if ((flag != ModInfo::FLAG_OVERWRITE_CONFLICT)) { + result += 1 << (int)flag; + } + } + return result; +} + +bool ModListSortProxy::lessThan(const QModelIndex& left, const QModelIndex& right) const +{ + if (sourceModel()->hasChildren(left) || sourceModel()->hasChildren(right)) { + // when sorting by priority, we do not want to use the parent lessThan because + // it uses the display role which can be inconsistent (e.g. for backups) + if (sortColumn() != ModList::COL_PRIORITY) { + return QSortFilterProxyModel::lessThan(left, right); + } else if (qobject_cast(sourceModel())) { + // if the underlying proxy is a QtGroupingProxy we need to rely on + // Qt::DisplayRole because the other roles are not correctly handled + // by that kind of proxy + return left.data(Qt::DisplayRole).toInt() < right.data(Qt::DisplayRole).toInt(); + } + } + + bool lOk, rOk; + int leftIndex = left.data(ModList::IndexRole).toInt(&lOk); + int rightIndex = right.data(ModList::IndexRole).toInt(&rOk); + + if (!lOk || !rOk) { + return false; + } + + ModInfo::Ptr leftMod = ModInfo::getByIndex(leftIndex); + ModInfo::Ptr rightMod = ModInfo::getByIndex(rightIndex); + + bool lt = left.data(ModList::PriorityRole).toInt() < + right.data(ModList::PriorityRole).toInt(); + + switch (left.column()) { + case ModList::COL_FLAGS: { + std::vector leftFlags = leftMod->getFlags(); + std::vector rightFlags = rightMod->getFlags(); + if (leftFlags.size() != rightFlags.size()) { + lt = leftFlags.size() < rightFlags.size(); + } else { + lt = flagsId(leftFlags) < flagsId(rightFlags); + } + } break; + case ModList::COL_CONFLICTFLAGS: { + std::vector leftFlags = leftMod->getConflictFlags(); + std::vector rightFlags = rightMod->getConflictFlags(); + if (leftFlags.size() != rightFlags.size()) { + lt = leftFlags.size() < rightFlags.size(); + } else { + lt = conflictFlagsId(leftFlags) < conflictFlagsId(rightFlags); + } + } break; + case ModList::COL_CONTENT: { + const auto& lContents = leftMod->getContents(); + const auto& rContents = rightMod->getContents(); + unsigned int lValue = 0; + unsigned int rValue = 0; + m_Organizer->modDataContents().forEachContentIn( + lContents, [&lValue](auto const& content) { + lValue += 2U << static_cast(content.id()); + }); + m_Organizer->modDataContents().forEachContentIn( + rContents, [&rValue](auto const& content) { + rValue += 2U << static_cast(content.id()); + }); + lt = lValue < rValue; + } break; + case ModList::COL_NAME: { + int comp = QString::compare(leftMod->name(), rightMod->name(), Qt::CaseInsensitive); + if (comp != 0) + lt = comp < 0; + } break; + case ModList::COL_CATEGORY: { + if (leftMod->primaryCategory() != rightMod->primaryCategory()) { + if (leftMod->primaryCategory() < 0) + lt = false; + else if (rightMod->primaryCategory() < 0) + lt = true; + else { + try { + CategoryFactory& categories = CategoryFactory::instance(); + QString leftCatName = categories.getCategoryName( + categories.getCategoryIndex(leftMod->primaryCategory())); + QString rightCatName = categories.getCategoryName( + categories.getCategoryIndex(rightMod->primaryCategory())); + lt = leftCatName < rightCatName; + } catch (const std::exception& e) { + log::error("failed to compare categories: {}", e.what()); + } + } + } + } break; + case ModList::COL_AUTHOR: { + if (leftMod->author() != rightMod->author()) { + lt = leftMod->author() < rightMod->author(); + } else { + int comp = QString::compare(leftMod->uploader(), rightMod->uploader(), + Qt::CaseInsensitive); + if (comp != 0) + lt = comp < 0; + } + } break; + case ModList::COL_UPLOADER: { + if (leftMod->uploader() != rightMod->uploader()) { + lt = leftMod->uploader() < rightMod->uploader(); + } else { + int comp = QString::compare(leftMod->uploader(), rightMod->uploader(), + Qt::CaseInsensitive); + if (comp != 0) + lt = comp < 0; + } + } break; + case ModList::COL_MODID: { + if (leftMod->nexusId() != rightMod->nexusId()) + lt = leftMod->nexusId() < rightMod->nexusId(); + } break; + case ModList::COL_VERSION: { + if (leftMod->version() != rightMod->version()) + lt = leftMod->version() < rightMod->version(); + } break; + case ModList::COL_INSTALLTIME: { + QDateTime leftTime = left.data().toDateTime(); + QDateTime rightTime = right.data().toDateTime(); + if (leftTime != rightTime) + return leftTime < rightTime; + } break; + case ModList::COL_GAME: { + if (leftMod->gameName() != rightMod->gameName()) { + lt = leftMod->gameName() < rightMod->gameName(); + } else { + int comp = + QString::compare(leftMod->name(), rightMod->name(), Qt::CaseInsensitive); + if (comp != 0) + lt = comp < 0; + } + } break; + case ModList::COL_NOTES: { + QString leftComments = leftMod->comments(); + QString rightComments = rightMod->comments(); + if (leftComments != rightComments) { + if (leftComments.isEmpty()) { + lt = sortOrder() == Qt::DescendingOrder; + } else if (rightComments.isEmpty()) { + lt = sortOrder() == Qt::AscendingOrder; + } else { + lt = leftComments < rightComments; + } + } + } break; + case ModList::COL_PRIORITY: { + if (leftMod->isBackup() != rightMod->isBackup()) { + lt = leftMod->isBackup(); + } else if (leftMod->isOverwrite() != rightMod->isOverwrite()) { + lt = rightMod->isOverwrite(); + } + } break; + default: { + log::warn("Sorting is not defined for column {}", left.column()); + } break; + } + return lt; +} + +void ModListSortProxy::updateFilter(const QString& filter) +{ + m_Filter = filter; + updateFilterActive(); + invalidateFilter(); + emit filterInvalidated(); +} + +bool ModListSortProxy::hasConflictFlag( + const std::vector& flags) const +{ + for (ModInfo::EConflictFlag flag : flags) { + if ((flag == ModInfo::FLAG_CONFLICT_MIXED) || + (flag == ModInfo::FLAG_CONFLICT_OVERWRITE) || + (flag == ModInfo::FLAG_CONFLICT_OVERWRITTEN) || + (flag == ModInfo::FLAG_CONFLICT_REDUNDANT) || + (flag == ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITE) || + (flag == ModInfo::FLAG_ARCHIVE_CONFLICT_OVERWRITTEN) || + (flag == ModInfo::FLAG_ARCHIVE_CONFLICT_MIXED) || + (flag == ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITE) || + (flag == ModInfo::FLAG_ARCHIVE_LOOSE_CONFLICT_OVERWRITTEN)) { + return true; + } + } + + return false; +} + +bool ModListSortProxy::filterMatchesModAnd(ModInfo::Ptr info, bool enabled) const +{ + for (auto&& c : m_Criteria) { + if (!criteriaMatchMod(info, enabled, c)) { + return false; + } + } + + return true; +} + +bool ModListSortProxy::filterMatchesModOr(ModInfo::Ptr info, bool enabled) const +{ + for (auto&& c : m_Criteria) { + if (criteriaMatchMod(info, enabled, c)) { + return true; + } + } + + if (!m_Criteria.empty()) { + // nothing matched + return false; + } + + return true; +} + +bool ModListSortProxy::optionsMatchMod(ModInfo::Ptr info, bool) const +{ + return true; +} + +bool ModListSortProxy::criteriaMatchMod(ModInfo::Ptr info, bool enabled, + const Criteria& c) const +{ + bool b = false; + + switch (c.type) { + case TypeSpecial: // fall-through + case TypeCategory: { + b = categoryMatchesMod(info, enabled, c.id); + break; + } + + case TypeContent: { + b = contentMatchesMod(info, enabled, c.id); + break; + } + + default: { + log::error("bad criteria type {}", c.type); + break; + } + } + + if (c.inverse) { + b = !b; + } + + return b; +} + +bool ModListSortProxy::categoryMatchesMod(ModInfo::Ptr info, bool enabled, + int category) const +{ + bool b = false; + + switch (category) { + case CategoryFactory::Checked: { + b = (enabled || info->alwaysEnabled()); + break; + } + + case CategoryFactory::UpdateAvailable: { + b = (info->updateAvailable() || info->downgradeAvailable()); + break; + } + + case CategoryFactory::HasCategory: { + b = !info->getCategories().empty(); + break; + } + + case CategoryFactory::Conflict: { + b = (hasConflictFlag(info->getConflictFlags())); + break; + } + + case CategoryFactory::HasHiddenFiles: { + b = (info->hasFlag(ModInfo::FLAG_HIDDEN_FILES)); + break; + } + + case CategoryFactory::Endorsed: { + b = (info->endorsedState() == EndorsedState::ENDORSED_TRUE); + break; + } + + case CategoryFactory::Backup: { + b = (info->hasFlag(ModInfo::FLAG_BACKUP)); + break; + } + + case CategoryFactory::Managed: { + b = (!info->hasFlag(ModInfo::FLAG_FOREIGN)); + break; + } + + case CategoryFactory::HasGameData: { + b = !info->hasFlag(ModInfo::FLAG_INVALID); + break; + } + + case CategoryFactory::HasNexusID: { + // never show these + if (info->hasFlag(ModInfo::FLAG_FOREIGN) || info->hasFlag(ModInfo::FLAG_BACKUP) || + info->hasFlag(ModInfo::FLAG_OVERWRITE)) { + return false; + } + + b = (info->nexusId() > 0); + break; + } + + case CategoryFactory::Tracked: { + b = (info->trackedState() == TrackedState::TRACKED_TRUE); + break; + } + + default: { + b = (info->categorySet(category)); + break; + } + } + + return b; +} + +bool ModListSortProxy::contentMatchesMod(ModInfo::Ptr info, bool enabled, + int content) const +{ + return info->hasContent(content); +} + +bool ModListSortProxy::filterMatchesMod(ModInfo::Ptr info, bool enabled) const +{ + // don't check if there are no filters selected + if (!m_FilterActive) { + return true; + } + + // special case for separators + if (info->hasFlag(ModInfo::FLAG_SEPARATOR)) { + switch (m_FilterSeparators) { + case SeparatorFilter: { + // filter normally + break; + } + + case SeparatorShow: { + // force visible + return true; + } + + case SeparatorHide: { + // force hide + return false; + } + } + } + + if (!m_Filter.isEmpty()) { + bool display = false; + QString filterCopy = QString(m_Filter); + filterCopy.replace("||", ";").replace("OR", ";").replace("|", ";"); + QStringList ORList = filterCopy.split(";", Qt::SkipEmptyParts); + + bool segmentGood = true; + + // split in ORSegments that internally use AND logic + for (auto& ORSegment : ORList) { + QStringList ANDKeywords = ORSegment.split(" ", Qt::SkipEmptyParts); + segmentGood = true; + bool foundKeyword = false; + + // check each word in the segment for match, each word needs to be matched but + // it doesn't matter where. + for (auto& currentKeyword : ANDKeywords) { + foundKeyword = false; + + // search keyword in name + if (m_EnabledColumns[ModList::COL_NAME] && + info->name().contains(currentKeyword, Qt::CaseInsensitive)) { + foundKeyword = true; + } + + // Search by author + if (!foundKeyword && m_EnabledColumns[ModList::COL_AUTHOR] && + info->author().contains(currentKeyword, Qt::CaseInsensitive)) { + foundKeyword = true; + } + + // Search by uploader + if (!foundKeyword && m_EnabledColumns[ModList::COL_UPLOADER] && + info->uploader().contains(currentKeyword, Qt::CaseInsensitive)) { + foundKeyword = true; + } + + // Search by notes + if (!foundKeyword && m_EnabledColumns[ModList::COL_NOTES] && + (info->notes().contains(currentKeyword, Qt::CaseInsensitive) || + info->comments().contains(currentKeyword, Qt::CaseInsensitive))) { + foundKeyword = true; + } + + // Search by categories + if (!foundKeyword && m_EnabledColumns[ModList::COL_CATEGORY]) { + for (auto category : info->categories()) { + if (category.contains(currentKeyword, Qt::CaseInsensitive)) { + foundKeyword = true; + break; + } + } + } + + // Search by Nexus ID + if (!foundKeyword && m_EnabledColumns[ModList::COL_MODID]) { + bool ok; + int filterID = currentKeyword.toInt(&ok); + if (ok) { + int modID = info->nexusId(); + while (modID > 0) { + if (modID == filterID) { + foundKeyword = true; + break; + } + modID = (int)(modID / 10); + } + } + } + + if (!foundKeyword) { + // currentKeword is missing from everything, AND fails and we need to check + // next ORsegment + segmentGood = false; + break; + } + + } // for ANDKeywords loop + + if (segmentGood) { + // the last AND loop didn't break so the ORSegments is true so mod matches + // filter + display = true; + break; + } + + } // for ORList loop + + if (!display) { + return false; + } + } // if (!m_CurrentFilter.isEmpty()) + + if (m_FilterMode == FilterAnd) { + return filterMatchesModAnd(info, enabled); + } else { + return filterMatchesModOr(info, enabled); + } +} + +void ModListSortProxy::setColumnVisible(int column, bool visible) +{ + m_EnabledColumns[column] = visible; +} + +void ModListSortProxy::setOptions(ModListSortProxy::FilterMode mode, + SeparatorsMode separators) +{ + if (m_FilterMode != mode || separators != m_FilterSeparators) { + m_FilterMode = mode; + m_FilterSeparators = separators; + invalidateFilter(); + emit filterInvalidated(); + } +} + +bool ModListSortProxy::filterAcceptsRow(int source_row, const QModelIndex& parent) const +{ + if (m_Profile == nullptr) { + return false; + } + + if (source_row >= static_cast(m_Profile->numMods())) { + log::warn("invalid row index: {}", source_row); + return false; + } + + QModelIndex idx = sourceModel()->index(source_row, 0, parent); + if (!idx.isValid()) { + log::debug("invalid mod index"); + return false; + } + + unsigned int index = ULONG_MAX; + { + bool ok = false; + index = idx.data(ModList::IndexRole).toInt(&ok); + if (!ok) { + index = ULONG_MAX; + } + } + + if (sourceModel()->hasChildren(idx)) { + // we need to check the separator itself first + if (index < ModInfo::getNumMods() && ModInfo::getByIndex(index)->isSeparator()) { + if (filterMatchesMod(ModInfo::getByIndex(index), false)) { + return true; + } + } + for (int i = 0; i < sourceModel()->rowCount(idx); ++i) { + if (filterAcceptsRow(i, idx)) { + return true; + } + } + + return false; + } else { + bool modEnabled = + idx.sibling(source_row, 0).data(Qt::CheckStateRole).toInt() == Qt::Checked; + return filterMatchesMod(ModInfo::getByIndex(index), modEnabled); + } +} + +bool ModListSortProxy::sourceIsByPriorityProxy() const +{ + return dynamic_cast(sourceModel()) != nullptr; +} + +bool ModListSortProxy::canDropMimeData(const QMimeData* data, Qt::DropAction action, + int row, int column, + const QModelIndex& parent) const +{ + ModListDropInfo dropInfo(data, *m_Organizer); + + if (!dropInfo.isLocalFileDrop() && sortColumn() != ModList::COL_PRIORITY) { + return false; + } + + // disable drop install with group proxy, except the one for collapsible separator + // - it would be nice to be able to "install to category" or something like that but + // it's a bit more complicated since the drop position is based on the category, + // so just disabling for now + if (dropInfo.isDownloadDrop()) { + // maybe there is a cleaner way? + if (qobject_cast(sourceModel())) { + return false; + } + } + + // see dropMimeData for details + if (sortOrder() == Qt::DescendingOrder && row != -1 && !sourceIsByPriorityProxy()) { + --row; + } + + return QSortFilterProxyModel::canDropMimeData(data, action, row, column, parent); +} + +bool ModListSortProxy::dropMimeData(const QMimeData* data, Qt::DropAction action, + int row, int column, const QModelIndex& parent) +{ + ModListDropInfo dropInfo(data, *m_Organizer); + + if (!dropInfo.isLocalFileDrop() && sortColumn() != ModList::COL_PRIORITY) { + QWidget* wid = qApp->activeWindow()->findChild("modList"); + MessageDialog::showMessage( + tr("Drag&Drop is only supported when sorting by priority"), wid); + return false; + } + + if (row == -1 && column == -1) { + return sourceModel()->dropMimeData(data, action, -1, -1, mapToSource(parent)); + } + + // in the regular model, when dropping between rows, the row-value passed to + // the sourceModel is inconsistent between ascending and descending ordering + // + // we want to fix that, but we cannot do it for the by-priority proxy because + // it messes up with non top-level items, so we simply forward the row and the + // by-priority proxy will fix the row for us + if (sortOrder() == Qt::DescendingOrder && row != -1 && !sourceIsByPriorityProxy()) { + --row; + } + + return QSortFilterProxyModel::dropMimeData(data, action, row, column, parent); +} + +void ModListSortProxy::setSourceModel(QAbstractItemModel* sourceModel) +{ + QSortFilterProxyModel::setSourceModel(sourceModel); + QAbstractProxyModel* proxy = qobject_cast(sourceModel); + if (proxy != nullptr) { + sourceModel = proxy->sourceModel(); + } + if (sourceModel) { + connect(sourceModel, SIGNAL(aboutToChangeData()), this, SLOT(aboutToChangeData()), + Qt::UniqueConnection); + connect(sourceModel, SIGNAL(postDataChanged()), this, SLOT(postDataChanged()), + Qt::UniqueConnection); + } +} + +void ModListSortProxy::aboutToChangeData() +{ + // having a filter active when dataChanged is called caused a crash + // (at least with some Qt versions) + // this may be related to the fact that the item being edited may disappear from the + // view as a result of the edit + m_PreChangeCriteria = m_Criteria; + setCriteria({}); +} + +void ModListSortProxy::postDataChanged() +{ + // if the filter is re-activated right away the editor can't be deleted but becomes + // invisible or at least the view continues to think it's being edited. As a result + // no new editor can be opened + QTimer::singleShot(10, [this]() { + setCriteria(m_PreChangeCriteria); + m_PreChangeCriteria.clear(); + }); +} diff --git a/src/src/modlistsortproxy.h b/src/src/modlistsortproxy.h new file mode 100644 index 0000000..030dc76 --- /dev/null +++ b/src/src/modlistsortproxy.h @@ -0,0 +1,167 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef MODLISTSORTPROXY_H +#define MODLISTSORTPROXY_H + +#include "modlist.h" +#include +#include + +class Profile; +class OrganizerCore; + +class ModListSortProxy : public QSortFilterProxyModel +{ + Q_OBJECT + +public: + enum FilterMode + { + FilterAnd, + FilterOr + }; + + enum CriteriaType + { + TypeSpecial, + TypeCategory, + TypeContent + }; + + enum SeparatorsMode + { + SeparatorFilter, + SeparatorShow, + SeparatorHide + }; + + struct Criteria + { + CriteriaType type; + int id; + bool inverse; + + bool operator==(const Criteria& other) const + { + return (type == other.type) && (id == other.id) && (inverse == other.inverse); + } + + bool operator!=(const Criteria& other) const { return !(*this == other); } + }; + +public: + explicit ModListSortProxy(Profile* profile, OrganizerCore* organizer); + + void setProfile(Profile* profile); + + bool canDropMimeData(const QMimeData* data, Qt::DropAction action, int row, + int column, const QModelIndex& parent) const override; + bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column, + const QModelIndex& parent) override; + + virtual void setSourceModel(QAbstractItemModel* sourceModel) override; + + /** + * @brief tests if a filtere matches for a mod + * @param info mod information + * @param enabled true if the mod is currently active + * @return true if current active filters match for the specified mod + */ + bool filterMatchesMod(ModInfo::Ptr info, bool enabled) const; + + /** + * @return true if a filter is currently active + */ + bool isFilterActive() const { return m_FilterActive; } + + void setCriteria(const std::vector& criteria); + void setOptions(FilterMode mode, SeparatorsMode separators); + + auto filterMode() const { return m_FilterMode; } + auto separatorsMode() const { return m_FilterSeparators; } + + /** + * @brief tests if the specified index has child nodes + * @param parent the node to test + * @return true if there are child nodes + */ + virtual bool hasChildren(const QModelIndex& parent = QModelIndex()) const + { + return rowCount(parent) > 0; + } + + /** + * @brief sets whether a column is visible + * @param column the index of the column + * @param visible the visibility of the column + */ + void setColumnVisible(int column, bool visible); + +public slots: + + void updateFilter(const QString& filter); + +signals: + + void filterActive(bool active); + void filterInvalidated(); + +protected: + virtual bool lessThan(const QModelIndex& left, const QModelIndex& right) const; + virtual bool filterAcceptsRow(int row, const QModelIndex& parent) const; + +private: + unsigned long flagsId(const std::vector& flags) const; + unsigned long conflictFlagsId(const std::vector& flags) const; + bool hasConflictFlag(const std::vector& flags) const; + void updateFilterActive(); + bool filterMatchesModAnd(ModInfo::Ptr info, bool enabled) const; + bool filterMatchesModOr(ModInfo::Ptr info, bool enabled) const; + + // check if the source model is the by-priority proxy + // + bool sourceIsByPriorityProxy() const; + +private slots: + + void aboutToChangeData(); + void postDataChanged(); + +private: + OrganizerCore* m_Organizer; + + Profile* m_Profile; + std::vector m_Criteria; + QString m_Filter; + std::bitset m_EnabledColumns; + + bool m_FilterActive; + FilterMode m_FilterMode; + SeparatorsMode m_FilterSeparators; + + std::vector m_PreChangeCriteria; + + bool optionsMatchMod(ModInfo::Ptr info, bool enabled) const; + bool criteriaMatchMod(ModInfo::Ptr info, bool enabled, const Criteria& c) const; + bool categoryMatchesMod(ModInfo::Ptr info, bool enabled, int category) const; + bool contentMatchesMod(ModInfo::Ptr info, bool enabled, int content) const; +}; + +#endif // MODLISTSORTPROXY_H diff --git a/src/src/modlistversiondelegate.cpp b/src/src/modlistversiondelegate.cpp new file mode 100644 index 0000000..1728001 --- /dev/null +++ b/src/src/modlistversiondelegate.cpp @@ -0,0 +1,67 @@ +#include "modlistversiondelegate.h" + +#include "log.h" +#include "modlistview.h" +#include "settings.h" + +ModListVersionDelegate::ModListVersionDelegate(ModListView* view, Settings& settings) + : QItemDelegate(view), m_view(view), m_settings(settings) +{} + +void ModListVersionDelegate::paint(QPainter* painter, + const QStyleOptionViewItem& option, + const QModelIndex& index) const +{ + m_view->itemDelegate()->paint(painter, option, index); + + if (m_view->hasCollapsibleSeparators() && m_view->model()->hasChildren(index) && + m_settings.interface().collapsibleSeparatorsIcons(ModList::COL_VERSION) && + !m_view->isExpanded(index.sibling(index.row(), 0))) { + auto* model = m_view->model(); + + bool downgrade = false, upgrade = false; + + for (int i = 0; i < model->rowCount(index); ++i) { + const auto mIndex = + model->index(i, index.column(), index).data(ModList::IndexRole); + if (mIndex.isValid()) { + auto info = ModInfo::getByIndex(mIndex.toInt()); + downgrade = downgrade || info->downgradeAvailable(); + upgrade = upgrade || info->updateAvailable(); + } + } + + const int margin = + m_view->style()->pixelMetric(QStyle::PM_FocusFrameHMargin, nullptr, m_view) + 1; + + QStyleOptionViewItem opt(option); + const int sz = + m_view->style()->pixelMetric(QStyle::PM_SmallIconSize, nullptr, m_view); + opt.decorationSize = QSize(sz, sz); + opt.decorationAlignment = Qt::AlignCenter; + + if (upgrade) { + QIcon icon(":/MO/gui/update_available"); + QPixmap pixmap = decoration(opt, icon); + + QSize pm = icon.actualSize(opt.decorationSize); + pm.rwidth() += 2 * margin; + opt.rect.setRect(opt.rect.x(), opt.rect.y(), pm.width(), opt.rect.height()); + drawDecoration(painter, opt, opt.rect, pixmap); + + // add margin for next icon (if any) + opt.rect.adjust(opt.decorationSize.width() + margin, 0, 0, 0); + } + + if (downgrade) { + QIcon icon(":/MO/gui/warning"); + QPixmap pixmap = decoration(opt, icon); + + QSize pm = icon.actualSize(opt.decorationSize); + pm.rwidth() += 2 * margin; + opt.rect.setRect(opt.rect.x(), opt.rect.y(), pm.width(), opt.rect.height()); + + drawDecoration(painter, opt, opt.rect, pixmap); + } + } +} diff --git a/src/src/modlistversiondelegate.h b/src/src/modlistversiondelegate.h new file mode 100644 index 0000000..08c429c --- /dev/null +++ b/src/src/modlistversiondelegate.h @@ -0,0 +1,22 @@ +#ifndef MODLISTVERSIONDELEGATE_H +#define MODLISTVERSIONDELEGATE_H + +#include + +class ModListView; +class Settings; + +class ModListVersionDelegate : public QItemDelegate +{ +public: + ModListVersionDelegate(ModListView* view, Settings& settings); + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + +private: + ModListView* m_view; + Settings& m_settings; +}; + +#endif diff --git a/src/src/modlistview.cpp b/src/src/modlistview.cpp new file mode 100644 index 0000000..ae69ac4 --- /dev/null +++ b/src/src/modlistview.cpp @@ -0,0 +1,1538 @@ +#include "modlistview.h" +#include +#include +#include + +#include + +#include "filesystemutilities.h" +#include + +#include "ui_mainwindow.h" + +#include "copyeventfilter.h" +#include "filterlist.h" +#include "genericicondelegate.h" +#include "log.h" +#include "mainwindow.h" +#include "modconflicticondelegate.h" +#include "modcontenticondelegate.h" +#include "modelutils.h" +#include "modflagicondelegate.h" +#include "modlist.h" +#include "modlistbypriorityproxy.h" +#include "modlistcontextmenu.h" +#include "modlistdropinfo.h" +#include "modlistsortproxy.h" +#include "modlistversiondelegate.h" +#include "modlistviewactions.h" +#include "organizercore.h" + +using namespace MOBase; +using namespace MOShared; + +// delegate to remove indentation for mods when using collapsible +// separator +// +// the delegate works by removing the indentation of the child items +// before drawing, but unfortunately this normally breaks event +// handling (e.g. checkbox, edit, etc.), so we also need to override +// the visualRect() function from the mod list view. +// +class ModListStyledItemDelegate : public QStyledItemDelegate +{ + ModListView* m_view; + +public: + ModListStyledItemDelegate(ModListView* view) : QStyledItemDelegate(view), m_view(view) + {} + + void initStyleOption(QStyleOptionViewItem* option, + const QModelIndex& index) const override + { + // the parent version always overwrite the background brush, so + // we need to save it and restore it + auto backgroundColor = option->backgroundBrush.color(); + QStyledItemDelegate::initStyleOption(option, index); + + if (backgroundColor.isValid()) { + option->backgroundBrush = backgroundColor; + } + } + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override + { + QStyleOptionViewItem opt(option); + + // remove items indentaiton when using collapsible separators + if (index.column() == 0 && m_view->hasCollapsibleSeparators()) { + if (!index.model()->hasChildren(index) && index.parent().isValid()) { + auto parentIndex = index.parent().data(ModList::IndexRole).toInt(); + if (ModInfo::getByIndex(parentIndex)->isSeparator()) { + opt.rect.adjust(-m_view->indentation(), 0, 0, 0); + } + } + } + + // compute required color from children, otherwise fallback to the + // color from the model, and draw the background here + auto color = m_view->markerColor(index); + if (!color.isValid()) { + color = index.data(Qt::BackgroundRole).value(); + } + opt.backgroundBrush = color; + + // we need to find the background color to compute the ideal text color + // but the mod list view uses alternate color so we need to find the + // right color + auto bg = opt.palette.base().color(); + if (opt.features & QStyleOptionViewItem::Alternate) { + bg = opt.palette.alternateBase().color(); + } + + // compute ideal foreground color for some rows + if (color.isValid()) { + if (((index.column() == ModList::COL_NAME || + index.column() == ModList::COL_PRIORITY) && + ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()) + ->isSeparator()) || + index.column() == ModList::COL_NOTES) { + + // combine the color with the background and then find the "ideal" text color + const auto a = color.alpha() / 255.; + int r = (1 - a) * bg.red() + a * color.red(), + g = (1 - a) * bg.green() + a * color.green(), + b = (1 - a) * bg.blue() + a * color.blue(); + opt.palette.setBrush(QPalette::Text, + ColorSettings::idealTextColor(QColor(r, g, b))); + } + } + + QStyledItemDelegate::paint(painter, opt, index); + } +}; + +class ModListViewMarkingScrollBar : public ViewMarkingScrollBar +{ + ModListView* m_view; + +public: + ModListViewMarkingScrollBar(ModListView* view) + : ViewMarkingScrollBar(view, ModList::ScrollMarkRole), m_view(view) + {} + + QColor color(const QModelIndex& index) const override + { + auto color = m_view->markerColor(index); + if (!color.isValid()) { + color = ViewMarkingScrollBar::color(index); + } + return color; + } +}; + +ModListView::ModListView(QWidget* parent) + : QTreeView(parent), m_core(nullptr), m_sortProxy(nullptr), + m_byPriorityProxy(nullptr), m_byCategoryProxy(nullptr), m_byNexusIdProxy(nullptr), + m_markers{{}, {}, {}, {}, {}, {}}, + m_scrollbar(new ModListViewMarkingScrollBar(this)) +{ + setVerticalScrollBar(m_scrollbar); + MOBase::setCustomizableColumns(this); + setAutoExpandDelay(750); + + setItemDelegate(new ModListStyledItemDelegate(this)); + + connect(this, &ModListView::doubleClicked, this, &ModListView::onDoubleClicked); + connect(this, &ModListView::customContextMenuRequested, this, + &ModListView::onCustomContextMenuRequested); + + // the timeout is pretty small because its main purpose is to avoid + // refreshing multiple times when calling expandAll() or collapseAll() + // which emit a lots of expanded/collapsed signals in a very small + // time window + m_refreshMarkersTimer.setInterval(50); + m_refreshMarkersTimer.setSingleShot(true); + connect(&m_refreshMarkersTimer, &QTimer::timeout, [=] { + refreshMarkersAndPlugins(); + }); + + installEventFilter(new CopyEventFilter(this, [=](auto& index) { + QVariant mIndex = index.data(ModList::IndexRole); + QString name = index.data(Qt::DisplayRole).toString(); + if (mIndex.isValid() && hasCollapsibleSeparators()) { + ModInfo::Ptr info = ModInfo::getByIndex(mIndex.toInt()); + if (info->isSeparator()) { + name = "[" + name + "]"; + } + } else if (model()->hasChildren(index)) { + name = "[" + name + "]"; + } + return name; + })); +} + +void ModListView::refresh() +{ + updateGroupByProxy(); +} + +void ModListView::onProfileChanged(Profile* oldProfile, Profile* newProfile) +{ + const auto perProfileSeparators = + m_core->settings().interface().collapsibleSeparatorsPerProfile(); + + // save expanded/collapsed state of separators + if (oldProfile && perProfileSeparators) { + auto& collapsed = m_collapsed[m_byPriorityProxy]; + oldProfile->storeSetting("UserInterface", "collapsed_separators", + QStringList(collapsed.begin(), collapsed.end())); + } + + m_sortProxy->setProfile(newProfile); + m_byPriorityProxy->setProfile(newProfile); + + if (newProfile && perProfileSeparators) { + auto collapsed = + newProfile->setting("UserInterface", "collapsed_separators", QStringList()) + .toStringList(); + m_collapsed[m_byPriorityProxy] = {collapsed.begin(), collapsed.end()}; + } +} + +bool ModListView::hasCollapsibleSeparators() const +{ + return groupByMode() == GroupByMode::SEPARATOR; +} + +int ModListView::sortColumn() const +{ + return m_sortProxy ? m_sortProxy->sortColumn() : -1; +} + +Qt::SortOrder ModListView::sortOrder() const +{ + return m_sortProxy ? m_sortProxy->sortOrder() : Qt::AscendingOrder; +} + +bool ModListView::isFilterActive() const +{ + return m_sortProxy && m_sortProxy->isFilterActive(); +} + +ModListView::GroupByMode ModListView::groupByMode() const +{ + if (m_sortProxy == nullptr) { + return GroupByMode::NONE; + } else if (m_sortProxy->sourceModel() == m_byPriorityProxy) { + return GroupByMode::SEPARATOR; + } else if (m_sortProxy->sourceModel() == m_byCategoryProxy) { + return GroupByMode::CATEGORY; + } else if (m_sortProxy->sourceModel() == m_byNexusIdProxy) { + return GroupByMode::NEXUS_ID; + } else { + return GroupByMode::NONE; + } +} + +ModListViewActions& ModListView::actions() const +{ + return *m_actions; +} + +std::optional ModListView::nextMod(unsigned int modIndex) const +{ + const QModelIndex start = indexModelToView(m_core->modList()->index(modIndex, 0)); + + auto index = start; + + for (;;) { + index = nextIndex(index); + + if (index == start || !index.isValid()) { + // wrapped around, give up + break; + } + + modIndex = index.data(ModList::IndexRole).toInt(); + + ModInfo::Ptr mod = ModInfo::getByIndex(modIndex); + + // skip overwrite, backups and separators + if (mod->isOverwrite() || mod->isBackup() || mod->isSeparator()) { + continue; + } + + return modIndex; + } + + return {}; +} + +std::optional ModListView::prevMod(unsigned int modIndex) const +{ + const QModelIndex start = indexModelToView(m_core->modList()->index(modIndex, 0)); + + auto index = start; + + for (;;) { + index = prevIndex(index); + + if (index == start || !index.isValid()) { + // wrapped around, give up + break; + } + + modIndex = index.data(ModList::IndexRole).toInt(); + + // skip overwrite, backups and separators + ModInfo::Ptr mod = ModInfo::getByIndex(modIndex); + if (mod->isOverwrite() || mod->isBackup() || mod->isSeparator()) { + continue; + } + + return modIndex; + } + + return {}; +} + +void ModListView::invalidateFilter() +{ + m_sortProxy->invalidate(); +} + +void ModListView::setFilterCriteria( + const std::vector& criteria) +{ + m_sortProxy->setCriteria(criteria); +} + +void ModListView::setFilterOptions(ModListSortProxy::FilterMode mode, + ModListSortProxy::SeparatorsMode sep) +{ + m_sortProxy->setOptions(mode, sep); +} + +bool ModListView::isModVisible(unsigned int index) const +{ + return m_sortProxy->filterMatchesMod(ModInfo::getByIndex(index), + m_core->currentProfile()->modEnabled(index)); +} + +bool ModListView::isModVisible(ModInfo::Ptr mod) const +{ + return m_sortProxy->filterMatchesMod( + mod, m_core->currentProfile()->modEnabled(ModInfo::getIndex(mod->name()))); +} + +QModelIndex ModListView::indexModelToView(const QModelIndex& index) const +{ + return ::indexModelToView(index, this); +} + +QModelIndexList ModListView::indexModelToView(const QModelIndexList& index) const +{ + return ::indexModelToView(index, this); +} + +QModelIndex ModListView::indexViewToModel(const QModelIndex& index) const +{ + return ::indexViewToModel(index, m_core->modList()); +} + +QModelIndexList ModListView::indexViewToModel(const QModelIndexList& index) const +{ + return ::indexViewToModel(index, m_core->modList()); +} + +QModelIndex ModListView::nextIndex(const QModelIndex& index) const +{ + auto* model = index.model(); + if (!model) { + return {}; + } + + if (model->rowCount(index) > 0) { + return model->index(0, index.column(), index); + } + + if (index.parent().isValid()) { + if (index.row() + 1 < model->rowCount(index.parent())) { + return index.model()->index(index.row() + 1, index.column(), index.parent()); + } else { + return index.model()->index((index.parent().row() + 1) % + model->rowCount(index.parent().parent()), + index.column(), index.parent().parent()); + ; + } + } else { + return index.model()->index((index.row() + 1) % model->rowCount(index.parent()), + index.column(), index.parent()); + } +} + +QModelIndex ModListView::prevIndex(const QModelIndex& index) const +{ + if (index.row() == 0 && index.parent().isValid()) { + return index.parent(); + } + + auto* model = index.model(); + if (!model) { + return {}; + } + + auto prev = model->index((index.row() - 1) % model->rowCount(index.parent()), + index.column(), index.parent()); + + if (model->rowCount(prev) > 0) { + return model->index(model->rowCount(prev) - 1, index.column(), prev); + } + + return prev; +} + +std::pair ModListView::selected() const +{ + return {indexViewToModel(currentIndex()), + indexViewToModel(selectionModel()->selectedRows())}; +} + +void ModListView::setSelected(const QModelIndex& current, + const QModelIndexList& selected) +{ + setCurrentIndex(indexModelToView(current)); + for (auto idx : selected) { + selectionModel()->select(indexModelToView(idx), + QItemSelectionModel::Select | QItemSelectionModel::Rows); + } +} + +void ModListView::scrollToAndSelect(const QModelIndex& index) +{ + scrollToAndSelect(QModelIndexList{index}); +} + +void ModListView::scrollToAndSelect(const QModelIndexList& indexes, + const QModelIndex& current) +{ + // focus, scroll to and select + if (!current.isValid() && indexes.isEmpty()) { + return; + } + scrollTo(current.isValid() ? current : indexes.first()); + setCurrentIndex(current.isValid() ? current : indexes.first()); + QItemSelection selection; + for (auto& idx : indexes) { + selection.select(idx, idx); + } + selectionModel()->select(selection, + QItemSelectionModel::Select | QItemSelectionModel::Rows); + QTimer::singleShot(50, [=] { + setFocus(); + }); +} + +void ModListView::refreshExpandedItems() +{ + auto* model = m_sortProxy->sourceModel(); + for (auto i = 0; i < model->rowCount(); ++i) { + auto idx = model->index(i, 0); + if (!m_collapsed[model].contains(idx.data(Qt::DisplayRole).toString())) { + setExpanded(m_sortProxy->mapFromSource(idx), true); + } + } +} + +void ModListView::onModPrioritiesChanged(const QModelIndexList& indices) +{ + // expand separator whose priority has changed and parents + for (auto index : indices) { + auto idx = indexModelToView(index); + if (hasCollapsibleSeparators() && model()->hasChildren(idx)) { + setExpanded(idx, true); + } + if (idx.parent().isValid()) { + setExpanded(idx.parent(), true); + } + } + + setOverwriteMarkers(selectionModel()->selectedRows()); +} + +void ModListView::onModInstalled(const QString& modName) +{ + unsigned int index = ModInfo::getIndex(modName); + + if (index == UINT_MAX) { + return; + } + + QModelIndex qIndex = indexModelToView(m_core->modList()->index(index, 0)); + + if (hasCollapsibleSeparators() && qIndex.parent().isValid()) { + setExpanded(qIndex.parent(), true); + } + + scrollToAndSelect(qIndex); +} + +void ModListView::onModFilterActive(bool filterActive) +{ + ui.clearFilters->setVisible(filterActive); + if (filterActive) { + setStyleSheet("QTreeView { border: 2px ridge #f00; }"); + ui.counter->setStyleSheet("QLCDNumber { border: 2px ridge #f00; }"); + } else if (ui.groupBy->currentIndex() != GroupBy::NONE) { + setStyleSheet("QTreeView { border: 2px ridge #337733; }"); + ui.counter->setStyleSheet(""); + } else { + setStyleSheet(""); + ui.counter->setStyleSheet(""); + } +} + +ModListView::ModCounters ModListView::counters() const +{ + ModCounters c; + + auto hasFlag = [](std::vector flags, ModInfo::EFlag filter) { + return std::find(flags.begin(), flags.end(), filter) != flags.end(); + }; + + for (unsigned int index = 0; index < ModInfo::getNumMods(); ++index) { + auto info = ModInfo::getByIndex(index); + const auto flags = info->getFlags(); + + const bool enabled = m_core->currentProfile()->modEnabled(index); + const bool visible = m_sortProxy->filterMatchesMod(info, enabled); + + if (info->isBackup()) { + c.backup++; + if (visible) + c.visible.backup++; + } else if (info->isForeign()) { + c.foreign++; + if (visible) + c.visible.foreign++; + } else if (info->isSeparator()) { + c.separator++; + if (visible) + c.visible.separator++; + } else if (!info->isOverwrite()) { + c.regular++; + if (visible) + c.visible.regular++; + if (enabled) { + c.active++; + if (visible) + c.visible.active++; + } + } + } + + return c; +} + +void ModListView::updateModCount() +{ + const auto c = counters(); + + ui.counter->display(c.visible.active); + ui.counter->setToolTip(tr("" + "" + "" + "" + "" + "" + "
    TypeAllVisible
    Enabled mods: %1 / " + "%2%3 / %4
    Unmanaged/DLCs: %5%6
    Mod backups: %7%8
    Separators: %9%10
    ") + .arg(c.active) + .arg(c.regular) + .arg(c.visible.active) + .arg(c.visible.regular) + .arg(c.foreign) + .arg(c.visible.foreign) + .arg(c.backup) + .arg(c.visible.backup) + .arg(c.separator) + .arg(c.visible.separator)); +} + +void ModListView::refreshFilters() +{ + auto [current, sourceRows] = selected(); + + setCurrentIndex(QModelIndex()); + m_filters->refresh(); + + setSelected(current, sourceRows); +} + +void ModListView::onExternalFolderDropped(const QUrl& url, int priority) +{ + setWindowState(Qt::WindowActive); + + QFileInfo fileInfo(url.toLocalFile()); + + GuessedValue name; + name.setFilter(&fixDirectoryName); + name.update(fileInfo.fileName(), GUESS_PRESET); + + do { + bool ok; + name.update( + QInputDialog::getText(this, tr("Copy Folder..."), + tr("This will copy the content of %1 to a new mod.\n" + "Please enter the name:") + .arg(fileInfo.fileName()), + QLineEdit::Normal, name, &ok), + GUESS_USER); + if (!ok) { + return; + } + } while (name->isEmpty()); + + if (m_core->modList()->getMod(name) != nullptr) { + reportError(tr("A mod with this name already exists.")); + return; + } + + IModInterface* newMod = m_core->createMod(name); + if (!newMod) { + return; + } + + // TODO: this is currently a silent copy, which can take some time, but there is + // no clean method to do this in uibase + if (!copyDir(fileInfo.absoluteFilePath(), newMod->absolutePath(), true)) { + return; + } + + m_core->refresh(); + + const auto index = ModInfo::getIndex(name); + if (priority != -1) { + m_core->modList()->changeModPriority(index, priority); + } + + scrollToAndSelect(indexModelToView(m_core->modList()->index(index, 0))); +} + +bool ModListView::moveSelection(int key) +{ + auto rows = selectionModel()->selectedRows(); + const QPersistentModelIndex current(key == Qt::Key_Up ? rows.first() : rows.last()); + + int offset = key == Qt::Key_Up ? -1 : 1; + if (m_sortProxy->sortOrder() == Qt::DescendingOrder) { + offset = -offset; + } + + m_core->modList()->shiftModsPriority(indexViewToModel(rows), offset); + selectionModel()->setCurrentIndex(current, QItemSelectionModel::NoUpdate); + scrollTo(current); + + return true; +} + +bool ModListView::removeSelection() +{ + m_actions->removeMods(indexViewToModel(selectionModel()->selectedRows())); + return true; +} + +bool ModListView::toggleSelectionState() +{ + if (!selectionModel()->hasSelection()) { + return true; + } + return m_core->modList()->toggleState( + indexViewToModel(selectionModel()->selectedRows())); +} + +void ModListView::updateGroupByProxy() +{ + int groupIndex = ui.groupBy->currentIndex(); + auto* previousModel = m_sortProxy->sourceModel(); + + QAbstractItemModel* nextModel = m_core->modList(); + if (groupIndex == GroupBy::CATEGORY) { + nextModel = m_byCategoryProxy; + } else if (groupIndex == GroupBy::NEXUS_ID) { + nextModel = m_byNexusIdProxy; + } else if (m_core->settings().interface().collapsibleSeparators( + m_sortProxy->sortOrder()) && + m_sortProxy->sortColumn() == ModList::COL_PRIORITY) { + m_byPriorityProxy->setSortOrder(m_sortProxy->sortOrder()); + nextModel = m_byPriorityProxy; + } + + if (nextModel != previousModel) { + + if (auto* proxy = dynamic_cast(nextModel)) { + proxy->setSourceModel(m_core->modList()); + } + m_sortProxy->setSourceModel(nextModel); + + // reset the source model of the old proxy because we do not want to + // react to signals + // + if (auto* proxy = qobject_cast(previousModel)) { + proxy->setSourceModel(nullptr); + } + + // expand items previously expanded + refreshExpandedItems(); + + if (hasCollapsibleSeparators()) { + ui.filterSeparators->setCurrentIndex(ModListSortProxy::SeparatorFilter); + ui.filterSeparators->setEnabled(false); + } else { + ui.filterSeparators->setEnabled(true); + } + } +} + +void ModListView::setup(OrganizerCore& core, CategoryFactory& factory, MainWindow* mw, + Ui::MainWindow* mwui) +{ + // attributes + m_core = &core; + m_filters.reset(new FilterList(mwui, core, factory)); + m_categories = &factory; + m_actions = + new ModListViewActions(core, *m_filters, factory, this, mwui->espList, mw); + ui = {mwui->groupCombo, + mwui->activeModsCounter, + mwui->modFilterEdit, + mwui->currentCategoryLabel, + mwui->clearFiltersButton, + mwui->filtersSeparators, + mwui->espList}; + + connect(m_core, &OrganizerCore::modInstalled, [=](auto&& name) { + onModInstalled(name); + }); + connect(m_core, &OrganizerCore::profileChanged, this, &ModListView::onProfileChanged); + connect(core.modList(), &ModList::modPrioritiesChanged, [=](auto&& indices) { + onModPrioritiesChanged(indices); + }); + connect(core.modList(), &ModList::clearOverwrite, [=] { + m_actions->clearOverwrite(); + }); + connect(core.modList(), &ModList::modStatesChanged, [=] { + updateModCount(); + setOverwriteMarkers(selectionModel()->selectedRows()); + }); + connect(core.modList(), &ModList::modelReset, [=] { + clearOverwriteMarkers(); + }); + + // proxy for various group by + m_byPriorityProxy = + new ModListByPriorityProxy(core.currentProfile().get(), core, this); + m_byCategoryProxy = new QtGroupingProxy(QModelIndex(), ModList::COL_CATEGORY, + ModList::GroupingRole, 0, ModList::AggrRole); + m_byNexusIdProxy = new QtGroupingProxy( + QModelIndex(), ModList::COL_MODID, ModList::GroupingRole, + QtGroupingProxy::FLAG_NOGROUPNAME | QtGroupingProxy::FLAG_NOSINGLE, + ModList::AggrRole); + + // we need to store the expanded/collapsed state of all items and restore them 1) when + // switching proxies, 2) when filtering and 3) when reseting the mod list. + connect(this, &QTreeView::expanded, [=](const QModelIndex& index) { + auto it = m_collapsed[m_sortProxy->sourceModel()].find( + index.data(Qt::DisplayRole).toString()); + if (it != m_collapsed[m_sortProxy->sourceModel()].end()) { + m_collapsed[m_sortProxy->sourceModel()].erase(it); + } + }); + connect(this, &QTreeView::collapsed, [=](const QModelIndex& index) { + m_collapsed[m_sortProxy->sourceModel()].insert( + index.data(Qt::DisplayRole).toString()); + }); + + // the top-level proxy + m_sortProxy = new ModListSortProxy(core.currentProfile().get(), &core); + setModel(m_sortProxy); + connect(m_sortProxy, &ModList::modelReset, [=] { + refreshExpandedItems(); + }); + + // update the proxy when changing the sort column/direction and the group + connect(m_sortProxy, &QAbstractItemModel::layoutAboutToBeChanged, + [this](auto&& parents, auto&& hint) { + if (hint == QAbstractItemModel::VerticalSortHint) { + updateGroupByProxy(); + } + }); + connect(ui.groupBy, QOverload::of(&QComboBox::currentIndexChanged), + [=](int index) { + updateGroupByProxy(); + onModFilterActive(m_sortProxy->isFilterActive()); + }); + sortByColumn(ModList::COL_PRIORITY, Qt::AscendingOrder); + + // inform the mod list about the type of item being dropped at the beginning of a drag + // and the position of the drop indicator at the end (only for by-priority) + connect(this, &ModListView::dragEntered, core.modList(), &ModList::onDragEnter); + connect(this, &ModListView::dropEntered, m_byPriorityProxy, + &ModListByPriorityProxy::onDropEnter); + + connect(m_sortProxy, &ModListSortProxy::filterInvalidated, this, + &ModListView::updateModCount); + + connect(header(), &QHeaderView::sortIndicatorChanged, [=](int, Qt::SortOrder) { + verticalScrollBar()->repaint(); + }); + connect(header(), &QHeaderView::sectionResized, + [=](int logicalIndex, int oldSize, int newSize) { + m_sortProxy->setColumnVisible(logicalIndex, newSize != 0); + }); + + setItemDelegateForColumn(ModList::COL_FLAGS, + new ModFlagIconDelegate(this, ModList::COL_FLAGS, 120)); + setItemDelegateForColumn( + ModList::COL_CONFLICTFLAGS, + new ModConflictIconDelegate(this, ModList::COL_CONFLICTFLAGS, 80)); + setItemDelegateForColumn(ModList::COL_CONTENT, + new ModContentIconDelegate(this, ModList::COL_CONTENT, 150)); + setItemDelegateForColumn(ModList::COL_VERSION, + new ModListVersionDelegate(this, core.settings())); + + if (m_core->settings().geometry().restoreState(header())) { + // hack: force the resize-signal to be triggered because restoreState doesn't seem + // to do that + for (int column = 0; column <= ModList::COL_LASTCOLUMN; ++column) { + int sectionSize = header()->sectionSize(column); + header()->resizeSection(column, sectionSize + 1); + header()->resizeSection(column, sectionSize); + } + } else { + // hide these columns by default + header()->setSectionHidden(ModList::COL_CONTENT, true); + header()->setSectionHidden(ModList::COL_MODID, true); + header()->setSectionHidden(ModList::COL_UPLOADER, true); + header()->setSectionHidden(ModList::COL_GAME, true); + header()->setSectionHidden(ModList::COL_INSTALLTIME, true); + header()->setSectionHidden(ModList::COL_NOTES, true); + + // resize mod list to fit content + for (int i = 0; i < header()->count(); ++i) { + header()->setSectionResizeMode(i, QHeaderView::ResizeToContents); + } + + header()->setSectionResizeMode(ModList::COL_NAME, QHeaderView::Stretch); + } + + // prevent the name-column from being hidden + header()->setSectionHidden(ModList::COL_NAME, false); + + // we need QueuedConnection for the download/archive dropped otherwise the + // installation starts within the drop-event and it's not possible to drag&drop + // in the manual installer + connect( + m_core->modList(), &ModList::downloadArchiveDropped, this, + [=](int row, int priority) { + m_core->installDownload(row, priority); + }, + Qt::QueuedConnection); + connect( + m_core->modList(), &ModList::externalArchiveDropped, this, + [=](const QUrl& url, int priority) { + setWindowState(Qt::WindowActive); + m_core->installArchive(url.toLocalFile(), priority, false, nullptr); + }, + Qt::QueuedConnection); + connect(m_core->modList(), &ModList::externalFolderDropped, this, + &ModListView::onExternalFolderDropped); + + connect(selectionModel(), &QItemSelectionModel::selectionChanged, [=] { + m_refreshMarkersTimer.start(); + }); + connect(this, &QTreeView::collapsed, [=] { + m_refreshMarkersTimer.start(); + }); + connect(this, &QTreeView::expanded, [=] { + m_refreshMarkersTimer.start(); + }); + + // filters + connect(m_sortProxy, &ModListSortProxy::filterActive, this, + &ModListView::onModFilterActive); + connect(m_filters.get(), &FilterList::criteriaChanged, [=](auto&& v) { + onFiltersCriteria(v); + }); + connect(m_filters.get(), &FilterList::optionsChanged, [=](auto&& mode, auto&& sep) { + setFilterOptions(mode, sep); + }); + connect(ui.filter, &QLineEdit::textChanged, m_sortProxy, + &ModListSortProxy::updateFilter); + connect(ui.clearFilters, &QPushButton::clicked, [=]() { + ui.filter->clear(); + m_filters->clearSelection(); + }); + connect(m_sortProxy, &ModListSortProxy::filterInvalidated, [=]() { + if (hasCollapsibleSeparators()) { + refreshExpandedItems(); + } + }); +} + +void ModListView::restoreState(const Settings& s) +{ + s.geometry().restoreState(header()); + + s.widgets().restoreIndex(ui.groupBy); + s.widgets().restoreTreeExpandState(this); + + m_filters->restoreState(s); +} + +void ModListView::saveState(Settings& s) const +{ + s.geometry().saveState(header()); + + s.widgets().saveIndex(ui.groupBy); + s.widgets().saveTreeExpandState(this); + + m_filters->saveState(s); +} + +QRect ModListView::visualRect(const QModelIndex& index) const +{ + // this shift the visualRect() from QTreeView to match the new actual + // zone after removing indentation (see the ModListStyledItemDelegate) + QRect rect = QTreeView::visualRect(index); + if (hasCollapsibleSeparators() && index.column() == 0 && index.isValid() && + index.parent().isValid()) { + rect.adjust(-indentation(), 0, 0, 0); + } + return rect; +} + +void ModListView::drawBranches(QPainter* painter, const QRect& rect, + const QModelIndex& index) const +{ + // the branches are the small indicator left to the row (there are none in the default + // style, and the VS dark style only has background for these) + // + // the branches are not shifted left with the visualRect() change and since MO2 uses + // stylesheet, it is not possible to shift those in the proxy style so we have to + // shift it here. + // + QRect r(rect); + if (hasCollapsibleSeparators() && index.parent().isValid()) { + r.adjust(-indentation(), 0, 0 - indentation(), 0); + } + QTreeView::drawBranches(painter, r, index); +} + +void ModListView::commitData(QWidget* editor) +{ + // maintain the selection when changing priority + if (currentIndex().column() == ModList::COL_PRIORITY) { + auto [current, selected] = this->selected(); + QTreeView::commitData(editor); + setSelected(current, selected); + } else { + QTreeView::commitData(editor); + } +} + +QModelIndexList ModListView::selectedIndexes() const +{ + // during drag&drop events, we fake the return value of selectedIndexes() + // to allow drag&drop of a parent into its children + // + // this is only "active" during the actual dragXXXEvent and dropEvent method, + // not during the whole drag&drop event + // + // selectedIndexes() is a protected method from QTreeView which is little + // used so this should not break anything + // + return m_inDragMoveEvent ? QModelIndexList() : QTreeView::selectedIndexes(); +} + +void ModListView::onCustomContextMenuRequested(const QPoint& pos) +{ + try { + QModelIndex contextIdx = indexViewToModel(indexAt(pos)); + + if (!contextIdx.isValid()) { + // no selection + ModListGlobalContextMenu(*m_core, this).exec(viewport()->mapToGlobal(pos)); + } else { + ModListContextMenu(contextIdx, *m_core, m_categories, this) + .exec(viewport()->mapToGlobal(pos)); + } + } catch (const std::exception& e) { + reportError(tr("Exception: ").arg(e.what())); + } catch (...) { + reportError(tr("Unknown exception")); + } +} + +void ModListView::onDoubleClicked(const QModelIndex& index) +{ + if (!index.isValid()) { + return; + } + + if (m_core->modList()->timeElapsedSinceLastChecked() <= + QApplication::doubleClickInterval()) { + // don't interpret double click if we only just checked a mod + return; + } + + bool indexOk = false; + int modIndex = index.data(ModList::IndexRole).toInt(&indexOk); + + if (!indexOk || modIndex < 0 || modIndex >= ModInfo::getNumMods()) { + return; + } + + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); + + const auto modifiers = QApplication::queryKeyboardModifiers(); + if (modifiers.testFlag(Qt::ControlModifier)) { + try { + shell::Explore(modInfo->absolutePath()); + } catch (const std::exception& e) { + reportError(e.what()); + } + } else if (modifiers.testFlag(Qt::ShiftModifier)) { + try { + actions().visitNexusOrWebPage({indexViewToModel(index)}); + } catch (const std::exception& e) { + reportError(e.what()); + } + } else if (hasCollapsibleSeparators() && modInfo->isSeparator()) { + setExpanded(index, !isExpanded(index)); + } else { + try { + auto tab = ModInfoTabIDs::None; + + switch (index.column()) { + case ModList::COL_NOTES: + tab = ModInfoTabIDs::Notes; + break; + case ModList::COL_VERSION: + tab = ModInfoTabIDs::Nexus; + break; + case ModList::COL_MODID: + tab = ModInfoTabIDs::Nexus; + break; + case ModList::COL_GAME: + tab = ModInfoTabIDs::Nexus; + break; + case ModList::COL_CATEGORY: + tab = ModInfoTabIDs::Categories; + break; + case ModList::COL_CONFLICTFLAGS: + tab = ModInfoTabIDs::Conflicts; + break; + } + + actions().displayModInformation(modIndex, tab); + } catch (const std::exception& e) { + reportError(e.what()); + } + } + + // workaround to cancel the editor that might have opened because of + // selection-click + closePersistentEditor(index); +} + +void ModListView::clearOverwriteMarkers() +{ + m_markers.overwrite.clear(); + m_markers.overwritten.clear(); + m_markers.archiveOverwrite.clear(); + m_markers.archiveOverwritten.clear(); + m_markers.archiveLooseOverwrite.clear(); + m_markers.archiveLooseOverwritten.clear(); +} + +void ModListView::setOverwriteMarkers(const QModelIndexList& indexes) +{ + const auto insert = [](auto& dest, const auto& from) { + dest.insert(from.begin(), from.end()); + }; + clearOverwriteMarkers(); + for (auto& idx : indexes) { + auto mIndex = idx.data(ModList::IndexRole); + if (mIndex.isValid()) { + auto info = ModInfo::getByIndex(mIndex.toInt()); + insert(m_markers.overwrite, info->getModOverwrite()); + insert(m_markers.overwritten, info->getModOverwritten()); + insert(m_markers.archiveOverwrite, info->getModArchiveOverwrite()); + insert(m_markers.archiveOverwritten, info->getModArchiveOverwritten()); + insert(m_markers.archiveLooseOverwrite, info->getModArchiveLooseOverwrite()); + insert(m_markers.archiveLooseOverwritten, info->getModArchiveLooseOverwritten()); + } + } + dataChanged(model()->index(0, 0), + model()->index(model()->rowCount() ? model()->rowCount() - 1 : 0, + model()->columnCount() ? model()->columnCount() - 1 : 0)); + verticalScrollBar()->repaint(); +} + +void ModListView::refreshMarkersAndPlugins() +{ + QModelIndexList indexes = selectionModel()->selectedRows(); + + if (m_core->settings().interface().collapsibleSeparatorsHighlightFrom()) { + for (auto& idx : selectionModel()->selectedRows()) { + if (hasCollapsibleSeparators() && model()->hasChildren(idx) && !isExpanded(idx)) { + for (int i = 0; i < model()->rowCount(idx); ++i) { + indexes.append(model()->index(i, idx.column(), idx)); + } + } + } + } + + setOverwriteMarkers(indexes); + + // highlight plugins + std::vector modIndices; + for (auto& idx : indexes) { + modIndices.push_back(idx.data(ModList::IndexRole).toInt()); + } + m_core->pluginList()->highlightPlugins(modIndices, *m_core->directoryStructure()); + ui.pluginList->verticalScrollBar()->repaint(); +} + +void ModListView::setHighlightedMods(const std::set& modNames) +{ + m_markers.highlight.clear(); + for (const auto& modName : modNames) { + const auto index = ModInfo::getIndex(modName); + if (index != UINT_MAX) { + m_markers.highlight.insert(index); + } + } + dataChanged(model()->index(0, 0), + model()->index(model()->rowCount() ? model()->rowCount() - 1 : 0, + model()->columnCount() ? model()->columnCount() - 1 : 0)); + verticalScrollBar()->repaint(); +} + +QColor ModListView::markerColor(const QModelIndex& index) const +{ + unsigned int modIndex = index.data(ModList::IndexRole).toInt(); + bool highlight = m_markers.highlight.find(modIndex) != m_markers.highlight.end(); + bool overwrite = m_markers.overwrite.find(modIndex) != m_markers.overwrite.end(); + bool archiveOverwrite = + m_markers.archiveOverwrite.find(modIndex) != m_markers.archiveOverwrite.end(); + bool archiveLooseOverwrite = m_markers.archiveLooseOverwrite.find(modIndex) != + m_markers.archiveLooseOverwrite.end(); + bool overwritten = + m_markers.overwritten.find(modIndex) != m_markers.overwritten.end(); + bool archiveOverwritten = + m_markers.archiveOverwritten.find(modIndex) != m_markers.archiveOverwritten.end(); + bool archiveLooseOverwritten = m_markers.archiveLooseOverwritten.find(modIndex) != + m_markers.archiveLooseOverwritten.end(); + + if (highlight) { + return Settings::instance().colors().modlistContainsFile(); + } else if (overwritten || archiveLooseOverwritten) { + return Settings::instance().colors().modlistOverwritingLoose(); + } else if (overwrite || archiveLooseOverwrite) { + return Settings::instance().colors().modlistOverwrittenLoose(); + } else if (archiveOverwritten) { + return Settings::instance().colors().modlistOverwritingArchive(); + } else if (archiveOverwrite) { + return Settings::instance().colors().modlistOverwrittenArchive(); + } + + // collapsed separator + auto rowIndex = index.sibling(index.row(), 0); + if (hasCollapsibleSeparators() && + m_core->settings().interface().collapsibleSeparatorsHighlightTo() && + model()->hasChildren(rowIndex) && !isExpanded(rowIndex)) { + + std::vector colors; + for (int i = 0; i < model()->rowCount(rowIndex); ++i) { + auto childColor = markerColor(model()->index(i, index.column(), rowIndex)); + if (childColor.isValid()) { + colors.push_back(childColor); + } + } + + if (colors.empty()) { + return QColor(); + } + + int r = 0, g = 0, b = 0, a = 0; + for (auto& color : colors) { + r += color.red(); + g += color.green(); + b += color.blue(); + a += color.alpha(); + } + + return QColor(r / colors.size(), g / colors.size(), b / colors.size(), + a / colors.size()); + } + + return QColor(); +} + +std::vector ModListView::modFlags(const QModelIndex& index, + bool* forceCompact) const +{ + ModInfo::Ptr info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + + auto flags = info->getFlags(); + bool compact = false; + if (info->isSeparator() && hasCollapsibleSeparators() && + m_core->settings().interface().collapsibleSeparatorsIcons(ModList::COL_FLAGS) && + !isExpanded(index.sibling(index.row(), 0))) { + + // combine the child conflicts + std::set eFlags(flags.begin(), flags.end()); + for (int i = 0; i < model()->rowCount(index); ++i) { + auto cIndex = + model()->index(i, index.column(), index).data(ModList::IndexRole).toInt(); + auto cFlags = ModInfo::getByIndex(cIndex)->getFlags(); + eFlags.insert(cFlags.begin(), cFlags.end()); + } + flags = {eFlags.begin(), eFlags.end()}; + + // force compact because there can be a lots of flags here + compact = true; + } + + if (forceCompact) { + *forceCompact = true; + } + + return flags; +} + +std::vector ModListView::conflictFlags(const QModelIndex& index, + bool* forceCompact) const +{ + ModInfo::Ptr info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + + auto flags = info->getConflictFlags(); + bool compact = false; + if (info->isSeparator() && hasCollapsibleSeparators() && + m_core->settings().interface().collapsibleSeparatorsIcons( + ModList::COL_CONFLICTFLAGS) && + !isExpanded(index.sibling(index.row(), 0))) { + + // combine the child conflicts + std::set eFlags(flags.begin(), flags.end()); + for (int i = 0; i < model()->rowCount(index); ++i) { + auto cIndex = + model()->index(i, index.column(), index).data(ModList::IndexRole).toInt(); + auto cFlags = ModInfo::getByIndex(cIndex)->getConflictFlags(); + eFlags.insert(cFlags.begin(), cFlags.end()); + } + flags = {eFlags.begin(), eFlags.end()}; + + // force compact because there can be a lots of flags here + compact = true; + } + + if (forceCompact) { + *forceCompact = true; + } + + return flags; +} + +std::set ModListView::contents(const QModelIndex& index, + bool* includeChildren) const +{ + auto modIndex = index.data(ModList::IndexRole); + if (!modIndex.isValid()) { + return {}; + } + ModInfo::Ptr info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + auto contents = info->getContents(); + bool children = false; + + if (info->isSeparator() && hasCollapsibleSeparators() && + m_core->settings().interface().collapsibleSeparatorsIcons(ModList::COL_CONTENT) && + !isExpanded(index.sibling(index.row(), 0))) { + + // combine the child contents + std::set eContents(contents.begin(), contents.end()); + for (int i = 0; i < model()->rowCount(index); ++i) { + auto cIndex = + model()->index(i, index.column(), index).data(ModList::IndexRole).toInt(); + auto cContents = ModInfo::getByIndex(cIndex)->getContents(); + eContents.insert(cContents.begin(), cContents.end()); + } + contents = {eContents.begin(), eContents.end()}; + children = true; + } + + if (includeChildren) { + *includeChildren = children; + } + + return contents; +} + +QList ModListView::contentsIcons(const QModelIndex& index, + bool* forceCompact) const +{ + auto contents = this->contents(index, forceCompact); + QList result; + m_core->modDataContents().forEachContentInOrOut( + contents, + [&result](auto const& content) { + result.append(content.icon()); + }, + [&result](auto const&) { + result.append(QString()); + }); + return result; +} + +QString ModListView::contentsTooltip(const QModelIndex& index) const +{ + auto contents = this->contents(index, nullptr); + if (contents.empty()) { + return {}; + } + QString result(""); + m_core->modDataContents().forEachContentIn(contents, [&result](auto const& content) { + result.append(QString("" + "") + .arg(content.icon()) + .arg(content.name())); + }); + result.append("
    %2
    "); + return result; +} + +void ModListView::onFiltersCriteria( + const std::vector& criteria) +{ + setFilterCriteria(criteria); + + QString label = "?"; + + if (criteria.empty()) { + label = ""; + } else if (criteria.size() == 1) { + const auto& c = criteria[0]; + + if (c.type == ModListSortProxy::TypeContent) { + const auto* content = m_core->modDataContents().findById(c.id); + label = content ? content->name() : QString(); + } else { + label = m_categories->getCategoryNameByID(c.id); + } + + if (label.isEmpty()) { + log::error("category {}:{} not found", c.type, c.id); + } + } else { + label = tr(""); + } + + ui.currentCategory->setText(label); +} + +void ModListView::dragEnterEvent(QDragEnterEvent* event) +{ + // this event is used by the modlist to check if we are draggin + // to a mod (local files) or to a priority (mods, downloads, external + // files) + emit dragEntered(event->mimeData()); + QTreeView::dragEnterEvent(event); + + // there is no drop event for invalid data since canDropMimeData + // returns false, so we notify user on drag enter + ModListDropInfo dropInfo(event->mimeData(), *m_core); + + if (dropInfo.isValid() && !dropInfo.isLocalFileDrop() && + sortColumn() != ModList::COL_PRIORITY) { + log::warn("Drag&Drop is only supported when sorting by priority."); + } +} + +void ModListView::dragMoveEvent(QDragMoveEvent* event) +{ + // this replace the openTimer from QTreeView to prevent + // auto-collapse of items + if (autoExpandDelay() >= 0) { + m_openTimer.start(autoExpandDelay(), this); + } + + // see selectedIndexes() + m_inDragMoveEvent = true; + QAbstractItemView::dragMoveEvent(event); + m_inDragMoveEvent = false; +} + +void ModListView::dropEvent(QDropEvent* event) +{ + // from Qt source + QModelIndex index; + if (viewport()->rect().contains(event->pos())) { + index = indexAt(event->pos()); + if (!index.isValid() || !visualRect(index).contains(event->pos())) + index = QModelIndex(); + } + + // this event is used by the byPriorityProxy to know if allow + // dropping mod between a separator and its first mod (there + // is no way to deduce this except using dropIndicatorPosition()) + emit dropEntered(event->mimeData(), isExpanded(index), + static_cast(dropIndicatorPosition())); + + // see selectedIndexes() + m_inDragMoveEvent = true; + QTreeView::dropEvent(event); + m_inDragMoveEvent = false; +} + +void ModListView::timerEvent(QTimerEvent* event) +{ + // prevent auto-collapse, see dragMoveEvent() + if (event->timerId() == m_openTimer.timerId()) { + QPoint pos = viewport()->mapFromGlobal(QCursor::pos()); + if (state() == QAbstractItemView::DraggingState && + viewport()->rect().contains(pos)) { + QModelIndex index = indexAt(pos); + setExpanded(index, !m_core->settings().interface().autoCollapseOnHover() || + !isExpanded(index)); + } + m_openTimer.stop(); + } else { + QTreeView::timerEvent(event); + } +} + +void ModListView::mousePressEvent(QMouseEvent* event) +{ + // allow alt+click to select all mods inside a separator + // when using collapsible separators + // + // similar code is also present in mouseReleaseEvent to + // avoid missing events + + // disable edit if Alt is pressed + auto triggers = editTriggers(); + if (event->modifiers() & Qt::AltModifier) { + setEditTriggers(NoEditTriggers); + } + + // we call the parent class first so that we can use the actual + // selection state of the item after + QTreeView::mousePressEvent(event); + + // restore triggers + setEditTriggers(triggers); + + const auto index = indexAt(event->pos()); + + if (event->isAccepted() && hasCollapsibleSeparators() && index.isValid() && + model()->hasChildren(indexAt(event->pos())) && + (event->modifiers() & Qt::AltModifier)) { + + const auto flag = selectionModel()->isSelected(index) + ? QItemSelectionModel::Select + : QItemSelectionModel::Deselect; + const QItemSelection selection( + model()->index(0, index.column(), index), + model()->index(model()->rowCount(index) - 1, index.column(), index)); + selectionModel()->select(selection, flag | QItemSelectionModel::Rows); + } +} + +void ModListView::mouseReleaseEvent(QMouseEvent* event) +{ + // this is a duplicate of mousePressEvent because for some reason + // the selection is not always triggered in mousePressEvent and only + // doing it here create a small lag between the selection of the + // separator and the children + + // disable edit if Alt is pressed + auto triggers = editTriggers(); + if (event->modifiers() & Qt::AltModifier) { + setEditTriggers(NoEditTriggers); + } + + // we call the parent class first so that we can use the actual + // selection state of the item after + QTreeView::mouseReleaseEvent(event); + + const auto index = indexAt(event->pos()); + + // restore triggers + setEditTriggers(triggers); + + if (event->isAccepted() && hasCollapsibleSeparators() && index.isValid() && + model()->hasChildren(indexAt(event->pos())) && + (event->modifiers() & Qt::AltModifier)) { + + const auto flag = selectionModel()->isSelected(index) + ? QItemSelectionModel::Select + : QItemSelectionModel::Deselect; + const QItemSelection selection( + model()->index(0, index.column(), index), + model()->index(model()->rowCount(index) - 1, index.column(), index)); + selectionModel()->select(selection, flag | QItemSelectionModel::Rows); + } +} + +bool ModListView::event(QEvent* event) +{ + if (event->type() == QEvent::KeyPress && m_core->currentProfile() && + selectionModel()->hasSelection()) { + QKeyEvent* keyEvent = static_cast(event); + + auto index = selectionModel()->currentIndex(); + + if (keyEvent->modifiers() == Qt::ControlModifier) { + // ctrl+enter open explorer + if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) { + if (selectionModel()->selectedRows().count() == 1) { + m_actions->openExplorer({indexViewToModel(index)}); + return true; + } + } + // ctrl+up/down move selection + else if (sortColumn() == ModList::COL_PRIORITY && + (keyEvent->key() == Qt::Key_Up || keyEvent->key() == Qt::Key_Down)) { + return moveSelection(keyEvent->key()); + } + } else if (keyEvent->modifiers() == Qt::ShiftModifier) { + // shift+enter expand + if ((keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) && + selectionModel()->selectedRows().count() == 1) { + if (model()->hasChildren(index)) { + setExpanded(index, !isExpanded(index)); + } else if (index.parent().isValid()) { + setExpanded(index.parent(), false); + selectionModel()->select(index.parent(), QItemSelectionModel::SelectCurrent | + QItemSelectionModel::Rows); + setCurrentIndex(index.parent()); + } + } + } else { + if (keyEvent->key() == Qt::Key_Delete) { + return removeSelection(); + } else if (keyEvent->key() == Qt::Key_Space) { + return toggleSelectionState(); + } + } + return QTreeView::event(event); + } + return QTreeView::event(event); +} diff --git a/src/src/modlistview.h b/src/src/modlistview.h new file mode 100644 index 0000000..c3ffac6 --- /dev/null +++ b/src/src/modlistview.h @@ -0,0 +1,352 @@ +#ifndef MODLISTVIEW_H +#define MODLISTVIEW_H + +#include +#include +#include + +#include +#include +#include +#include + +#include "modlistsortproxy.h" +#include "qtgroupingproxy.h" +#include "viewmarkingscrollbar.h" + +namespace Ui +{ +class MainWindow; +} + +class CategoryFactory; +class FilterList; +class OrganizerCore; +class MainWindow; +class Profile; +class ModListByPriorityProxy; +class ModListViewActions; +class PluginListView; + +class ModListView : public QTreeView +{ + Q_OBJECT + +public: + // this is a public version of DropIndicatorPosition + enum DropPosition + { + OnItem = DropIndicatorPosition::OnItem, + AboveItem = DropIndicatorPosition::AboveItem, + BelowItem = DropIndicatorPosition::BelowItem, + OnViewport = DropIndicatorPosition::OnViewport + }; + + // indiucate the groupby mode + enum class GroupByMode + { + NONE, + SEPARATOR, + CATEGORY, + NEXUS_ID + }; + +public: + explicit ModListView(QWidget* parent = 0); + + void setup(OrganizerCore& core, CategoryFactory& factory, MainWindow* mw, + Ui::MainWindow* mwui); + + // restore/save the state between session + // + void restoreState(const Settings& s); + void saveState(Settings& s) const; + + // check if collapsible separators are currently used + // + bool hasCollapsibleSeparators() const; + + // the column/order by which the mod list is currently sorted + // + int sortColumn() const; + Qt::SortOrder sortOrder() const; + + // check if a filter is currently active + // + bool isFilterActive() const; + + // the current group mode + // + GroupByMode groupByMode() const; + + // retrieve the actions from the view + // + ModListViewActions& actions() const; + + // retrieve the next/previous mod in the current view, the given index + // should be a mod index (not a model row) + // + std::optional nextMod(unsigned int index) const; + std::optional prevMod(unsigned int index) const; + + // check if the given mod is visible, i.e. not filtered (returns true + // for collapsed mods) + // + bool isModVisible(unsigned int index) const; + bool isModVisible(ModInfo::Ptr mod) const; + + // focus the view, select the given index and scroll to it + // + void scrollToAndSelect(const QModelIndex& index); + void scrollToAndSelect(const QModelIndexList& indexes, + const QModelIndex& current = QModelIndex()); + + // refresh the view (to call when settings have been changed) + // + void refresh(); + +signals: + + // emitted for dragEnter events + // + void dragEntered(const QMimeData* mimeData); + + // emitted for dropEnter events, the boolean indicates if the drop target + // is expanded and the position of the indicator + // + void dropEntered(const QMimeData* mimeData, bool dropExpanded, DropPosition position); + +public slots: + + // invalidate (refresh) the filter (similar to a layout changed event) + // + void invalidateFilter(); + + // set the filter criteria/options for mods + // + void setFilterCriteria(const std::vector& criteria); + void setFilterOptions(ModListSortProxy::FilterMode mode, + ModListSortProxy::SeparatorsMode sep); + + // update the mod counter + // + void updateModCount(); + + // refresh the filters + // + void refreshFilters(); + + // set highligth markers + // + void setHighlightedMods(const std::set& modNames); + +protected: + // map from/to the view indexes to the model + // + QModelIndex indexModelToView(const QModelIndex& index) const; + QModelIndexList indexModelToView(const QModelIndexList& index) const; + QModelIndex indexViewToModel(const QModelIndex& index) const; + QModelIndexList indexViewToModel(const QModelIndexList& index) const; + + // returns the next/previous index of the given index + // + QModelIndex nextIndex(const QModelIndex& index) const; + QModelIndex prevIndex(const QModelIndex& index) const; + + // re-implemented to fake the return value to allow drag-and-drop on + // itself for separators + // + QModelIndexList selectedIndexes() const; + + // drop from external folder + // + void onExternalFolderDropped(const QUrl& url, int priority); + + // method to react to various key events + // + bool moveSelection(int key); + bool removeSelection(); + bool toggleSelectionState(); + + // re-implemented to fix indentation with collapsible separators + // + QRect visualRect(const QModelIndex& index) const override; + void drawBranches(QPainter* painter, const QRect& rect, + const QModelIndex& index) const override; + + void timerEvent(QTimerEvent* event) override; + void dragEnterEvent(QDragEnterEvent* event) override; + void dragMoveEvent(QDragMoveEvent* event) override; + void dropEvent(QDropEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + bool event(QEvent* event) override; + +protected slots: + + void onCustomContextMenuRequested(const QPoint& pos); + void onDoubleClicked(const QModelIndex& index); + void onFiltersCriteria(const std::vector& filters); + void onProfileChanged(Profile* oldProfile, Profile* newProfile); + + void commitData(QWidget* editor) override; + +private: // friend classes + friend class ModConflictIconDelegate; + friend class ModContentIconDelegate; + friend class ModFlagIconDelegate; + friend class ModListContextMenu; + friend class ModListStyledItemDelegate; + friend class ModListViewActions; + friend class ModListViewMarkingScrollBar; + +private: // private structures + struct ModListViewUi + { + // the group by combo box + QComboBox* groupBy; + + // the mod counter + QLCDNumber* counter; + + // filters related + QLineEdit* filter; + QLabel* currentCategory; + QPushButton* clearFilters; + QComboBox* filterSeparators; + + // the plugin list (for highlights) + PluginListView* pluginList; + }; + + struct MarkerInfos + { + // conflicts + std::set overwrite; + std::set overwritten; + std::set archiveOverwrite; + std::set archiveOverwritten; + std::set archiveLooseOverwrite; + std::set archiveLooseOverwritten; + + // selected plugins + std::set highlight; + }; + + struct ModCounters + { + int active = 0; + int backup = 0; + int foreign = 0; + int separator = 0; + int regular = 0; + + struct + { + int active = 0; + int backup = 0; + int foreign = 0; + int separator = 0; + int regular = 0; + } visible; + }; + + // index in the groupby combo + // + enum GroupBy + { + NONE = 0, + CATEGORY = 1, + NEXUS_ID = 2 + }; + +private: // private functions + void onModPrioritiesChanged(const QModelIndexList& indices); + void onModInstalled(const QString& modName); + void onModFilterActive(bool filterActive); + + // refresh the overwrite markers and the highlighted plugins from + // the current selection + // + void refreshMarkersAndPlugins(); + + // clear overwrite markers (without repainting) + // + void clearOverwriteMarkers(); + + // set overwrite markers from the mod in the given list and repaint (if the list + // is empty, clear overwrite and repaint) + // + void setOverwriteMarkers(const QModelIndexList& indexes); + + // retrieve the marker color for the given index + // + QColor markerColor(const QModelIndex& index) const; + + // retrieve the mod flags for the given index + // + std::vector modFlags(const QModelIndex& index, + bool* forceCompact = nullptr) const; + + // retrieve the conflicts flags for the given index + // + std::vector conflictFlags(const QModelIndex& index, + bool* forceCompact = nullptr) const; + + // retrieve the content icons and tooltip for the given index + // + std::set contents(const QModelIndex& index, bool* includeChildren) const; + QList contentsIcons(const QModelIndex& index, + bool* forceCompact = nullptr) const; + QString contentsTooltip(const QModelIndex& index) const; + + // compute the counters for mods according to the current filter + // + ModCounters counters() const; + + // get/set the selected items on the view, this method return/take indices + // from the mod list model, not the view, so it's safe to restore + // + std::pair selected() const; + void setSelected(const QModelIndex& current, const QModelIndexList& selected); + + // refresh stored expanded items for the current intermediate proxy + // + void refreshExpandedItems(); + + // refresh the group-by proxy, if the index is -1 will refresh the + // current one (e.g. when changing the sort column) + // + void updateGroupByProxy(); + +public: // member variables + OrganizerCore* m_core; + std::unique_ptr m_filters; + CategoryFactory* m_categories; + ModListViewUi ui; + ModListViewActions* m_actions; + + ModListSortProxy* m_sortProxy; + ModListByPriorityProxy* m_byPriorityProxy; + QtGroupingProxy* m_byCategoryProxy; + QtGroupingProxy* m_byNexusIdProxy; + + // marker used to avoid calling refreshing markers to many + // time in a row + QTimer m_refreshMarkersTimer; + + // maintain collapsed items for each proxy to avoid + // losing them on model reset + std::map> m_collapsed; + + MarkerInfos m_markers; + ViewMarkingScrollBar* m_scrollbar; + + bool m_inDragMoveEvent = false; + + // replace the auto-expand timer from QTreeView to avoid + // auto-collapsing + QBasicTimer m_openTimer; +}; + +#endif // MODLISTVIEW_H diff --git a/src/src/modlistviewactions.cpp b/src/src/modlistviewactions.cpp new file mode 100644 index 0000000..6b2daad --- /dev/null +++ b/src/src/modlistviewactions.cpp @@ -0,0 +1,1496 @@ +#include "modlistviewactions.h" + +#include +#include +#include +#include + +#include "filesystemutilities.h" +#include +#include + +#include "categories.h" +#include "csvbuilder.h" +#include "directoryrefresher.h" +#include "downloadmanager.h" +#include "filedialogmemory.h" +#include "filterlist.h" +#include "listdialog.h" +#include "messagedialog.h" +#include "modelutils.h" +#include "modinfodialog.h" +#include "modlist.h" +#include "modlistview.h" +#include "nexusinterface.h" +#include "nxmaccessmanager.h" +#include "organizercore.h" +#include "overwriteinfodialog.h" +#include "pluginlistview.h" +#include "savetextasdialog.h" +#include "shared/directoryentry.h" +#include "shared/fileregister.h" +#include "shared/filesorigin.h" + +using namespace MOBase; +using namespace MOShared; + +ModListViewActions::ModListViewActions(OrganizerCore& core, FilterList& filters, + CategoryFactory& categoryFactory, + ModListView* view, PluginListView* pluginView, + QObject* nxmReceiver) + : QObject(view), m_core(core), m_filters(filters), m_categories(categoryFactory), + m_view(view), m_pluginView(pluginView), m_parent(view->topLevelWidget()), + m_receiver(nxmReceiver) +{} + +int ModListViewActions::findInstallPriority(const QModelIndex& index) const +{ + int newPriority = -1; + if (index.isValid() && index.data(ModList::IndexRole).isValid() && + m_view->sortColumn() == ModList::COL_PRIORITY) { + auto mIndex = index.data(ModList::IndexRole).toInt(); + auto info = ModInfo::getByIndex(mIndex); + newPriority = m_core.currentProfile()->getModPriority(mIndex); + if (info->isSeparator()) { + + auto isSeparator = [](const auto& p) { + return ModInfo::getByIndex(p.second)->isSeparator(); + }; + + auto& ibp = m_core.currentProfile()->getAllIndexesByPriority(); + + // start right after/before the current priority and look for the next + // separator + if (m_view->sortOrder() == Qt::AscendingOrder) { + auto it = std::find_if(ibp.find(newPriority + 1), ibp.end(), isSeparator); + if (it != ibp.end()) { + newPriority = it->first; + } else { + newPriority = -1; + } + } else { + auto it = std::find_if(std::reverse_iterator{ibp.find(newPriority - 1)}, + ibp.rend(), isSeparator); + if (it != ibp.rend()) { + newPriority = it->first + 1; + } else { + // create "before" priority 0, i.e. at the end in descending priority. + newPriority = 0; + } + } + } + } + + return newPriority; +} + +void ModListViewActions::installMod(const QString& archivePath, + const QModelIndex& index) const +{ + try { + QString path = archivePath; + if (path.isEmpty()) { + QStringList extensions = m_core.installationManager()->getSupportedExtensions(); + for (auto iter = extensions.begin(); iter != extensions.end(); ++iter) { + *iter = "*." + *iter; + } + + path = FileDialogMemory::getOpenFileName( + "installMod", m_parent, tr("Choose Mod"), QString(), + tr("Mod Archive").append(QString(" (%1)").arg(extensions.join(" ")))); + } + + if (path.isEmpty()) { + return; + } else { + m_core.installMod(path, findInstallPriority(index), false, nullptr, QString()); + } + } catch (const std::exception& e) { + reportError(e.what()); + } +} + +void ModListViewActions::createEmptyMod(const QModelIndex& index) const +{ + GuessedValue name; + name.setFilter(&fixDirectoryName); + + while (name->isEmpty()) { + bool ok; + name.update(QInputDialog::getText(m_parent, tr("Create Mod..."), + tr("This will create an empty mod.\n" + "Please enter a name:"), + QLineEdit::Normal, "", &ok), + GUESS_USER); + if (!ok) { + return; + } + } + + if (m_core.modList()->getMod(name) != nullptr) { + reportError(tr("A mod with this name already exists")); + return; + } + + if (m_core.createMod(name) == nullptr) { + return; + } + + // find the priority before refresh() otherwise the index might not be valid + const int newPriority = findInstallPriority(index); + m_core.refresh(); + + const auto mIndex = ModInfo::getIndex(name); + if (newPriority >= 0) { + m_core.modList()->changeModPriority(mIndex, newPriority); + } + + m_view->scrollToAndSelect( + m_view->indexModelToView(m_core.modList()->index(mIndex, 0))); +} + +void ModListViewActions::createSeparator(const QModelIndex& index) const +{ + GuessedValue name; + name.setFilter(&fixDirectoryName); + while (name->isEmpty()) { + bool ok; + name.update(QInputDialog::getText(m_parent, tr("Create Separator..."), + tr("This will create a new separator.\n" + "Please enter a name:"), + QLineEdit::Normal, "", &ok), + GUESS_USER); + if (!ok) { + return; + } + } + if (m_core.modList()->getMod(name) != nullptr) { + reportError(tr("A separator with this name already exists")); + return; + } + name->append("_separator"); + if (m_core.modList()->getMod(name) != nullptr) { + return; + } + + int newPriority = -1; + if (index.isValid() && m_view->sortColumn() == ModList::COL_PRIORITY) { + newPriority = + m_core.currentProfile()->getModPriority(index.data(ModList::IndexRole).toInt()); + + // descending order, we need to fix the priority + if (m_view->sortOrder() == Qt::DescendingOrder) { + newPriority++; + } + } + + if (m_core.createMod(name) == nullptr) { + return; + } + + m_core.refresh(); + + const auto mIndex = ModInfo::getIndex(name); + if (newPriority >= 0) { + m_core.modList()->changeModPriority(mIndex, newPriority); + } + + if (auto c = m_core.settings().colors().previousSeparatorColor()) { + ModInfo::getByIndex(mIndex)->setColor(*c); + } + + m_view->scrollToAndSelect( + m_view->indexModelToView(m_core.modList()->index(mIndex, 0))); +} + +void ModListViewActions::setAllMatchingModsEnabled(bool enabled) const +{ + // number of mods to enable / disable + const auto counters = m_view->counters(); + const auto count = enabled ? counters.visible.regular - counters.visible.active + : counters.visible.active; + + // retrieve visible mods from the model view + const auto allIndex = m_view->indexViewToModel(flatIndex(m_view->model())); + const QString message = + enabled ? tr("Really enable %1 mod(s)?") : tr("Really disable %1 mod(s)?"); + if (QMessageBox::question(m_parent, tr("Confirm"), message.arg(count), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_core.modList()->setActive(allIndex, enabled); + } +} + +void ModListViewActions::checkModsForUpdates() const +{ + bool checkingModsForUpdate = false; + if (NexusInterface::instance().getAccessManager()->validated()) { + checkingModsForUpdate = + ModInfo::checkAllForUpdate(&m_core.pluginContainer(), m_receiver); + NexusInterface::instance().requestEndorsementInfo(m_receiver, QVariant(), + QString()); + NexusInterface::instance().requestTrackingInfo(m_receiver, QVariant(), QString()); + } else { + QString apiKey; + if (GlobalSettings::nexusApiKey(apiKey)) { + m_core.doAfterLogin([=]() { + checkModsForUpdates(); + }); + NexusInterface::instance().getAccessManager()->apiCheck(apiKey); + } else { + log::warn("{}", tr("You are not currently authenticated with Nexus. Please do so " + "under Settings -> Nexus.")); + } + } + + bool updatesAvailable = false; + for (auto mod : m_core.modList()->allMods()) { + ModInfo::Ptr modInfo = ModInfo::getByName(mod); + if (modInfo->updateAvailable()) { + updatesAvailable = true; + break; + } + } + + if (updatesAvailable || checkingModsForUpdate) { + m_view->setFilterCriteria( + {{ModListSortProxy::TypeSpecial, CategoryFactory::UpdateAvailable, false}}); + + m_filters.setSelection( + {{ModListSortProxy::TypeSpecial, CategoryFactory::UpdateAvailable, false}}); + } +} + +void ModListViewActions::assignCategories() const +{ + if (!GlobalSettings::hideAssignCategoriesQuestion()) { + QMessageBox warning; + warning.setWindowTitle(tr("Are you sure?")); + warning.setText( + tr("This action will remove any existing categories on any mod with a valid " + "Nexus category mapping. Are you certain you want to proceed?")); + warning.setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); + QCheckBox dontShow(tr("&Don't show this again")); + warning.setCheckBox(&dontShow); + auto result = warning.exec(); + if (dontShow.isChecked()) + GlobalSettings::setHideAssignCategoriesQuestion(true); + if (result == QMessageBox::Cancel) + return; + } + for (auto mod : m_core.modList()->allMods()) { + ModInfo::Ptr modInfo = ModInfo::getByName(mod); + if (modInfo->isSeparator()) + continue; + int nexusCategory = modInfo->getNexusCategory(); + if (!nexusCategory) { + QSettings downloadMeta(m_core.downloadsPath() + "/" + + modInfo->installationFile() + ".meta", + QSettings::IniFormat); + if (downloadMeta.contains("category")) { + nexusCategory = downloadMeta.value("category", 0).toInt(); + } + } + int newCategory = CategoryFactory::instance().resolveNexusID(nexusCategory); + if (newCategory != 0) { + for (auto category : modInfo->categories()) { + modInfo->removeCategory(category); + } + } + modInfo->setCategory(CategoryFactory::instance().getCategoryID(newCategory), true); + } +} + +void ModListViewActions::checkModsForUpdates( + std::multimap const& IDs) const +{ + if (m_core.settings().network().offlineMode()) { + return; + } + + if (NexusInterface::instance().getAccessManager()->validated()) { + ModInfo::manualUpdateCheck(m_receiver, IDs); + } else { + QString apiKey; + if (GlobalSettings::nexusApiKey(apiKey)) { + m_core.doAfterLogin([=]() { + checkModsForUpdates(IDs); + }); + NexusInterface::instance().getAccessManager()->apiCheck(apiKey); + } else + log::warn("{}", tr("You are not currently authenticated with Nexus. Please do so " + "under Settings -> Nexus.")); + } +} + +void ModListViewActions::checkModsForUpdates(const QModelIndexList& indices) const +{ + std::multimap ids; + for (auto& idx : indices) { + ModInfo::Ptr info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + ids.insert(std::make_pair(info->gameName(), info->nexusId())); + } + checkModsForUpdates(ids); +} + +void ModListViewActions::exportModListCSV() const +{ + QDialog selection(m_parent); + QGridLayout* grid = new QGridLayout; + selection.setWindowTitle(tr("Export to csv")); + + QLabel* csvDescription = new QLabel(); + csvDescription->setText( + tr("CSV (Comma Separated Values) is a format that can be imported in programs " + "like Excel to create a spreadsheet.\nYou can also use online editors and " + "converters instead.")); + grid->addWidget(csvDescription); + + QGroupBox* groupBoxRows = new QGroupBox(tr("Select what mods you want export:")); + QRadioButton* all = new QRadioButton(tr("All installed mods")); + QRadioButton* active = + new QRadioButton(tr("Only active (checked) mods from your current profile")); + QRadioButton* visible = + new QRadioButton(tr("All currently visible mods in the mod list")); + + QVBoxLayout* vbox = new QVBoxLayout; + vbox->addWidget(all); + vbox->addWidget(active); + vbox->addWidget(visible); + vbox->addStretch(1); + groupBoxRows->setLayout(vbox); + + grid->addWidget(groupBoxRows); + + QButtonGroup* buttonGroupRows = new QButtonGroup(); + buttonGroupRows->addButton(all, 0); + buttonGroupRows->addButton(active, 1); + buttonGroupRows->addButton(visible, 2); + buttonGroupRows->button(0)->setChecked(true); + + QGroupBox* groupBoxColumns = new QGroupBox(tr("Choose what Columns to export:")); + groupBoxColumns->setFlat(true); + + QCheckBox* mod_Priority = new QCheckBox(tr("Mod_Priority")); + mod_Priority->setChecked(true); + QCheckBox* mod_Name = new QCheckBox(tr("Mod_Name")); + mod_Name->setChecked(true); + QCheckBox* mod_Note = new QCheckBox(tr("Notes_column")); + QCheckBox* mod_Status = new QCheckBox(tr("Mod_Status")); + mod_Status->setChecked(true); + QCheckBox* primary_Category = new QCheckBox(tr("Primary_Category")); + QCheckBox* mod_Author = new QCheckBox(tr("Mod_Author")); + QCheckBox* mod_Uploader = new QCheckBox(tr("Mod_Uploader")); + QCheckBox* nexus_ID = new QCheckBox(tr("Nexus_ID")); + QCheckBox* mod_Nexus_URL = new QCheckBox(tr("Mod_Nexus_URL")); + QCheckBox* mod_Uploader_URL = new QCheckBox(tr("Mod_Uploader_URL")); + QCheckBox* mod_Version = new QCheckBox(tr("Mod_Version")); + QCheckBox* install_Date = new QCheckBox(tr("Install_Date")); + QCheckBox* download_File_Name = new QCheckBox(tr("Download_File_Name")); + + QVBoxLayout* vbox1 = new QVBoxLayout; + vbox1->addWidget(mod_Priority); + vbox1->addWidget(mod_Name); + vbox1->addWidget(mod_Status); + vbox1->addWidget(mod_Note); + vbox1->addWidget(primary_Category); + vbox1->addWidget(mod_Author); + vbox1->addWidget(mod_Uploader); + vbox1->addWidget(nexus_ID); + vbox1->addWidget(mod_Nexus_URL); + vbox1->addWidget(mod_Uploader_URL); + vbox1->addWidget(mod_Version); + vbox1->addWidget(install_Date); + vbox1->addWidget(download_File_Name); + groupBoxColumns->setLayout(vbox1); + + grid->addWidget(groupBoxColumns); + + QPushButton* ok = new QPushButton("Ok"); + QPushButton* cancel = new QPushButton("Cancel"); + QDialogButtonBox* buttons = + new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + + connect(buttons, SIGNAL(accepted()), &selection, SLOT(accept())); + connect(buttons, SIGNAL(rejected()), &selection, SLOT(reject())); + + grid->addWidget(buttons); + + selection.setLayout(grid); + + if (selection.exec() == QDialog::Accepted) { + + unsigned int numMods = ModInfo::getNumMods(); + int selectedRowID = buttonGroupRows->checkedId(); + + try { + QBuffer buffer; + buffer.open(QIODevice::ReadWrite); + CSVBuilder builder(&buffer); + builder.setEscapeMode(CSVBuilder::TYPE_STRING, CSVBuilder::QUOTE_ALWAYS); + std::vector> fields; + if (mod_Priority->isChecked()) + fields.push_back( + std::make_pair(QString("#Mod_Priority"), CSVBuilder::TYPE_STRING)); + if (mod_Status->isChecked()) + fields.push_back( + std::make_pair(QString("#Mod_Status"), CSVBuilder::TYPE_STRING)); + if (mod_Name->isChecked()) + fields.push_back(std::make_pair(QString("#Mod_Name"), CSVBuilder::TYPE_STRING)); + if (mod_Note->isChecked()) + fields.push_back(std::make_pair(QString("#Note"), CSVBuilder::TYPE_STRING)); + if (primary_Category->isChecked()) + fields.push_back( + std::make_pair(QString("#Primary_Category"), CSVBuilder::TYPE_STRING)); + if (mod_Author->isChecked()) + fields.push_back( + std::make_pair(QString("#Mod_Author"), CSVBuilder::TYPE_STRING)); + if (mod_Uploader->isChecked()) + fields.push_back( + std::make_pair(QString("#Mod_Uploader"), CSVBuilder::TYPE_STRING)); + if (nexus_ID->isChecked()) + fields.push_back( + std::make_pair(QString("#Nexus_ID"), CSVBuilder::TYPE_INTEGER)); + if (mod_Nexus_URL->isChecked()) + fields.push_back( + std::make_pair(QString("#Mod_Nexus_URL"), CSVBuilder::TYPE_STRING)); + if (mod_Uploader_URL->isChecked()) + fields.push_back( + std::make_pair(QString("#Mod_Uploader_URL"), CSVBuilder::TYPE_STRING)); + if (mod_Version->isChecked()) + fields.push_back( + std::make_pair(QString("#Mod_Version"), CSVBuilder::TYPE_STRING)); + if (install_Date->isChecked()) + fields.push_back( + std::make_pair(QString("#Install_Date"), CSVBuilder::TYPE_STRING)); + if (download_File_Name->isChecked()) + fields.push_back( + std::make_pair(QString("#Download_File_Name"), CSVBuilder::TYPE_STRING)); + + builder.setFields(fields); + + builder.writeHeader(); + + auto indexesByPriority = m_core.currentProfile()->getAllIndexesByPriority(); + for (auto& iter : indexesByPriority) { + ModInfo::Ptr info = ModInfo::getByIndex(iter.second); + bool enabled = m_core.currentProfile()->modEnabled(iter.second); + if ((selectedRowID == 1) && !enabled) { + continue; + } else if ((selectedRowID == 2) && !m_view->isModVisible(iter.second)) { + continue; + } + std::vector flags = info->getFlags(); + if ((std::find(flags.begin(), flags.end(), ModInfo::FLAG_OVERWRITE) == + flags.end()) && + (std::find(flags.begin(), flags.end(), ModInfo::FLAG_BACKUP) == + flags.end())) { + if (mod_Priority->isChecked()) + builder.setRowField("#Mod_Priority", + QString("%1").arg(iter.first, 4, 10, QChar('0'))); + if (mod_Status->isChecked()) + builder.setRowField("#Mod_Status", (enabled) ? "+" : "-"); + if (mod_Name->isChecked()) + builder.setRowField("#Mod_Name", info->name()); + if (mod_Note->isChecked()) + builder.setRowField("#Note", + QString("%1").arg(info->comments().remove(','))); + if (primary_Category->isChecked()) + builder.setRowField( + "#Primary_Category", + (m_categories.categoryExists(info->primaryCategory())) + ? m_categories.getCategoryNameByID(info->primaryCategory()) + : ""); + if (mod_Author->isChecked()) + builder.setRowField("#Mod_Author", info->author()); + if (mod_Uploader->isChecked()) + builder.setRowField("#Mod_Uploader", info->uploader()); + if (nexus_ID->isChecked()) + builder.setRowField("#Nexus_ID", info->nexusId()); + if (mod_Nexus_URL->isChecked()) + builder.setRowField("#Mod_Nexus_URL", + (info->nexusId() > 0) + ? NexusInterface::instance().getModURL( + info->nexusId(), info->gameName()) + : ""); + if (mod_Uploader_URL->isChecked()) + builder.setRowField("#Mod_Uploader_URL", info->uploaderUrl()); + if (mod_Version->isChecked()) + builder.setRowField("#Mod_Version", info->version().canonicalString()); + if (install_Date->isChecked()) + builder.setRowField("#Install_Date", + info->creationTime().toString("yyyy/MM/dd HH:mm:ss")); + if (download_File_Name->isChecked()) + builder.setRowField("#Download_File_Name", info->installationFile()); + + builder.writeRow(); + } + } + + SaveTextAsDialog saveDialog(m_parent); + saveDialog.setText(buffer.data()); + saveDialog.exec(); + } catch (const std::exception& e) { + reportError(tr("export failed: %1").arg(e.what())); + } + } +} + +void ModListViewActions::displayModInformation(const QString& modName, + ModInfoTabIDs tab) const +{ + unsigned int index = ModInfo::getIndex(modName); + if (index == UINT_MAX) { + log::error("failed to resolve mod name {}", modName); + return; + } + + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + displayModInformation(modInfo, index, tab); +} + +void ModListViewActions::displayModInformation(unsigned int index, + ModInfoTabIDs tab) const +{ + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + displayModInformation(modInfo, index, tab); +} + +void ModListViewActions::displayModInformation(ModInfo::Ptr modInfo, + unsigned int modIndex, + ModInfoTabIDs tab) const +{ + if (!m_core.modList()->modInfoAboutToChange(modInfo)) { + log::debug("a different mod information dialog is open. If this is incorrect, " + "please restart MO"); + return; + } + std::vector flags = modInfo->getFlags(); + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_OVERWRITE) != flags.end()) { + QDialog* dialog = m_parent->findChild("__overwriteDialog"); + try { + if (dialog == nullptr) { + dialog = new OverwriteInfoDialog(modInfo, m_core, m_parent); + dialog->setObjectName("__overwriteDialog"); + } else { + qobject_cast(dialog)->setModInfo(modInfo); + } + + dialog->show(); + dialog->raise(); + dialog->activateWindow(); + connect(dialog, &QDialog::finished, [=]() { + m_core.modList()->modInfoChanged(modInfo); + dialog->deleteLater(); + m_core.refreshDirectoryStructure(); + }); + } catch (const std::exception& e) { + reportError(tr("Failed to display overwrite dialog: %1").arg(e.what())); + } + } else { + modInfo->saveMeta(); + + ModInfoDialog dialog(m_core, m_core.pluginContainer(), modInfo, m_view, m_parent); + connect(&dialog, &ModInfoDialog::originModified, this, + &ModListViewActions::originModified); + connect(&dialog, &ModInfoDialog::modChanged, [=](unsigned int index) { + auto idx = m_view->indexModelToView(m_core.modList()->index(index, 0)); + m_view->selectionModel()->select(idx, QItemSelectionModel::ClearAndSelect | + QItemSelectionModel::Rows); + m_view->scrollTo(idx); + }); + + // Open the tab first if we want to use the standard indexes of the tabs. + if (tab != ModInfoTabIDs::None) { + dialog.selectTab(tab); + } + + dialog.exec(); + + modInfo->saveMeta(); + m_core.modList()->modInfoChanged(modInfo); + emit modInfoDisplayed(); + } + + if (m_core.currentProfile()->modEnabled(modIndex) && !modInfo->isForeign()) { + FilesOrigin& origin = + m_core.directoryStructure()->getOriginByName(ToWString(modInfo->name())); + origin.enable(false); + + if (m_core.directoryStructure()->originExists(ToWString(modInfo->name()))) { + FilesOrigin& origin = + m_core.directoryStructure()->getOriginByName(ToWString(modInfo->name())); + origin.enable(false); + QString path = modInfo->absolutePath(); + QString modDataDir = m_core.managedGame()->modDataDirectory(); + path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; + m_core.directoryRefresher()->addModToStructure( + m_core.directoryStructure(), modInfo->name(), + m_core.currentProfile()->getModPriority(modIndex), path, + modInfo->stealFiles(), modInfo->archives()); + DirectoryRefresher::cleanStructure(m_core.directoryStructure()); + m_core.directoryStructure()->getFileRegister()->sortOrigins(); + m_core.refreshLists(); + } + } +} + +void ModListViewActions::sendModsToTop(const QModelIndexList& indexes) const +{ + m_core.modList()->changeModsPriority(indexes, Profile::MinimumPriority); +} + +void ModListViewActions::sendModsToBottom(const QModelIndexList& indexes) const +{ + m_core.modList()->changeModsPriority(indexes, Profile::MaximumPriority); +} + +void ModListViewActions::sendModsToPriority(const QModelIndexList& indexes) const +{ + bool ok; + int priority = QInputDialog::getInt(m_parent, tr("Set Priority"), + tr("Set the priority of the selected mods"), 0, 0, + std::numeric_limits::max(), 1, &ok); + if (!ok) + return; + + m_core.modList()->changeModsPriority(indexes, priority); +} + +void ModListViewActions::sendModsToSeparator(const QModelIndexList& indexes) const +{ + QStringList separators; + const auto& ibp = m_core.currentProfile()->getAllIndexesByPriority(); + for (const auto& [priority, index] : ibp) { + if (index < ModInfo::getNumMods()) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + if (modInfo->isSeparator()) { + separators << modInfo->name().chopped( + 10); // chops the "_separator" away from the name + } + } + } + + // in descending order, reverse the separator + if (m_view->sortOrder() == Qt::DescendingOrder) { + std::reverse(separators.begin(), separators.end()); + } + + ListDialog dialog(m_parent); + dialog.setWindowTitle("Select a separator..."); + dialog.setChoices(separators); + + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const QString result = dialog.getChoice(); + if (result.isEmpty()) { + return; + } + + const auto sepPriority = + m_core.currentProfile()->getModPriority(ModInfo::getIndex(result + "_separator")); + + auto isSeparator = [](const auto& p) { + return ModInfo::getByIndex(p.second)->isSeparator(); + }; + + // start right after/before the current priority and look for the next + // separator + int priority = -1; + if (m_view->sortOrder() == Qt::AscendingOrder) { + auto it = std::find_if(ibp.find(sepPriority + 1), ibp.end(), isSeparator); + if (it != ibp.end()) { + priority = it->first; + } else { + priority = Profile::MaximumPriority; + } + } else { + auto it = std::find_if(--std::reverse_iterator{ibp.find(sepPriority - 1)}, + ibp.rend(), isSeparator); + if (it != ibp.rend()) { + priority = it->first + 1; + } else { + // create "before" priority 0, i.e. at the end in descending priority. + priority = Profile::MinimumPriority; + } + } + + // when the priority of a single mod is incremented, we need to shift the + // target priority, otherwise we will miss the target by one + if (indexes.size() == 1 && + indexes[0].data(ModList::PriorityRole).toInt() < sepPriority) { + priority--; + } + + m_core.modList()->changeModsPriority(indexes, priority); +} + +void ModListViewActions::sendModsToFirstConflict(const QModelIndexList& indexes) const +{ + std::set conflicts; + + for (auto& idx : indexes) { + if (!idx.data(ModList::IndexRole).isValid()) { + continue; + } + auto info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + conflicts.insert(info->getModOverwrite().begin(), info->getModOverwrite().end()); + } + + std::set priorities; + std::transform(conflicts.begin(), conflicts.end(), + std::inserter(priorities, priorities.end()), [=](auto index) { + return m_core.currentProfile()->getModPriority(index); + }); + + if (!priorities.empty()) { + m_core.modList()->changeModsPriority(indexes, *priorities.begin()); + } +} + +void ModListViewActions::sendModsToLastConflict(const QModelIndexList& indexes) const +{ + std::set conflicts; + + for (auto& idx : indexes) { + if (!idx.data(ModList::IndexRole).isValid()) { + continue; + } + auto info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + conflicts.insert(info->getModOverwritten().begin(), + info->getModOverwritten().end()); + } + + std::set priorities; + std::transform(conflicts.begin(), conflicts.end(), + std::inserter(priorities, priorities.end()), [=](auto index) { + return m_core.currentProfile()->getModPriority(index); + }); + + if (!priorities.empty()) { + m_core.modList()->changeModsPriority(indexes, *priorities.rbegin()); + } +} + +void ModListViewActions::renameMod(const QModelIndex& index) const +{ + try { + m_view->edit(m_view->indexModelToView(index)); + } catch (const std::exception& e) { + reportError(tr("failed to rename mod: %1").arg(e.what())); + } +} + +void ModListViewActions::removeMods(const QModelIndexList& indices) const +{ + const int max_items = 20; + + try { + if (indices.size() > 1) { + QString mods; + QStringList modNames; + + int i = 0; + for (auto& idx : indices) { + QString name = idx.data().toString(); + if (!ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->isRegular()) { + continue; + } + + // adds an item for the mod name until `i` reaches `max_items`, which + // adds one "..." item; subsequent mods are not shown on the list but + // are still added to `modNames` below so they can be removed correctly + + if (i < max_items) { + mods += "
  • " + name + "
  • "; + } else if (i == max_items) { + mods += "
  • ...
  • "; + } + + modNames.append( + ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->name()); + ++i; + } + if (QMessageBox::question( + m_parent, tr("Confirm"), + tr("Remove the following mods?
      %1
    ").arg(mods), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + // use mod names instead of indexes because those become invalid during the + // removal + DownloadManager::startDisableDirWatcher(); + for (QString name : modNames) { + m_core.modList()->removeRowForce(ModInfo::getIndex(name), QModelIndex()); + } + DownloadManager::endDisableDirWatcher(); + } + } else if (!indices.isEmpty()) { + m_core.modList()->removeRow(indices[0].data(ModList::IndexRole).toInt(), + QModelIndex()); + } + m_view->updateModCount(); + m_pluginView->updatePluginCount(); + } catch (const std::exception& e) { + reportError(tr("failed to remove mod: %1").arg(e.what())); + } +} + +void ModListViewActions::ignoreMissingData(const QModelIndexList& indices) const +{ + for (auto& idx : indices) { + int row_idx = idx.data(ModList::IndexRole).toInt(); + ModInfo::Ptr info = ModInfo::getByIndex(row_idx); + info->markValidated(true); + m_core.modList()->notifyChange(row_idx); + } +} + +void ModListViewActions::setIgnoreUpdate(const QModelIndexList& indices, + bool ignore) const +{ + for (auto& idx : indices) { + int modIdx = idx.data(ModList::IndexRole).toInt(); + ModInfo::Ptr info = ModInfo::getByIndex(modIdx); + info->ignoreUpdate(ignore); + m_core.modList()->notifyChange(modIdx); + } +} + +void ModListViewActions::changeVersioningScheme(const QModelIndex& index) const +{ + if (QMessageBox::question( + m_parent, tr("Continue?"), + tr("The versioning scheme decides which version is considered newer than " + "another.\n" + "This function will guess the versioning scheme under the assumption that " + "the installed version is outdated."), + QMessageBox::Yes | QMessageBox::Cancel) == QMessageBox::Yes) { + + ModInfo::Ptr info = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + + bool success = false; + + static VersionInfo::VersionScheme schemes[] = { + VersionInfo::SCHEME_REGULAR, VersionInfo::SCHEME_DECIMALMARK, + VersionInfo::SCHEME_NUMBERSANDLETTERS}; + + for (int i = 0; + i < sizeof(schemes) / sizeof(VersionInfo::VersionScheme) && !success; ++i) { + VersionInfo verOld(info->version().canonicalString(), schemes[i]); + VersionInfo verNew(info->newestVersion().canonicalString(), schemes[i]); + if (verOld < verNew) { + info->setVersion(verOld); + info->setNewestVersion(verNew); + success = true; + } + } + if (!success) { + QMessageBox::information( + m_parent, tr("Sorry"), + tr("I don't know a versioning scheme where %1 is newer than %2.") + .arg(info->newestVersion().canonicalString()) + .arg(info->version().canonicalString()), + QMessageBox::Ok); + } + } +} + +void ModListViewActions::markConverted(const QModelIndexList& indices) const +{ + for (auto& idx : indices) { + int modIdx = idx.data(ModList::IndexRole).toInt(); + ModInfo::Ptr info = ModInfo::getByIndex(modIdx); + info->markConverted(true); + m_core.modList()->notifyChange(modIdx); + } +} + +void ModListViewActions::visitOnNexus(const QModelIndexList& indices) const +{ + if (indices.size() > 10) { + if (!askOpenLinksConfirmation(indices.size(), tr("Nexus Links"))) { + return; + } + } + + for (auto& idx : indices) { + ModInfo::Ptr info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + int modID = info->nexusId(); + QString gameName = info->gameName(); + if (modID > 0) { + shell::Open(QUrl(NexusInterface::instance().getModURL(modID, gameName))); + } else { + log::error("mod '{}' has no nexus id", info->name()); + } + } +} + +void ModListViewActions::visitWebPage(const QModelIndexList& indices) const +{ + if (indices.size() > 10) { + if (!askOpenLinksConfirmation(indices.size(), tr("Web Pages"))) { + return; + } + } + + for (auto& idx : indices) { + ModInfo::Ptr info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + + const auto url = info->parseCustomURL(); + if (url.isValid()) { + shell::Open(url); + } + } +} + +void ModListViewActions::visitNexusOrWebPage(const QModelIndexList& indices) const +{ + if (indices.size() > 10) { + if (!askOpenLinksConfirmation(indices.size(), tr("Web Pages"))) { + return; + } + } + + for (auto& idx : indices) { + ModInfo::Ptr info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + if (!info) { + log::error("mod {} not found", idx.data(ModList::IndexRole).toInt()); + continue; + } + + int modID = info->nexusId(); + QString gameName = info->gameName(); + const auto url = info->parseCustomURL(); + + if (modID > 0) { + shell::Open(QUrl(NexusInterface::instance().getModURL(modID, gameName))); + } else if (url.isValid()) { + shell::Open(url); + } else { + log::error("mod '{}' has no valid link", info->name()); + } + } +} + +void ModListViewActions::visitUploaderProfile(const QModelIndexList& indices) const +{ + if (indices.size() > 10) { + if (!askOpenLinksConfirmation(indices.size(), tr("Uploader Profiles"))) { + return; + } + } + + for (auto& idx : indices) { + ModInfo::Ptr info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + const auto uploaderUrl = info->uploaderUrl(); + + if (!uploaderUrl.isEmpty()) { + shell::Open(QUrl(uploaderUrl)); + } else { + log::error("mod '{}' has no uploader url", info->name()); + } + } +} + +bool ModListViewActions::askOpenLinksConfirmation(std::size_t numberOfLinks, + const QString& nameOfLinks) const +{ + return QMessageBox::question(m_parent, tr("Opening %1").arg(nameOfLinks), + tr("You are trying to open %1 %2. Are you sure " + "you want to do this?") + .arg(numberOfLinks) + .arg(nameOfLinks), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes; +} + +void ModListViewActions::reinstallMod(const QModelIndex& index) const +{ + ModInfo::Ptr modInfo = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + QString installationFile = modInfo->installationFile(); + if (installationFile.length() != 0) { + QString fullInstallationFile; + QFileInfo fileInfo(installationFile); + if (fileInfo.isAbsolute()) { + if (fileInfo.exists()) { + fullInstallationFile = installationFile; + } else { + fullInstallationFile = + m_core.downloadManager()->getOutputDirectory() + "/" + fileInfo.fileName(); + } + } else { + fullInstallationFile = + m_core.downloadManager()->getOutputDirectory() + "/" + installationFile; + } + if (QFile::exists(fullInstallationFile)) { + m_core.installMod(fullInstallationFile, -1, true, modInfo, modInfo->name()); + } else { + QMessageBox::information(m_parent, tr("Failed"), + tr("Installation file no longer exists")); + } + } else { + QMessageBox::information( + m_parent, tr("Failed"), + tr("Mods installed with old versions of MO can't be reinstalled in this way.")); + } +} + +void ModListViewActions::createBackup(const QModelIndex& index) const +{ + ModInfo::Ptr modInfo = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + QString backupDirectory = + m_core.installationManager()->generateBackupName(modInfo->absolutePath()); + if (!copyDir(modInfo->absolutePath(), backupDirectory, false)) { + QMessageBox::information(m_parent, tr("Failed"), tr("Failed to create backup.")); + } + m_core.refresh(); + m_view->updateModCount(); +} + +void ModListViewActions::restoreHiddenFiles(const QModelIndexList& indices) const +{ + const int max_items = 20; + + QFlags flags = FileRenamer::UNHIDE; + flags |= FileRenamer::MULTIPLE; + + FileRenamer renamer(m_parent, flags); + + FileRenamer::RenameResults result = FileRenamer::RESULT_OK; + + // multi selection + if (indices.size() > 1) { + + QStringList modNames; + for (auto& idx : indices) { + + ModInfo::Ptr modInfo = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + const auto flags = modInfo->getFlags(); + + if (!modInfo->isRegular() || + std::find(flags.begin(), flags.end(), ModInfo::FLAG_HIDDEN_FILES) == + flags.end()) { + continue; + } + + modNames.append(idx.data(Qt::DisplayRole).toString()); + } + + QString mods = "
  • " + modNames.mid(0, max_items).join("
  • ") + "
  • "; + if (modNames.size() > max_items) { + mods += "
  • ...
  • "; + } + + if (QMessageBox::question( + m_parent, tr("Confirm"), + tr("Restore all hidden files in the following mods?
      %1
    ") + .arg(mods), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + + for (auto& idx : indices) { + + ModInfo::Ptr modInfo = + ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + + const auto flags = modInfo->getFlags(); + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_HIDDEN_FILES) != + flags.end()) { + const QString modDir = modInfo->absolutePath(); + + auto partialResult = restoreHiddenFilesRecursive(renamer, modDir); + + if (partialResult == FileRenamer::RESULT_CANCEL) { + result = FileRenamer::RESULT_CANCEL; + break; + } + emit originModified((m_core.directoryStructure()->getOriginByName( + ToWString(modInfo->internalName()))) + .getID()); + } + } + } + } else if (!indices.isEmpty()) { + // single selection + ModInfo::Ptr modInfo = + ModInfo::getByIndex(indices[0].data(ModList::IndexRole).toInt()); + const QString modDir = modInfo->absolutePath(); + + if (QMessageBox::question( + m_parent, tr("Are you sure?"), + tr("About to restore all hidden files in:\n") + modInfo->name(), + QMessageBox::Ok | QMessageBox::Cancel) == QMessageBox::Ok) { + + result = restoreHiddenFilesRecursive(renamer, modDir); + + emit originModified((m_core.directoryStructure()->getOriginByName( + ToWString(modInfo->internalName()))) + .getID()); + } + } + + if (result == FileRenamer::RESULT_CANCEL) { + log::debug("Restoring hidden files operation cancelled"); + } else { + log::debug("Finished restoring hidden files"); + } +} + +void ModListViewActions::setTracked(const QModelIndexList& indices, bool tracked) const +{ + m_core.loggedInAction(m_parent, [=] { + for (auto& idx : indices) { + ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->track(tracked); + } + }); +} + +void ModListViewActions::setEndorsed(const QModelIndexList& indices, + bool endorsed) const +{ + m_core.loggedInAction(m_parent, [=] { + if (indices.size() > 1) { + MessageDialog::showMessage( + tr("Endorsing multiple mods will take a while. Please wait..."), m_parent); + } + + for (auto& idx : indices) { + ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->endorse(endorsed); + } + }); +} + +void ModListViewActions::willNotEndorsed(const QModelIndexList& indices) const +{ + for (auto& idx : indices) { + ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt())->setNeverEndorse(); + } +} + +void ModListViewActions::remapCategory(const QModelIndexList& indices) const +{ + for (auto& idx : indices) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + if (modInfo->isSeparator()) + continue; + + int categoryID = modInfo->getNexusCategory(); + if (!categoryID) { + QSettings downloadMeta(m_core.downloadsPath() + "/" + + modInfo->installationFile() + ".meta", + QSettings::IniFormat); + if (downloadMeta.contains("category")) { + categoryID = downloadMeta.value("category", 0).toInt(); + } + } + unsigned int categoryIndex = CategoryFactory::instance().resolveNexusID(categoryID); + if (categoryIndex != 0) + modInfo->setPrimaryCategory( + CategoryFactory::instance().getCategoryID(categoryIndex)); + } +} + +void ModListViewActions::setColor(const QModelIndexList& indices, + const QModelIndex& refIndex) const +{ + auto& settings = m_core.settings(); + ModInfo::Ptr modInfo = ModInfo::getByIndex(refIndex.data(ModList::IndexRole).toInt()); + + QColorDialog dialog(m_parent); + dialog.setOption(QColorDialog::ShowAlphaChannel); + + QColor currentColor = modInfo->color(); + if (currentColor.isValid()) { + dialog.setCurrentColor(currentColor); + } else if (auto c = settings.colors().previousSeparatorColor()) { + dialog.setCurrentColor(*c); + } + + if (!dialog.exec()) + return; + + currentColor = dialog.currentColor(); + if (!currentColor.isValid()) + return; + + settings.colors().setPreviousSeparatorColor(currentColor); + + for (auto& idx : indices) { + ModInfo::Ptr info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + info->setColor(currentColor); + } +} + +void ModListViewActions::resetColor(const QModelIndexList& indices) const +{ + for (auto& idx : indices) { + ModInfo::Ptr info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + info->setColor(QColor()); + } + m_core.settings().colors().removePreviousSeparatorColor(); +} + +void ModListViewActions::setCategories( + ModInfo::Ptr mod, const std::vector>& categories) const +{ + for (auto& [id, enabled] : categories) { + mod->setCategory(id, enabled); + } +} + +void ModListViewActions::setCategoriesIf( + ModInfo::Ptr mod, ModInfo::Ptr ref, + const std::vector>& categories) const +{ + for (auto& [id, enabled] : categories) { + if (ref->categorySet(id) != enabled) { + mod->setCategory(id, enabled); + } + } +} + +void ModListViewActions::setCategories( + const QModelIndexList& selected, const QModelIndex& ref, + const std::vector>& categories) const +{ + ModInfo::Ptr refMod = ModInfo::getByIndex(ref.data(ModList::IndexRole).toInt()); + if (selected.size() > 1) { + for (auto& idx : selected) { + if (idx.row() != ref.row()) { + setCategoriesIf(ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()), + refMod, categories); + } + } + setCategories(refMod, categories); + } else if (!selected.isEmpty()) { + // for single mod selections, just do a replace + setCategories(refMod, categories); + } + + for (auto& idx : selected) { + m_core.modList()->notifyChange(idx.data(ModList::IndexRole).toInt()); + } + + // reset the selection manually - still needed + auto viewIndices = m_view->indexModelToView(selected); + for (auto& idx : viewIndices) { + m_view->selectionModel()->select(idx, QItemSelectionModel::Select | + QItemSelectionModel::Rows); + } +} + +void ModListViewActions::setPrimaryCategory(const QModelIndexList& selected, + int category, bool force) +{ + for (auto& idx : selected) { + ModInfo::Ptr info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + if (force || info->categorySet(category)) { + info->setCategory(category, true); + info->setPrimaryCategory(category); + } + } + + // reset the selection manually - still needed + auto viewIndices = m_view->indexModelToView(selected); + for (auto& idx : viewIndices) { + m_view->selectionModel()->select(idx, QItemSelectionModel::Select | + QItemSelectionModel::Rows); + } +} + +void ModListViewActions::openExplorer(const QModelIndexList& index) const +{ + for (auto& idx : index) { + ModInfo::Ptr info = ModInfo::getByIndex(idx.data(ModList::IndexRole).toInt()); + if (!info->isForeign()) { + shell::Explore(info->absolutePath()); + } + } +} + +void ModListViewActions::restoreBackup(const QModelIndex& index) const +{ + QRegularExpression backupRegEx("(.*)_backup[0-9]*$"); + ModInfo::Ptr modInfo = ModInfo::getByIndex(index.data(ModList::IndexRole).toInt()); + auto match = backupRegEx.match(modInfo->name()); + if (match.hasMatch()) { + QString regName = match.captured(1); + QDir modDir(QDir::fromNativeSeparators(m_core.settings().paths().mods())); + if (!modDir.exists(regName) || + (QMessageBox::question( + m_parent, tr("Overwrite?"), + tr("This will replace the existing mod \"%1\". Continue?").arg(regName), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes)) { + if (modDir.exists(regName) && + !shellDelete(QStringList(modDir.absoluteFilePath(regName)))) { + reportError(tr("failed to remove mod \"%1\"").arg(regName)); + } else { + QString destinationPath = + QDir::fromNativeSeparators(m_core.settings().paths().mods()) + "/" + + regName; + if (!modDir.rename(modInfo->absolutePath(), destinationPath)) { + reportError(tr("failed to rename \"%1\" to \"%2\"") + .arg(modInfo->absolutePath()) + .arg(destinationPath)); + } + m_core.refresh(); + m_view->updateModCount(); + } + } + } +} + +void ModListViewActions::moveOverwriteContentsTo(const QString& absolutePath) const +{ + ModInfo::Ptr overwriteInfo = ModInfo::getOverwrite(); + bool successful = false; + if (m_core.managedGame()->getModMappings().count() > 1 || + m_core.managedGame()->getModMappings().keys().first() != "") { + QDirIterator iter(overwriteInfo->absolutePath(), + QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot); + while (iter.hasNext()) { + auto entry = iter.nextFileInfo(); + if (entry.isDir() && m_core.managedGame()->getModMappings().keys().contains( + entry.fileName(), Qt::CaseInsensitive)) { + successful = + shellCopy((QDir::toNativeSeparators(entry.absolutePath())), + (QDir::toNativeSeparators(absolutePath)), false, m_parent); + QDirIterator subDirIter(entry.absoluteFilePath(), + QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot); + while (subDirIter.hasNext()) { + auto subDirEntry = subDirIter.nextFileInfo(); + if (subDirEntry.isDir()) { + QDir(subDirEntry.absoluteFilePath()).removeRecursively(); + } else { + QFile(subDirEntry.absoluteFilePath()).remove(); + } + } + } else { + successful = + shellMove((QDir::toNativeSeparators(iter.filePath())), + (QDir::toNativeSeparators(absolutePath)), false, m_parent); + } + if (!successful) + break; + } + + } else { + successful = + shellMove((QDir::toNativeSeparators(overwriteInfo->absolutePath()) + QDir::separator() + "*"), + (QDir::toNativeSeparators(absolutePath)), false, m_parent); + } + + if (successful) { + MessageDialog::showMessage(tr("Move successful."), m_parent); + } else { + const auto e = GetLastError(); + log::error("Move operation failed: {}", formatSystemMessage(e)); + } + + m_core.refresh(); +} + +void ModListViewActions::createModFromOverwrite() const +{ + GuessedValue name; + name.setFilter(&fixDirectoryName); + + while (name->isEmpty()) { + bool ok; + name.update( + QInputDialog::getText( + m_parent, tr("Create Mod..."), + tr("This will move all files from overwrite into a new, regular mod.\n" + "Please enter a name:"), + QLineEdit::Normal, "", &ok), + GUESS_USER); + if (!ok) { + return; + } + } + + if (m_core.modList()->getMod(name) != nullptr) { + reportError(tr("A mod with this name already exists")); + return; + } + + const IModInterface* newMod = m_core.createMod(name); + if (newMod == nullptr) { + return; + } + + moveOverwriteContentsTo(newMod->absolutePath()); +} + +void ModListViewActions::moveOverwriteContentToExistingMod() const +{ + QStringList mods; + auto indexesByPriority = m_core.currentProfile()->getAllIndexesByPriority(); + for (auto& iter : indexesByPriority) { + if ((iter.second != UINT_MAX)) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(iter.second); + if (!modInfo->isSeparator() && !modInfo->isForeign() && !modInfo->isOverwrite()) { + mods << modInfo->name(); + } + } + } + + ListDialog dialog(m_parent); + dialog.setWindowTitle("Select a mod..."); + dialog.setChoices(mods); + + if (dialog.exec() == QDialog::Accepted) { + QString result = dialog.getChoice(); + if (!result.isEmpty()) { + + QString modAbsolutePath; + + for (const auto& mod : m_core.modList()->allModsByProfilePriority()) { + if (result.compare(mod) == 0) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(ModInfo::getIndex(mod)); + modAbsolutePath = modInfo->absolutePath(); + break; + } + } + + if (modAbsolutePath.isNull()) { + log::warn("Mod {} has not been found, for some reason", result); + return; + } + + moveOverwriteContentsTo(modAbsolutePath); + } + } +} + +void ModListViewActions::clearOverwrite() const +{ + ModInfo::Ptr modInfo = ModInfo::getOverwrite(); + if (modInfo) { + QDir overwriteDir(modInfo->absolutePath()); + if (QMessageBox::question( + m_parent, tr("Are you sure?"), + tr("About to recursively delete:\n") + overwriteDir.absolutePath(), + QMessageBox::Ok | QMessageBox::Cancel) == QMessageBox::Ok) { + QStringList delList; + for (auto f : overwriteDir.entryInfoList(QDir::AllDirs | QDir::Files | + QDir::NoDotAndDotDot)) { + if (f.isDir() && m_core.managedGame()->getModMappings().keys().contains( + f.fileName(), Qt::CaseInsensitive)) { + for (auto sf : + QDir(f.absoluteFilePath()) + .entryInfoList(QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot)) { + delList.push_back(sf.absoluteFilePath()); + } + } else { + delList.push_back(f.absoluteFilePath()); + } + } + if (shellDelete(delList, true)) { + emit overwriteCleared(); + m_core.refresh(); + } else { + const auto e = GetLastError(); + log::error("Delete operation failed: {}", formatSystemMessage(e)); + } + } + } +} diff --git a/src/src/modlistviewactions.h b/src/src/modlistviewactions.h new file mode 100644 index 0000000..3dcbffc --- /dev/null +++ b/src/src/modlistviewactions.h @@ -0,0 +1,194 @@ +#ifndef MODLISTVIEWACTIONS_H +#define MODLISTVIEWACTIONS_H + +#include +#include + +#include "modinfo.h" +#include "modinfodialogfwd.h" + +class CategoryFactory; +class FilterList; +class MainWindow; +class ModListView; +class PluginListView; +class OrganizerCore; +class DownloadManager; + +class ModListViewActions : public QObject +{ + Q_OBJECT + +public: + // currently passing the main window itself because a lots of stuff needs it but + // it would be nice to avoid passing it at some point + // + ModListViewActions(OrganizerCore& core, FilterList& filters, + CategoryFactory& categoryFactory, ModListView* view, + PluginListView* pluginView, QObject* nxmReceiver); + + // install mod, create an empty mod or a separator with at priority based on the + // index and the current sorting of the view + // - if the list is not sorted by priority or the index is invalid, use the + // highest priority + // - otherwise + // - create separators above the given index (the priority is not the + // same depending on the current sorting) + // - create/install mods above or below other mods depending on the sort order, or + // at the end of the separator if index corresponds to the separator + // + // if the archivePath is empty for installMod, ask the user for the archive + // to install + // + void installMod(const QString& archivePath = "", + const QModelIndex& index = QModelIndex()) const; + void createEmptyMod(const QModelIndex& index = QModelIndex()) const; + void createSeparator(const QModelIndex& index = QModelIndex()) const; + + // enable/disable all non-filtered mods + // + void setAllMatchingModsEnabled(bool enabled) const; + + // check all mods for update + // + void checkModsForUpdates() const; + void checkModsForUpdates(const QModelIndexList& indices) const; + + // auto-assign categories based on nexus ID + // + void assignCategories() const; + + // start the "Export Mod List" dialog + // + void exportModListCSV() const; + + // display mod information + // + void displayModInformation(const QString& modName, + ModInfoTabIDs tabID = ModInfoTabIDs::None) const; + void displayModInformation(unsigned int index, + ModInfoTabIDs tab = ModInfoTabIDs::None) const; + void displayModInformation(ModInfo::Ptr modInfo, unsigned int modIndex, + ModInfoTabIDs tabID = ModInfoTabIDs::None) const; + + // move mods to top/bottom, start the "Send to priority" and "Send to separator" + // dialog + // + void sendModsToTop(const QModelIndexList& index) const; + void sendModsToBottom(const QModelIndexList& index) const; + void sendModsToPriority(const QModelIndexList& index) const; + void sendModsToSeparator(const QModelIndexList& index) const; + void sendModsToFirstConflict(const QModelIndexList& index) const; + void sendModsToLastConflict(const QModelIndexList& index) const; + + // actions for most regular mods + // + void renameMod(const QModelIndex& index) const; + void removeMods(const QModelIndexList& indices) const; + void ignoreMissingData(const QModelIndexList& indices) const; + void setIgnoreUpdate(const QModelIndexList& indices, bool ignore) const; + void changeVersioningScheme(const QModelIndex& indices) const; + void markConverted(const QModelIndexList& indices) const; + void visitOnNexus(const QModelIndexList& indices) const; + void visitWebPage(const QModelIndexList& indices) const; + void visitNexusOrWebPage(const QModelIndexList& indices) const; + void visitUploaderProfile(const QModelIndexList& indices) const; + void reinstallMod(const QModelIndex& index) const; + void createBackup(const QModelIndex& index) const; + void restoreHiddenFiles(const QModelIndexList& indices) const; + void setTracked(const QModelIndexList& indices, bool tracked) const; + void setEndorsed(const QModelIndexList& indices, bool endorsed) const; + void willNotEndorsed(const QModelIndexList& indices) const; + void remapCategory(const QModelIndexList& indices) const; + + // set/reset color of the given selection, using the given reference index (index + // at which the context menu was shown) + // + void setColor(const QModelIndexList& indices, const QModelIndex& refIndex) const; + void resetColor(const QModelIndexList& indices) const; + + // set the category of the mods in the given list, using the given index as reference + // - the categories are set as-is on the refernce mod + // - for the other mods, the category is only set if the current state of the category + // on the reference is different + // + void setCategories(const QModelIndexList& selected, const QModelIndex& ref, + const std::vector>& categories) const; + + // set the primary category of the mods in the given list, adding the appropriate + // category to the mods unless force is false, in which case the primary category + // is set only on mods that have this category + // + void setPrimaryCategory(const QModelIndexList& selected, int category, + bool force = true); + + // open the Windows explorer for the specified mods + // + void openExplorer(const QModelIndexList& index) const; + + // backup-specific actions + // + void restoreBackup(const QModelIndex& index) const; + + // overwrite-specific actions + // + void createModFromOverwrite() const; + void moveOverwriteContentToExistingMod() const; + void clearOverwrite() const; + +signals: + + // emitted when the overwrite mod has been clear + // + void overwriteCleared() const; + + // emitted when the origin of a file is modified + // + void originModified(int originId) const; + + // emitted when the mod info dialog has been shown and closed + // + void modInfoDisplayed() const; + +private: + // find the priority where to install or create a mod for the + // given index (e.g. above, below, inside separator or at the end) + // + int findInstallPriority(const QModelIndex& current) const; + + // move the contents of the overwrite to the given path + // + void moveOverwriteContentsTo(const QString& absolutePath) const; + + // set the category of the given mod based on the given array + // + void setCategories(ModInfo::Ptr mod, + const std::vector>& categories) const; + + // set the category of the given mod if the category from the reference mod does not + // match the one in the array of categories + // + void setCategoriesIf(ModInfo::Ptr mod, ModInfo::Ptr ref, + const std::vector>& categories) const; + + // check the given mods from update, the map should map game names to nexus ID + // + void checkModsForUpdates(std::multimap const& IDs) const; + + // ask the user for confirmation for opening many links at once. + // returns true if the user confirmed, false otherwise. + // + bool askOpenLinksConfirmation(std::size_t numberOfLinks, + const QString& nameOfLinks) const; + +private: + OrganizerCore& m_core; + FilterList& m_filters; + CategoryFactory& m_categories; + ModListView* m_view; + PluginListView* m_pluginView; + QObject* m_receiver; + QWidget* m_parent; +}; + +#endif diff --git a/src/src/moshortcut.cpp b/src/src/moshortcut.cpp new file mode 100644 index 0000000..2dc5c05 --- /dev/null +++ b/src/src/moshortcut.cpp @@ -0,0 +1,73 @@ +#include "moshortcut.h" + +MOShortcut::MOShortcut(const QString& link) + : m_valid(link.startsWith("moshortcut://")), m_hasInstance(false), + m_hasExecutable(false) +{ + if (m_valid) { + int start = (int)strlen("moshortcut://"); + int sep = link.indexOf(':', start); + if (sep >= 0) { + m_hasInstance = true; + m_instance = link.mid(start, sep - start); + m_executable = link.mid(sep + 1); + } else + m_executable = link.mid(start); + if (!(m_executable == "")) + m_hasExecutable = true; + } +} + +bool MOShortcut::isValid() const +{ + return m_valid; +} + +bool MOShortcut::hasInstance() const +{ + return m_hasInstance; +} + +bool MOShortcut::hasExecutable() const +{ + return m_hasExecutable; +} + +QString MOShortcut::instanceDisplayName() const +{ + return (m_instance == "" ? QObject::tr("Portable") : m_instance); +} + +const QString& MOShortcut::instanceName() const +{ + return m_instance; +} + +const QString& MOShortcut::executableName() const +{ + return m_executable; +} + +bool MOShortcut::isForInstance(const Instance& i) const +{ + if (!m_hasInstance) { + // no instance name was specified, so the current one is fine + return true; + } + + if (m_instance == "") { + // empty instance name means portable + return i.isPortable(); + } else { + return (i.displayName() == m_instance); + } +} + +QString MOShortcut::toString() const +{ + if (m_hasInstance) { + return "moshortcut://" + m_instance + ":" + m_executable; + } else { + return "moshortcut://" + m_executable; + } +} diff --git a/src/src/moshortcut.h b/src/src/moshortcut.h new file mode 100644 index 0000000..1204be9 --- /dev/null +++ b/src/src/moshortcut.h @@ -0,0 +1,52 @@ +#ifndef MODORGANIZER_MOSHORTCUT_INCLUDED +#define MODORGANIZER_MOSHORTCUT_INCLUDED + +#include "instancemanager.h" +#include + +class MOShortcut +{ +public: + MOShortcut(const QString& link = {}); + + // true if initialized using a valid moshortcut link + // + bool isValid() const; + + // whether an instance name was given + // + bool hasInstance() const; + + // whether an executable name was given + // + bool hasExecutable() const; + + // name of the instance given, "Portable" for portable; undefined if + // hasInstance() returns false + // + QString instanceDisplayName() const; + + // name of the instance given, empty for portable; undefined if hasInstance() + // returns false + // + const QString& instanceName() const; + + // name of the executable given + // + const QString& executableName() const; + + // whether this shortcut is for the given instance + // + bool isForInstance(const Instance& i) const; + + QString toString() const; + +private: + QString m_instance; + QString m_executable; + bool m_valid; + bool m_hasInstance; + bool m_hasExecutable; +}; + +#endif // MODORGANIZER_MOSHORTCUT_INCLUDED diff --git a/src/src/motddialog.cpp b/src/src/motddialog.cpp new file mode 100644 index 0000000..b985c65 --- /dev/null +++ b/src/src/motddialog.cpp @@ -0,0 +1,53 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "motddialog.h" +#include "bbcode.h" +#include "organizercore.h" +#include "ui_motddialog.h" +#include "utility.h" +#ifdef _WIN32 +#include +#endif +#include + +using namespace MOBase; + +MotDDialog::MotDDialog(const QString& message, QWidget* parent) + : QDialog(parent), ui(new Ui::MotDDialog) +{ + ui->setupUi(this); + ui->motdView->setHtml(BBCode::convertToHTML(message)); + connect(ui->motdView, SIGNAL(anchorClicked(QUrl)), this, SLOT(linkClicked(QUrl))); +} + +MotDDialog::~MotDDialog() +{ + delete ui; +} + +void MotDDialog::on_okButton_clicked() +{ + this->close(); +} + +void MotDDialog::linkClicked(const QUrl& url) +{ + shell::Open(url); +} diff --git a/src/src/motddialog.h b/src/src/motddialog.h new file mode 100644 index 0000000..9f96470 --- /dev/null +++ b/src/src/motddialog.h @@ -0,0 +1,47 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef MOTDDIALOG_H +#define MOTDDIALOG_H + +#include +#include + +namespace Ui +{ +class MotDDialog; +} + +class MotDDialog : public QDialog +{ + Q_OBJECT + +public: + explicit MotDDialog(const QString& message, QWidget* parent = 0); + ~MotDDialog(); + +private slots: + void on_okButton_clicked(); + void linkClicked(const QUrl& url); + +private: + Ui::MotDDialog* ui; +}; + +#endif // MOTDDIALOG_H diff --git a/src/src/motddialog.ui b/src/src/motddialog.ui new file mode 100644 index 0000000..4d37968 --- /dev/null +++ b/src/src/motddialog.ui @@ -0,0 +1,52 @@ + + + MotDDialog + + + + 0 + 0 + 483 + 372 + + + + Message of the Day + + + + + + false + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + OK + + + + + + + + + + diff --git a/src/src/multiprocess.cpp b/src/src/multiprocess.cpp new file mode 100644 index 0000000..67270b8 --- /dev/null +++ b/src/src/multiprocess.cpp @@ -0,0 +1,137 @@ +#include "multiprocess.h" +#include "utility.h" +#include +#ifndef _WIN32 +#include +#endif +#include +#include + +static const char s_Key[] = "mo-43d1a3ad-eeb0-4818-97c9-eda5216c29b5"; +static const int s_Timeout = 5000; + +using MOBase::reportError; + +MOMultiProcess::MOMultiProcess(bool allowMultiple, QObject* parent) + : QObject(parent), m_Ephemeral(false), m_OwnsSM(false) +{ + m_SharedMem.setKey(s_Key); + + if (m_SharedMem.create(1)) { + m_OwnsSM = true; + } else { + auto error = m_SharedMem.error(); + + if (error == QSharedMemory::AlreadyExists && !allowMultiple) { + // Primary instance likely exists. Try to attach as ephemeral process. + if (m_SharedMem.attach()) { + m_Ephemeral = true; + } else { + // Handle races with stale shared memory state: + // between create() and attach(), the owner may have disappeared. + auto attachError = m_SharedMem.error(); + + if (attachError == QSharedMemory::NotFound || + attachError == QSharedMemory::KeyError || + attachError == QSharedMemory::UnknownError) { + MOBase::log::debug( + "shared memory attach race: {} ({}), retrying as owner", + m_SharedMem.errorString(), static_cast(attachError)); + if (m_SharedMem.create(1)) { + m_OwnsSM = true; + error = QSharedMemory::NoError; + } else { + error = m_SharedMem.error(); + } + } else { + MOBase::log::debug("shared memory attach failed: {} ({})", + m_SharedMem.errorString(), + static_cast(attachError)); + error = attachError; + } + } + } + + if (!m_OwnsSM && !m_Ephemeral && error != QSharedMemory::NoError && + error != QSharedMemory::AlreadyExists) { + throw MOBase::MyException(tr("SHM error: %1").arg(m_SharedMem.errorString())); + } + } + + if (m_OwnsSM) { + connect(&m_Server, SIGNAL(newConnection()), this, SLOT(receiveMessage()), + Qt::QueuedConnection); + // has to be called before listen + m_Server.setSocketOptions(QLocalServer::WorldAccessOption); + m_Server.listen(s_Key); + } +} + +void MOMultiProcess::sendMessage(const QString& message) +{ + if (m_OwnsSM) { + // nobody there to receive the message + return; + } + QLocalSocket socket(this); + + bool connected = false; + for (int i = 0; i < 2 && !connected; ++i) { + if (i > 0) { +#ifdef _WIN32 + Sleep(250); +#else + QThread::msleep(250); +#endif + } + + // other process may be just starting up + socket.connectToServer(s_Key, QIODevice::WriteOnly); + connected = socket.waitForConnected(s_Timeout); + } + + if (!connected) { + reportError( + tr("failed to connect to running process: %1").arg(socket.errorString())); + return; + } + + socket.write(message.toUtf8()); + if (!socket.waitForBytesWritten(s_Timeout)) { + if (socket.bytesToWrite()) { + reportError(tr("failed to communicate with running process: %1") + .arg(socket.errorString())); + } + } + + socket.disconnectFromServer(); + socket.waitForDisconnected(); +} + +void MOMultiProcess::receiveMessage() +{ + QLocalSocket* socket = m_Server.nextPendingConnection(); + if (!socket) { + return; + } + + if (!socket->waitForReadyRead(s_Timeout)) { + // check if there are bytes available; if so, it probably means the data was + // already received by the time waitForReadyRead() was called and the + // connection has been closed + const auto av = socket->bytesAvailable(); + + if (av <= 0) { + MOBase::log::error("failed to receive data from secondary process: {}", + socket->errorString()); + + reportError(tr("failed to receive data from secondary process: %1") + .arg(socket->errorString())); + return; + } + } + + QString message = QString::fromUtf8(socket->readAll().constData()); + emit messageSent(message); + socket->disconnectFromServer(); +} diff --git a/src/src/multiprocess.h b/src/src/multiprocess.h new file mode 100644 index 0000000..38261c4 --- /dev/null +++ b/src/src/multiprocess.h @@ -0,0 +1,62 @@ +#ifndef MODORGANIZER_MOMULTIPROCESS_INCLUDED +#define MODORGANIZER_MOMULTIPROCESS_INCLUDED + +#include +#include +#include + +/** + * used to ensure only a single process of Mod Organizer is started and to + * allow ephemeral processes to send messages to the primary (visible) one. + * This way, other processes can start a download in the primary one + **/ +class MOMultiProcess : public QObject +{ + Q_OBJECT + +public: + // `allowMultiple`: if another process is running, run this one + // disconnected from the shared memory + explicit MOMultiProcess(bool allowMultiple, QObject* parent = 0); + + /** + * @return true if this process's job is to forward data to the primary + * process through shared memory + **/ + bool ephemeral() const { return m_Ephemeral; } + + // returns true if this is not the primary process, but was allowed because + // of the AllowMultiple flag + // + bool secondary() const { return !m_Ephemeral && !m_OwnsSM; } + + /** + * send a message to the primary process. This can be used to transmit download urls + * + * @param message message to send + **/ + void sendMessage(const QString& message); + +signals: + + /** + * @brief emitted when an ephemeral process has sent a message (to us) + * + * @param message the message we received + **/ + void messageSent(const QString& message); + +public slots: + +private slots: + + void receiveMessage(); + +private: + bool m_Ephemeral; + bool m_OwnsSM; + QSharedMemory m_SharedMem; + QLocalServer m_Server; +}; + +#endif // MODORGANIZER_MOMULTIPROCESS_INCLUDED diff --git a/src/src/nexusinterface.cpp b/src/src/nexusinterface.cpp new file mode 100644 index 0000000..3dd1030 --- /dev/null +++ b/src/src/nexusinterface.cpp @@ -0,0 +1,1311 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "nexusinterface.h" + +#include "bbcode.h" +#include "iplugingame.h" +#include "nxmaccessmanager.h" +#include "selectiondialog.h" +#include "settings.h" +#include "shared/util.h" +#include +#include +#include + +#include +#include +#include +#include + +#include + +using namespace MOBase; +using namespace MOShared; + +void throttledWarning(const APIUserAccount& user) +{ + log::error("You have fewer than {} requests remaining ({}). Only downloads and " + "login validation are being allowed.", + APIUserAccount::ThrottleThreshold, user.remainingRequests()); +} + +NexusBridge::NexusBridge(PluginContainer* pluginContainer, const QString& subModule) + : m_Interface(&NexusInterface::instance()), m_SubModule(subModule) +{} + +void NexusBridge::requestDescription(QString gameName, int modID, QVariant userData) +{ + m_RequestIDs.insert( + m_Interface->requestDescription(gameName, modID, this, userData, m_SubModule)); +} + +void NexusBridge::requestFiles(QString gameName, int modID, QVariant userData) +{ + m_RequestIDs.insert( + m_Interface->requestFiles(gameName, modID, this, userData, m_SubModule)); +} + +void NexusBridge::requestFileInfo(QString gameName, int modID, int fileID, + QVariant userData) +{ + m_RequestIDs.insert(m_Interface->requestFileInfo(gameName, modID, fileID, this, + userData, m_SubModule)); +} + +void NexusBridge::requestDownloadURL(QString gameName, int modID, int fileID, + QVariant userData) +{ + m_RequestIDs.insert(m_Interface->requestDownloadURL(gameName, modID, fileID, this, + userData, m_SubModule)); +} + +void NexusBridge::requestToggleEndorsement(QString gameName, int modID, + QString modVersion, bool endorse, + QVariant userData) +{ + m_RequestIDs.insert(m_Interface->requestToggleEndorsement( + gameName, modID, modVersion, endorse, this, userData, m_SubModule)); +} + +void NexusBridge::requestToggleTracking(QString gameName, int modID, bool track, + QVariant userData) +{ + m_RequestIDs.insert(m_Interface->requestToggleTracking(gameName, modID, track, this, + userData, m_SubModule)); +} + +void NexusBridge::requestGameInfo(QString gameName, QVariant userData) +{ + m_RequestIDs.insert( + m_Interface->requestGameInfo(gameName, this, userData, m_SubModule)); +} + +void NexusBridge::nxmDescriptionAvailable(QString gameName, int modID, + QVariant userData, QVariant resultData, + int requestID) +{ + std::set::iterator iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + + emit descriptionAvailable(gameName, modID, userData, resultData); + } +} + +void NexusBridge::nxmFilesAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID) +{ + std::set::iterator iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + + QList fileInfoList; + + QVariantMap resultInfo = resultData.toMap(); + QList resultList = resultInfo["files"].toList(); + + for (const QVariant& file : resultList) { + ModRepositoryFileInfo temp; + QVariantMap fileInfo = file.toMap(); + temp.uri = fileInfo["file_name"].toString(); + temp.name = fileInfo["name"].toString(); + temp.description = BBCode::convertToHTML(fileInfo["description"].toString()); + temp.version = VersionInfo(fileInfo["version"].toString()); + temp.categoryID = fileInfo["category_id"].toInt(); + temp.fileID = fileInfo["file_id"].toInt(); + temp.fileSize = fileInfo["size"].toInt(); + temp.author = fileInfo["author"].toString(); + temp.uploader = fileInfo["uploaded_by"].toString(); + temp.uploaderUrl = fileInfo["uploaded_users_profile_url"].toString(); + fileInfoList.append(&temp); + } + + emit filesAvailable(gameName, modID, userData, fileInfoList); + } +} + +void NexusBridge::nxmFileInfoAvailable(QString gameName, int modID, int fileID, + QVariant userData, QVariant resultData, + int requestID) +{ + std::set::iterator iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit fileInfoAvailable(gameName, modID, fileID, userData, resultData); + } +} + +void NexusBridge::nxmDownloadURLsAvailable(QString gameName, int modID, int fileID, + QVariant userData, QVariant resultData, + int requestID) +{ + std::set::iterator iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit downloadURLsAvailable(gameName, modID, fileID, userData, resultData); + } +} + +void NexusBridge::nxmEndorsementsAvailable(QVariant userData, QVariant resultData, + int requestID) +{ + std::set::iterator iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit endorsementsAvailable(userData, resultData); + } +} + +void NexusBridge::nxmEndorsementToggled(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID) +{ + std::set::iterator iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit endorsementToggled(gameName, modID, userData, resultData); + } +} + +void NexusBridge::nxmTrackedModsAvailable(QVariant userData, QVariant resultData, + int requestID) +{ + std::set::iterator iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit trackedModsAvailable(userData, resultData); + } +} + +void NexusBridge::nxmTrackingToggled(QString gameName, int modID, QVariant userData, + bool tracked, int requestID) +{ + std::set::iterator iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit trackingToggled(gameName, modID, userData, tracked); + } +} + +void NexusBridge::nxmGameInfoAvailable(QString gameName, QVariant userData, + QVariant resultData, int requestID) +{ + std::set::iterator iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit gameInfoAvailable(gameName, userData, resultData); + } +} + +void NexusBridge::nxmRequestFailed(QString gameName, int modID, int fileID, + QVariant userData, int requestID, int errorCode, + const QString& errorMessage) +{ + std::set::iterator iter = m_RequestIDs.find(requestID); + if (iter != m_RequestIDs.end()) { + m_RequestIDs.erase(iter); + emit requestFailed(gameName, modID, fileID, userData, errorCode, errorMessage); + } +} + +QAtomicInt NexusInterface::NXMRequestInfo::s_NextID(0); + +APILimits NexusInterface::defaultAPILimits() +{ + // https://app.swaggerhub.com/apis-docs/NexusMods/nexus-mods_public_api_params_in_form_data/1.0#/ + const int MaxDaily = 2500; + const int MaxHourly = 100; + + APILimits limits; + + limits.maxDailyRequests = MaxDaily; + limits.remainingDailyRequests = MaxDaily; + limits.maxHourlyRequests = MaxHourly; + limits.remainingHourlyRequests = MaxHourly; + + return limits; +} + +APILimits NexusInterface::parseLimits(const QNetworkReply* reply) +{ + return parseLimits(reply->rawHeaderPairs()); +} + +APILimits +NexusInterface::parseLimits(const QList& headers) +{ + APILimits limits; + + for (const auto& pair : headers) { + const auto name = QString(pair.first).toLower(); + + if (name == "x-rl-daily-limit") { + limits.maxDailyRequests = pair.second.toInt(); + } else if (name == "x-rl-daily-remaining") { + limits.remainingDailyRequests = pair.second.toInt(); + } else if (name == "x-rl-hourly-limit") { + limits.maxHourlyRequests = pair.second.toInt(); + } else if (name == "x-rl-hourly-remaining") { + limits.remainingHourlyRequests = pair.second.toInt(); + } + } + + return limits; +} + +static NexusInterface* g_instance = nullptr; + +NexusInterface::NexusInterface(Settings* s) : m_PluginContainer(nullptr) +{ + MO_ASSERT(!g_instance); + g_instance = this; + + m_User.limits(defaultAPILimits()); + + m_AccessManager = new NXMAccessManager(this, s, createVersionInfo().string()); + + m_DiskCache = new QNetworkDiskCache(this); + + connect(m_AccessManager, &NXMAccessManager::requestNXMDownload, this, + &NexusInterface::downloadRequestedNXM); + + connect(m_AccessManager, &NXMAccessManager::credentialsReceived, this, + &NexusInterface::setUserAccount); +} + +NexusInterface::~NexusInterface() +{ + cleanup(); + + MO_ASSERT(g_instance == this); + g_instance = nullptr; +} + +NXMAccessManager* NexusInterface::getAccessManager() +{ + return m_AccessManager; +} + +NexusInterface& NexusInterface::instance() +{ + MO_ASSERT(g_instance); + return *g_instance; +} + +void NexusInterface::setCacheDirectory(const QString& directory) +{ + m_DiskCache->setCacheDirectory(directory); + m_AccessManager->setCache(m_DiskCache); +} + +void NexusInterface::loginCompleted() +{ + nextRequest(); +} + +void NexusInterface::setUserAccount(const APIUserAccount& user) +{ + m_User = user; + emit requestsChanged(getAPIStats(), m_User); +} + +void NexusInterface::interpretNexusFileName(const QString& fileName, QString& modName, + int& modID, bool query) +{ + // guess the mod name from the file name + static const QRegularExpression complex( + R"(^([a-zA-Z0-9_'"\-.() ]*?)([-_ ][VvRr]+[0-9]+(?:(?:[\.][0-9]+){0,2}|(?:[_][0-9]+){0,2}|(?:[-.][0-9]+){0,2})?[ab]?)??-([1-9][0-9]+)?-.*?\.(zip|rar|7z))"); + // complex regex explanation: + // group 1: modname. + // group 2: optional version, + // assumed to start with v (empty most of the time). + // group 3: NexusId, + // assumed wrapped in "-", will miss single digit IDs for better accuracy. + // If no id is present the whole regex does not match. + static const QRegularExpression simple(R"(^[^a-zA-Z]*([a-zA-Z_ ]+))"); + auto complexMatch = complex.match(fileName); + auto simpleMatch = simple.match(fileName); + + // assume not found + modID = -1; + + if (complexMatch.hasMatch()) { + modName = complexMatch.captured(1); + if (!query && !complexMatch.captured(3).isNull()) { + modID = complexMatch.captured(3).toInt(); + } + } else if (simpleMatch.hasMatch()) { + modName = simpleMatch.captured(0); + } else { + modName.clear(); + } + + if (query) { + SelectionDialog selection(tr("Please pick the mod ID for \"%1\"").arg(fileName)); + int index = 0; + auto splits = fileName.split(QRegularExpression("[^0-9]"), Qt::KeepEmptyParts); + for (auto substr : splits) { + bool ok = false; + int value = substr.toInt(&ok); + if (ok) { + QString highlight(fileName); + highlight.insert(index, " *"); + highlight.insert(index + substr.length() + 2, "* "); + + QStringList choice; + choice << substr; + choice << (index > 0 ? fileName.left(index - 1) : substr); + selection.addChoice(substr, highlight, choice); + } + index += substr.length() + 1; + } + + if (selection.numChoices() > 0) { + if (selection.exec() == QDialog::Accepted) { + auto choice = selection.getChoiceData().toStringList(); + modID = choice.at(0).toInt(); + modName = choice.at(1); + modName = modName.replace('_', ' ').trimmed(); + log::debug("user selected mod ID {} and mod name \"{}\"", modID, modName); + } else { + log::debug("user canceled mod ID selection"); + } + } else { + log::debug("no possible mod IDs found in file name"); + } + } +} + +bool NexusInterface::isURLGameRelated(const QUrl& url) const +{ + QString const name(url.toString()); + return name.startsWith(getGameURL("") + "/") || + name.startsWith(getOldModsURL("") + "/"); +} + +QString NexusInterface::getGameURL(QString gameName) const +{ + IPluginGame* game = getGame(gameName); + if (game != nullptr) { + QString gameNexusName = game->gameNexusName().toLower(); + if (gameNexusName.isEmpty()) { + return ""; + } else { + return "https://www.nexusmods.com/" + gameNexusName; + } + } else { + log::error("getGameURL can't find plugin for {}", gameName); + return ""; + } +} + +QString NexusInterface::getOldModsURL(QString gameName) const +{ + IPluginGame* game = getGame(gameName); + if (game != nullptr) { + return "https://" + game->gameNexusName().toLower() + ".nexusmods.com/mods"; + } else { + log::error("getOldModsURL can't find plugin for {}", gameName); + return ""; + } +} + +QString NexusInterface::getModURL(int modID, QString gameName = "") const +{ + return QString("%1/mods/%2").arg(getGameURL(gameName)).arg(modID); +} + +std::vector> +NexusInterface::getGameChoices(const MOBase::IPluginGame* game) +{ + std::vector> choices; + choices.push_back( + std::pair(game->gameShortName(), game->gameName())); + for (QString gameName : game->validShortNames()) { + for (auto gamePlugin : m_PluginContainer->plugins()) { + if (gamePlugin->gameShortName().compare(gameName, Qt::CaseInsensitive) == 0) { + choices.push_back(std::pair(gamePlugin->gameShortName(), + gamePlugin->gameName())); + break; + } + } + } + return choices; +} + +bool NexusInterface::isModURL(int modID, const QString& url) const +{ + if (QUrl(url) == QUrl(getModURL(modID))) { + return true; + } + // Try the alternate (old style) mod name + QString alt = QString("%1/%2").arg(getOldModsURL("")).arg(modID); + return QUrl(alt) == QUrl(url); +} + +void NexusInterface::setPluginContainer(PluginContainer* pluginContainer) +{ + m_PluginContainer = pluginContainer; +} + +int NexusInterface::requestDescription(QString gameName, int modID, QObject* receiver, + QVariant userData, const QString& subModule, + MOBase::IPluginGame const* game) +{ + NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_DESCRIPTION, userData, + subModule, game); + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmDescriptionAvailable(QString, int, QVariant, QVariant, int)), + receiver, + SLOT(nxmDescriptionAvailable(QString, int, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestModInfo(QString gameName, int modID, QObject* receiver, + QVariant userData, const QString& subModule, + MOBase::IPluginGame const* game) +{ + if (m_User.shouldThrottle()) { + throttledWarning(m_User); + return -1; + } + + NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_MODINFO, userData, subModule, + game); + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmModInfoAvailable(QString, int, QVariant, QVariant, int)), + receiver, SLOT(nxmModInfoAvailable(QString, int, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestUpdateInfo(QString gameName, + NexusInterface::UpdatePeriod period, + QObject* receiver, QVariant userData, + const QString& subModule, + const MOBase::IPluginGame* game) +{ + if (m_User.shouldThrottle()) { + throttledWarning(m_User); + return -1; + } + + NXMRequestInfo requestInfo(period, NXMRequestInfo::TYPE_CHECKUPDATES, userData, + subModule, game); + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmUpdateInfoAvailable(QString, QVariant, QVariant, int)), + receiver, SLOT(nxmUpdateInfoAvailable(QString, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestUpdates(const int& modID, QObject* receiver, + QVariant userData, QString gameName, + const QString& subModule) +{ + if (m_User.shouldThrottle()) { + throttledWarning(m_User); + return -1; + } + + IPluginGame* game = getGame(gameName); + if (game == nullptr) { + log::error("requestUpdates can't find plugin for {}", gameName); + return -1; + } + + NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_GETUPDATES, userData, + subModule, game); + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmUpdatesAvailable(QString, int, QVariant, QVariant, int)), + receiver, SLOT(nxmUpdatesAvailable(QString, int, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +void NexusInterface::fakeFiles() +{ + static int id = 42; + + QVariantList result; + QVariantMap fileMap; + fileMap["uri"] = "fakeURI"; + fileMap["name"] = "fakeName"; + fileMap["description"] = "fakeDescription"; + fileMap["version"] = "1.0.0"; + fileMap["category_id"] = "1"; + fileMap["id"] = "1"; + fileMap["size"] = "512"; + fileMap["author"] = "fakeAuthor"; + fileMap["uploaded_by"] = "fakeUploader"; + fileMap["uploaded_users_profile_url"] = "https://fakeuploader.com"; + result.append(fileMap); + + emit nxmFilesAvailable("fakeGame", 1234, "fake", result, id++); +} + +int NexusInterface::requestFiles(QString gameName, int modID, QObject* receiver, + QVariant userData, const QString& subModule, + MOBase::IPluginGame const* game) +{ + NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_FILES, userData, subModule, + game); + m_RequestQueue.enqueue(requestInfo); + connect(this, SIGNAL(nxmFilesAvailable(QString, int, QVariant, QVariant, int)), + receiver, SLOT(nxmFilesAvailable(QString, int, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestFileInfo(QString gameName, int modID, int fileID, + QObject* receiver, QVariant userData, + const QString& subModule) +{ + IPluginGame* gamePlugin = getGame(gameName); + if (gamePlugin == nullptr) { + log::error("requestFileInfo can't find plugin for {}", gameName); + return -1; + } + + NXMRequestInfo requestInfo(modID, fileID, NXMRequestInfo::TYPE_FILEINFO, userData, + subModule, gamePlugin); + m_RequestQueue.enqueue(requestInfo); + + connect( + this, SIGNAL(nxmFileInfoAvailable(QString, int, int, QVariant, QVariant, int)), + receiver, SLOT(nxmFileInfoAvailable(QString, int, int, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestDownloadURL(QString gameName, int modID, int fileID, + QObject* receiver, QVariant userData, + const QString& subModule, + MOBase::IPluginGame const* game) +{ + NXMRequestInfo requestInfo(modID, fileID, NXMRequestInfo::TYPE_DOWNLOADURL, userData, + subModule, game); + m_RequestQueue.enqueue(requestInfo); + + connect(this, + SIGNAL(nxmDownloadURLsAvailable(QString, int, int, QVariant, QVariant, int)), + receiver, + SLOT(nxmDownloadURLsAvailable(QString, int, int, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestEndorsementInfo(QObject* receiver, QVariant userData, + const QString& subModule) +{ + NXMRequestInfo requestInfo(NXMRequestInfo::TYPE_ENDORSEMENTS, userData, subModule); + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmEndorsementsAvailable(QVariant, QVariant, int)), receiver, + SLOT(nxmEndorsementsAvailable(QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestToggleEndorsement(QString gameName, int modID, + QString modVersion, bool endorse, + QObject* receiver, QVariant userData, + const QString& subModule, + MOBase::IPluginGame const* game) +{ + if (m_User.shouldThrottle()) { + throttledWarning(m_User); + return -1; + } + + NXMRequestInfo requestInfo(modID, modVersion, NXMRequestInfo::TYPE_TOGGLEENDORSEMENT, + userData, subModule, game); + requestInfo.m_Endorse = endorse; + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmEndorsementToggled(QString, int, QVariant, QVariant, int)), + receiver, SLOT(nxmEndorsementToggled(QString, int, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestTrackingInfo(QObject* receiver, QVariant userData, + const QString& subModule) +{ + NXMRequestInfo requestInfo(NXMRequestInfo::TYPE_TRACKEDMODS, userData, subModule); + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmTrackedModsAvailable(QVariant, QVariant, int)), receiver, + SLOT(nxmTrackedModsAvailable(QVariant, QVariant, int)), Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestToggleTracking(QString gameName, int modID, bool track, + QObject* receiver, QVariant userData, + const QString& subModule, + MOBase::IPluginGame const* game) +{ + if (m_User.shouldThrottle()) { + throttledWarning(m_User); + return -1; + } + + NXMRequestInfo requestInfo(modID, NXMRequestInfo::TYPE_TOGGLETRACKING, userData, + subModule, game); + requestInfo.m_Track = track; + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmTrackingToggled(QString, int, QVariant, bool, int)), receiver, + SLOT(nxmTrackingToggled(QString, int, QVariant, bool, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestGameInfo(QString gameName, QObject* receiver, + QVariant userData, const QString& subModule, + MOBase::IPluginGame const* game) +{ + if (m_User.shouldThrottle()) { + throttledWarning(m_User); + return -1; + } + + NXMRequestInfo requestInfo(NXMRequestInfo::TYPE_GAMEINFO, userData, subModule, game); + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmGameInfoAvailable(QString, QVariant, QVariant, int)), + receiver, SLOT(nxmGameInfoAvailable(QString, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +int NexusInterface::requestInfoFromMd5(QString gameName, QByteArray& hash, + QObject* receiver, QVariant userData, + const QString& subModule, + MOBase::IPluginGame const* game) +{ + NXMRequestInfo requestInfo(hash, NXMRequestInfo::TYPE_FILEINFO_MD5, userData, + subModule, game); + requestInfo.m_Hash = hash; + requestInfo.m_AllowedErrors[QNetworkReply::NetworkError::ContentNotFoundError].append( + 404); + requestInfo.m_IgnoreGenericErrorHandler = true; + m_RequestQueue.enqueue(requestInfo); + + connect(this, SIGNAL(nxmFileInfoFromMd5Available(QString, QVariant, QVariant, int)), + receiver, SLOT(nxmFileInfoFromMd5Available(QString, QVariant, QVariant, int)), + Qt::UniqueConnection); + + connect( + this, SIGNAL(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + receiver, SLOT(nxmRequestFailed(QString, int, int, QVariant, int, int, QString)), + Qt::UniqueConnection); + + nextRequest(); + return requestInfo.m_ID; +} + +IPluginGame* NexusInterface::getGame(QString gameName) const +{ + auto gamePlugins = m_PluginContainer->plugins(); + IPluginGame* gamePlugin = qApp->property("managed_game").value(); + for (auto plugin : gamePlugins) { + if (plugin != nullptr && + plugin->gameShortName().compare(gameName, Qt::CaseInsensitive) == 0) { + gamePlugin = plugin; + break; + } + } + return gamePlugin; +} + +void NexusInterface::cleanup() +{ + m_AccessManager = nullptr; + m_DiskCache = nullptr; +} + +void NexusInterface::clearCache() +{ + m_DiskCache->clear(); + m_AccessManager->clearCookies(); +} + +void NexusInterface::nextRequest() +{ + if ((m_ActiveRequest.size() >= MAX_ACTIVE_DOWNLOADS) || m_RequestQueue.isEmpty()) { + return; + } + + if (!getAccessManager()->validated()) { + if (!getAccessManager()->validateAttempted()) { + emit needLogin(); + return; + } else if (getAccessManager()->validateWaiting()) { + return; + } else { + log::error( + "{}", + tr("You must authorize MO2 in Settings -> Nexus to use the Nexus API.")); + } + } + + if (m_User.exhausted()) { + m_RequestQueue.clear(); + QTime time = QTime::currentTime(); + QTime targetTime; + targetTime.setHMS((time.hour() + 1) % 23, 5, 0); + QString warning = tr("You've exceeded the Nexus API rate limit and requests are " + "now being throttled. " + "Your next batch of requests will be available in " + "approximately %1 minutes and %2 seconds.") + .arg(time.secsTo(targetTime) / 60) + .arg(time.secsTo(targetTime) % 60); + + log::warn("{}", warning); + return; + } + + NXMRequestInfo info = m_RequestQueue.dequeue(); + info.m_Timeout = new QTimer(this); + info.m_Timeout->setInterval(60000); + + QJsonObject postObject; + QJsonDocument postData(postObject); + bool requestIsDelete = false; + + QString url; + if (!info.m_Reroute) { + bool hasParams = false; + switch (info.m_Type) { + case NXMRequestInfo::TYPE_DESCRIPTION: + case NXMRequestInfo::TYPE_MODINFO: { + url = QString("%1/games/%2/mods/%3") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(info.m_ModID); + } break; + case NXMRequestInfo::TYPE_CHECKUPDATES: { + QString period; + switch (info.m_UpdatePeriod) { + case UpdatePeriod::DAY: + period = "1d"; + break; + case UpdatePeriod::WEEK: + period = "1w"; + break; + case UpdatePeriod::MONTH: + period = "1m"; + break; + } + url = QString("%1/games/%2/mods/updated?period=%3") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(period); + } break; + case NXMRequestInfo::TYPE_FILES: + case NXMRequestInfo::TYPE_GETUPDATES: { + url = QString("%1/games/%2/mods/%3/files") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(info.m_ModID); + } break; + case NXMRequestInfo::TYPE_FILEINFO: { + url = QString("%1/games/%2/mods/%3/files/%4") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(info.m_ModID) + .arg(info.m_FileID); + } break; + case NXMRequestInfo::TYPE_DOWNLOADURL: { + ModRepositoryFileInfo* fileInfo = qobject_cast( + qvariant_cast(info.m_UserData)); + if (m_User.type() == APIUserAccountTypes::Premium) { + url = QString("%1/games/%2/mods/%3/files/%4/download_link") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(info.m_ModID) + .arg(info.m_FileID); + } else if (!fileInfo->nexusKey.isEmpty() && fileInfo->nexusExpires && + fileInfo->nexusDownloadUser == m_User.id().toInt()) { + url = QString("%1/games/%2/mods/%3/files/%4/download_link?key=%5&expires=%6") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(info.m_ModID) + .arg(info.m_FileID) + .arg(fileInfo->nexusKey) + .arg(fileInfo->nexusExpires); + } else { + log::warn("{}", tr("Aborting download: Either you clicked on a premium-only " + "link and your account is not premium, " + "or the download link was generated by a different account " + "than the one stored in Mod Organizer.")); + return; + } + } break; + case NXMRequestInfo::TYPE_ENDORSEMENTS: { + url = QString("%1/user/endorsements").arg(info.m_URL); + } break; + case NXMRequestInfo::TYPE_TOGGLEENDORSEMENT: { + QString endorse = info.m_Endorse ? "endorse" : "abstain"; + url = QString("%1/games/%2/mods/%3/%4") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(info.m_ModID) + .arg(endorse); + postObject.insert("Version", info.m_ModVersion); + postData.setObject(postObject); + } break; + case NXMRequestInfo::TYPE_TOGGLETRACKING: { + url = QStringLiteral("%1/user/tracked_mods?domain_name=%2") + .arg(info.m_URL) + .arg(info.m_GameName); + postObject.insert("mod_id", info.m_ModID); + postData.setObject(postObject); + requestIsDelete = !info.m_Track; + } break; + case NXMRequestInfo::TYPE_TRACKEDMODS: { + url = QStringLiteral("%1/user/tracked_mods").arg(info.m_URL); + } break; + case NXMRequestInfo::TYPE_FILEINFO_MD5: { + url = QStringLiteral("%1/games/%2/mods/md5_search/%3") + .arg(info.m_URL) + .arg(info.m_GameName) + .arg(QString(info.m_Hash.toHex())); + } break; + case NXMRequestInfo::TYPE_GAMEINFO: { + url = QStringLiteral("%1/games/%2").arg(info.m_URL).arg(info.m_GameName); + } + } + } else { + url = info.m_URL; + } + QNetworkRequest request(url); + request.setAttribute(QNetworkRequest::CacheSaveControlAttribute, false); + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, + QNetworkRequest::AlwaysNetwork); + request.setRawHeader("APIKEY", m_User.apiKey().toUtf8()); + request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, + m_AccessManager->userAgent(info.m_SubModule)); + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, + "application/json"); + request.setRawHeader("Protocol-Version", "1.0.0"); + request.setRawHeader("Application-Name", "MO2"); + request.setRawHeader("Application-Version", + QApplication::applicationVersion().toUtf8()); + + if (postData.object().isEmpty()) { + if (!requestIsDelete) { + info.m_Reply = m_AccessManager->get(request); + } else { + info.m_Reply = m_AccessManager->deleteResource(request); + } + } else if (!requestIsDelete) { + info.m_Reply = m_AccessManager->post(request, postData.toJson()); + } else { + // Qt doesn't support DELETE with a payload as that's technically against the HTTP + // standard... + info.m_Reply = + m_AccessManager->sendCustomRequest(request, "DELETE", postData.toJson()); + } + + connect(info.m_Reply, SIGNAL(finished()), this, SLOT(requestFinished())); + if (!info.m_IgnoreGenericErrorHandler) + connect(info.m_Reply, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this, + SLOT(requestError(QNetworkReply::NetworkError))); + connect(info.m_Timeout, SIGNAL(timeout()), this, SLOT(requestTimeout())); + info.m_Timeout->start(); + m_ActiveRequest.push_back(info); +} + +void NexusInterface::downloadRequestedNXM(const QString& url) +{ + emit requestNXMDownload(url); +} + +void NexusInterface::requestFinished(std::list::iterator iter) +{ + QNetworkReply* reply = iter->m_Reply; + + auto error = reply->error(); + if (error != QNetworkReply::NoError) { + int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QString errorMsg = reply->errorString(); + + if (iter->m_AllowedErrors.contains(error) && + iter->m_AllowedErrors[error].contains(statusCode)) { + // These errors are allows to silently happen. They should be handled in + // nxmRequestFailed below. + } else if (statusCode == 429) { + m_User.limits(parseLimits(reply)); + + if (!m_User.exhausted()) { + log::warn("You appear to be making requests to the Nexus API too quickly and " + "are being throttled. Please inform the MO2 team."); + } else { + log::warn("All API requests have been consumed and are now being denied."); + } + + emit requestsChanged(getAPIStats(), m_User); + log::warn("Error: {}", errorMsg); + } else { + QByteArray data = reply->readAll(); + if (!data.isEmpty()) { + QJsonDocument responseDoc = QJsonDocument::fromJson(data); + if (!responseDoc.isNull()) { + auto result = responseDoc.toVariant().toMap(); + auto error = result.find("error"); + if (error != result.end()) + errorMsg = result.value("error").toString(); + } + } + } + emit nxmRequestFailed(iter->m_GameName, iter->m_ModID, iter->m_FileID, + iter->m_UserData, iter->m_ID, statusCode, errorMsg); + } else { + int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (statusCode == 301) { + // redirect request, return request to queue + iter->m_URL = + reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toString(); + iter->m_Reroute = true; + m_RequestQueue.enqueue(*iter); + // nextRequest(); + return; + } + QByteArray data = reply->readAll(); + if (data.isNull() || data.isEmpty() || (strcmp(data.constData(), "null") == 0)) { + QString nexusError(reply->rawHeader("NexusErrorInfo")); + if (nexusError.length() == 0) { + nexusError = tr("empty response"); + } + log::debug("nexus error: {}", nexusError); + emit nxmRequestFailed(iter->m_GameName, iter->m_ModID, iter->m_FileID, + iter->m_UserData, iter->m_ID, reply->error(), nexusError); + } else { + QJsonDocument responseDoc = QJsonDocument::fromJson(data); + if (!responseDoc.isNull()) { + QVariant result = responseDoc.toVariant(); + switch (iter->m_Type) { + case NXMRequestInfo::TYPE_DESCRIPTION: { + emit nxmDescriptionAvailable(iter->m_GameName, iter->m_ModID, + iter->m_UserData, result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_MODINFO: { + emit nxmModInfoAvailable(iter->m_GameName, iter->m_ModID, iter->m_UserData, + result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_CHECKUPDATES: { + emit nxmUpdateInfoAvailable(iter->m_GameName, iter->m_UserData, result, + iter->m_ID); + } break; + case NXMRequestInfo::TYPE_FILES: { + emit nxmFilesAvailable(iter->m_GameName, iter->m_ModID, iter->m_UserData, + result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_GETUPDATES: { + emit nxmUpdatesAvailable(iter->m_GameName, iter->m_ModID, iter->m_UserData, + result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_FILEINFO: { + emit nxmFileInfoAvailable(iter->m_GameName, iter->m_ModID, iter->m_FileID, + iter->m_UserData, result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_DOWNLOADURL: { + emit nxmDownloadURLsAvailable(iter->m_GameName, iter->m_ModID, iter->m_FileID, + iter->m_UserData, result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_ENDORSEMENTS: { + emit nxmEndorsementsAvailable(iter->m_UserData, result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_TOGGLEENDORSEMENT: { + emit nxmEndorsementToggled(iter->m_GameName, iter->m_ModID, iter->m_UserData, + result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_TOGGLETRACKING: { + auto results = result.toMap(); + auto message = results["message"].toString(); + if (message.contains( + QRegularExpression("User [0-9]+ is already Tracking Mod: [0-9]+")) || + message.contains( + QRegularExpression("User [0-9]+ is now Tracking Mod: [0-9]+"))) { + emit nxmTrackingToggled(iter->m_GameName, iter->m_ModID, iter->m_UserData, + true, iter->m_ID); + } else if (message.contains(QRegularExpression( + "User [0-9]+ is no longer tracking [0-9]+")) || + message.contains(QRegularExpression( + "Users is not tracking mod. Unable to untrack."))) { + emit nxmTrackingToggled(iter->m_GameName, iter->m_ModID, iter->m_UserData, + false, iter->m_ID); + } + } break; + case NXMRequestInfo::TYPE_TRACKEDMODS: { + emit nxmTrackedModsAvailable(iter->m_UserData, result, iter->m_ID); + } break; + case NXMRequestInfo::TYPE_FILEINFO_MD5: { + emit nxmFileInfoFromMd5Available(iter->m_GameName, iter->m_UserData, result, + iter->m_ID); + } break; + case NXMRequestInfo::TYPE_GAMEINFO: { + emit nxmGameInfoAvailable(iter->m_GameName, iter->m_UserData, result, + iter->m_ID); + } break; + } + + m_User.limits(parseLimits(reply)); + emit requestsChanged(getAPIStats(), m_User); + } else { + emit nxmRequestFailed(iter->m_GameName, iter->m_ModID, iter->m_FileID, + iter->m_UserData, iter->m_ID, reply->error(), + tr("invalid response")); + } + } + } +} + +void NexusInterface::requestFinished() +{ + QNetworkReply* reply = static_cast(sender()); + for (std::list::iterator iter = m_ActiveRequest.begin(); + iter != m_ActiveRequest.end(); ++iter) { + if (iter->m_Reply == reply) { + iter->m_Timeout->stop(); + iter->m_Timeout->deleteLater(); + requestFinished(iter); + iter->m_Reply->deleteLater(); + m_ActiveRequest.erase(iter); + nextRequest(); + return; + } + } +} + +void NexusInterface::requestError(QNetworkReply::NetworkError) +{ + QNetworkReply* reply = qobject_cast(sender()); + if (reply == nullptr) { + log::warn("invalid sender type"); + return; + } + + log::error("request ({}) error: {} ({})", reply->url().toString(), + reply->errorString(), reply->error()); +} + +void NexusInterface::requestTimeout() +{ + QTimer* timer = qobject_cast(sender()); + if (timer == nullptr) { + log::warn("invalid sender type"); + return; + } + for (std::list::iterator iter = m_ActiveRequest.begin(); + iter != m_ActiveRequest.end(); ++iter) { + if (iter->m_Timeout == timer) { + // this abort causes a "request failed" which cleans up the rest + iter->m_Reply->abort(); + return; + } + } +} + +APIUserAccount NexusInterface::getAPIUserAccount() const +{ + return m_User; +} + +APIStats NexusInterface::getAPIStats() const +{ + APIStats stats; + stats.requestsQueued = m_RequestQueue.size(); + + return stats; +} + +namespace +{ +QString get_management_url() +{ + return "https://api.nexusmods.com/v1"; +} +} // namespace + +NexusInterface::NXMRequestInfo::NXMRequestInfo( + int modID, NexusInterface::NXMRequestInfo::Type type, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game) + : m_ModID(modID), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), + m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), + m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), + m_URL(get_management_url()), m_SubModule(subModule), + m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_Endorse(false), m_Track(false), m_Hash(QByteArray()) +{} + +NexusInterface::NXMRequestInfo::NXMRequestInfo( + int modID, QString modVersion, NexusInterface::NXMRequestInfo::Type type, + QVariant userData, const QString& subModule, MOBase::IPluginGame const* game) + : m_ModID(modID), m_ModVersion(modVersion), m_FileID(0), m_Reply(nullptr), + m_Type(type), m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), + m_Timeout(nullptr), m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), + m_URL(get_management_url()), m_SubModule(subModule), + m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_Endorse(false), m_Track(false), m_Hash(QByteArray()) +{} + +NexusInterface::NXMRequestInfo::NXMRequestInfo(Type type, QVariant userData, + const QString& subModule, + MOBase::IPluginGame const* game) + : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), + m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), + m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), + m_URL(get_management_url()), m_SubModule(subModule), + m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_Endorse(false), m_Track(false), m_Hash(QByteArray()) +{} + +NexusInterface::NXMRequestInfo::NXMRequestInfo( + int modID, int fileID, NexusInterface::NXMRequestInfo::Type type, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game) + : m_ModID(modID), m_ModVersion("0"), m_FileID(fileID), m_Reply(nullptr), + m_Type(type), m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), + m_Timeout(nullptr), m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), + m_URL(get_management_url()), m_SubModule(subModule), + m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_Endorse(false), m_Track(false), m_Hash(QByteArray()) +{} + +NexusInterface::NXMRequestInfo::NXMRequestInfo(Type type, QVariant userData, + const QString& subModule) + : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), + m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), + m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), + m_URL(get_management_url()), m_SubModule(subModule), m_NexusGameID(0), + m_GameName(""), m_Endorse(false), m_Track(false), m_Hash(QByteArray()) +{} + +NexusInterface::NXMRequestInfo::NXMRequestInfo( + UpdatePeriod period, NexusInterface::NXMRequestInfo::Type type, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game) + : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), + m_UpdatePeriod(period), m_UserData(userData), m_Timeout(nullptr), + m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), + m_URL(get_management_url()), m_SubModule(subModule), + m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_Endorse(false), m_Track(false), m_Hash(QByteArray()) +{} + +NexusInterface::NXMRequestInfo::NXMRequestInfo( + QByteArray& hash, NexusInterface::NXMRequestInfo::Type type, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game) + : m_ModID(0), m_ModVersion("0"), m_FileID(0), m_Reply(nullptr), m_Type(type), + m_UpdatePeriod(UpdatePeriod::NONE), m_UserData(userData), m_Timeout(nullptr), + m_Reroute(false), m_ID(s_NextID.fetchAndAddAcquire(1)), + m_URL(get_management_url()), m_SubModule(subModule), + m_NexusGameID(game->nexusGameID()), m_GameName(game->gameNexusName()), + m_Endorse(false), m_Track(false), m_Hash(hash) +{} diff --git a/src/src/nexusinterface.h b/src/src/nexusinterface.h new file mode 100644 index 0000000..eaae29f --- /dev/null +++ b/src/src/nexusinterface.h @@ -0,0 +1,688 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef NEXUSINTERFACE_H +#define NEXUSINTERFACE_H + +#include "apiuseraccount.h" +#include "plugincontainer.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +namespace MOBase +{ +class IPluginGame; +} + +class NexusInterface; +class NXMAccessManager; +class Settings; + +/** + * @brief convenience class to make nxm requests easier + * usually, all objects that started a nxm request will be signaled if one finished. + * Therefore, the objects need to store the id of the requests they started and then + *filter the result. NexusBridge does this automatically. Users connect to the signals + *of NexusBridge they intend to handle and only receive the signals the caused + **/ +class NexusBridge : public MOBase::IModRepositoryBridge +{ + + Q_OBJECT + +public: + NexusBridge(PluginContainer* pluginContainer, const QString& subModule = ""); + + /** + * @brief request description for a mod + * + * @param modID id of the mod caller is interested in + * @param userData user data to be returned with the result + * @param url the url to request from + **/ + virtual void requestDescription(QString gameName, int modID, QVariant userData); + + /** + * @brief request a list of the files belonging to a mod + * + * @param modID id of the mod caller is interested in + * @param userData user data to be returned with the result + **/ + virtual void requestFiles(QString gameName, int modID, QVariant userData); + + /** + * @brief request info about a single file of a mod + * + * @param modID id of the mod caller is interested in + * @param fileID id of the file the caller is interested in + * @param userData user data to be returned with the result + **/ + virtual void requestFileInfo(QString gameName, int modID, int fileID, + QVariant userData); + + /** + * @brief request the download url of a file + * + * @param modID id of the mod caller is interested in + * @param fileID id of the file the caller is interested in + * @param userData user data to be returned with the result + **/ + virtual void requestDownloadURL(QString gameName, int modID, int fileID, + QVariant userData); + + /** + * @brief requestToggleEndorsement + * @param modID id of the mod caller is interested in + * @param userData user data to be returned with the result + */ + virtual void requestToggleEndorsement(QString gameName, int modID, QString modVersion, + bool endorse, QVariant userData); + + /** + * @brief requestToggleTracking + * @param modID id of the mod caller is interested in + * @param userData user data to be returned with the result + */ + virtual void requestToggleTracking(QString gameName, int modID, bool track, + QVariant userData); + + /** + * @brief requestGameInfo + * @param userData user data to be returned with the result + */ + virtual void requestGameInfo(QString gameName, QVariant userData); + +public slots: + + void nxmDescriptionAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID); + void nxmFilesAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID); + void nxmFileInfoAvailable(QString gameName, int modID, int fileID, QVariant userData, + QVariant resultData, int requestID); + void nxmDownloadURLsAvailable(QString gameName, int modID, int fileID, + QVariant userData, QVariant resultData, int requestID); + void nxmEndorsementsAvailable(QVariant userData, QVariant resultData, int requestID); + void nxmEndorsementToggled(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID); + void nxmTrackedModsAvailable(QVariant userData, QVariant resultData, int requestID); + void nxmTrackingToggled(QString gameName, int modID, QVariant userData, bool tracked, + int requestID); + void nxmGameInfoAvailable(QString gameName, QVariant userData, QVariant resultData, + int requestID); + void nxmRequestFailed(QString gameName, int modID, int fileID, QVariant userData, + int requestID, int errorCode, const QString& errorMessage); + +private: + NexusInterface* m_Interface; + QString m_SubModule; + std::set m_RequestIDs; +}; + +/** + * @brief Makes asynchronous requests to the nexus API + * + * This class can be used to make asynchronous requests to the Nexus API. + * Currently, responses are sent to all receivers that have sent a request of the + *relevant type, so the recipient has to filter the response by the id returned when + *making the request + **/ +class NexusInterface : public QObject +{ + Q_OBJECT + +public: + enum UpdatePeriod + { + NONE, + DAY, + WEEK, + MONTH + }; + + // Nexus file category IDs (MAIN, OLD etc), not to be confused with mod categories + // (Armors, Texture etc). + enum FileStatus + { + MAIN = 1, + UPDATE = 2, + OPTIONAL_FILE = 3, // actual string version is "OPTIONAL", but that is already + // defined as a macro in minwindef.h + OLD_VERSION = 4, + MISCELLANEOUS = 5, + REMOVED = 6, + ARCHIVED = 7, + ARCHIVED_HIDDEN = 1000 // Archived files can be hidden by authors so if they aren't + // listed we can assume they were hidden. + }; + +public: + static APILimits defaultAPILimits(); + static APILimits parseLimits(const QNetworkReply* reply); + static APILimits parseLimits(const QList& headers); + + NexusInterface(Settings* s); + ~NexusInterface(); + + static NexusInterface& instance(); + + /** + * @return the access manager object used to connect to nexus + **/ + NXMAccessManager* getAccessManager(); + + /** + * @brief cleanup this interface. this is destructive, afterwards it can't be used + * again + */ + void cleanup(); + + /** + * @brief clear webcache and cookies associated with this access manager + */ + void clearCache(); + + /** + * @brief request description for a mod + * + * @param gameName the game short name to support multiple game sources + * @param modID id of the mod caller is interested in (assumed to be for the current + *game) + * @param receiver the object to receive the result asynchronously via a signal + *(nxmDescriptionAvailable) + * @param userData user data to be returned with the result + * @return int an id to identify the request + **/ + int requestDescription(QString gameName, int modID, QObject* receiver, + QVariant userData, const QString& subModule) + { + return requestDescription(gameName, modID, receiver, userData, subModule, + getGame(gameName)); + } + + /** + * @brief request description for a mod + * + * @param gameName the game short name to support multiple game sources + * @param modID id of the mod caller is interested in + * @param receiver the object to receive the result asynchronously via a signal + *(nxmDescriptionAvailable) + * @param userData user data to be returned with the result + * @param game Game with which the mod is associated + * @return int an id to identify the request + **/ + int requestDescription(QString gameName, int modID, QObject* receiver, + QVariant userData, const QString& subModule, + MOBase::IPluginGame const* game); + + /** + * @brief request description for a mod + * + * @param gameName the game short name to support multiple game sources + * @param modID id of the mod caller is interested in (assumed to be for the current + *game) + * @param receiver the object to receive the result asynchronously via a signal + *(nxmModInfoAvailable) + * @param userData user data to be returned with the result + * @return int an id to identify the request + **/ + int requestModInfo(QString gameName, int modID, QObject* receiver, QVariant userData, + const QString& subModule) + { + return requestModInfo(gameName, modID, receiver, userData, subModule, + getGame(gameName)); + } + + /** + * @brief request mod info + * + * @param gameName the game short name to support multiple game sources + * @param modID id of the mod caller is interested in + * @param receiver the object to receive the result asynchronously via a signal + *(nxmModInfoAvailable) + * @param userData user data to be returned with the result + * @param game Game with which the mod is associated + * @return int an id to identify the request + **/ + int requestModInfo(QString gameName, int modID, QObject* receiver, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game); + + int requestUpdateInfo(QString gameName, NexusInterface::UpdatePeriod period, + QObject* receiver, QVariant userData, const QString& subModule) + { + return requestUpdateInfo(gameName, period, receiver, userData, subModule, + getGame(gameName)); + } + + int requestUpdateInfo(QString gameName, NexusInterface::UpdatePeriod period, + QObject* receiver, QVariant userData, const QString& subModule, + const MOBase::IPluginGame* game); + + /** + * @brief request nexus descriptions for multiple mods at once + * @param modID id of the mod the caller is interested in + * @param receiver the object to receive the result asynchronously via a signal + * (nxmDescriptionAvailable) + * @param userData user data to be returned with the result + * @param gameName the game with which the mods are associated + * @return int an id to identify the request + */ + int requestUpdates(const int& modID, QObject* receiver, QVariant userData, + QString gameName, const QString& subModule); + + /** + * @brief request a list of the files belonging to a mod + * + * @param gameName the game short name to support multiple game sources + * @param modID id of the mod caller is interested in (assumed to be for the current + *game) + * @param receiver the object to receive the result asynchronously via a signal + *(nxmFilesAvailable) + * @param userData user data to be returned with the result + * @return int an id to identify the request + **/ + int requestFiles(QString gameName, int modID, QObject* receiver, QVariant userData, + const QString& subModule) + { + return requestFiles(gameName, modID, receiver, userData, subModule, + getGame(gameName)); + } + + /** + * @brief request a list of the files belonging to a mod + * + * @param gameName the game short name to support multiple game sources + * @param modID id of the mod caller is interested in + * @param receiver the object to receive the result asynchronously via a signal + *(nxmFilesAvailable) + * @param userData user data to be returned with the result + * @param game the game with which the mods are associated + * @return int an id to identify the request + **/ + int requestFiles(QString gameName, int modID, QObject* receiver, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game); + + /** + * @brief request info about a single file of a mod + * + * @param gameName name of the game short name to request the download from + * @param modID id of the mod caller is interested in (assumed to be for the current + *game) + * @param fileID id of the file the caller is interested in + * @param receiver the object to receive the result asynchronously via a signal + *(nxmFilesAvailable) + * @param userData user data to be returned with the result + * @return int an id to identify the request + **/ + int requestFileInfo(QString gameName, int modID, int fileID, QObject* receiver, + QVariant userData, const QString& subModule); + + /** + * @brief request the download url of a file + * + * @param gameName the game short name to support multiple game sources + * @param modID id of the mod caller is interested in (assumed to be for the current + *game) + * @param fileID id of the file the caller is interested in + * @param receiver the object to receive the result asynchronously via a signal + *(nxmFilesAvailable) + * @param userData user data to be returned with the result + * @return int an id to identify the request + **/ + int requestDownloadURL(QString gameName, int modID, int fileID, QObject* receiver, + QVariant userData, const QString& subModule) + { + return requestDownloadURL(gameName, modID, fileID, receiver, userData, subModule, + getGame(gameName)); + } + + /** + * @brief request the download url of a file + * + * @param gameName the game short name to support multiple game sources + * @param modID id of the mod caller is interested in + * @param fileID id of the file the caller is interested in + * @param receiver the object to receive the result asynchronously via a signal + *(nxmFilesAvailable) + * @param userData user data to be returned with the result + * @param game the game with which the mods are associated + * @return int an id to identify the request + **/ + int requestDownloadURL(QString gameName, int modID, int fileID, QObject* receiver, + QVariant userData, const QString& subModule, + MOBase::IPluginGame const* game); + + int requestEndorsementInfo(QObject* receiver, QVariant userData, + const QString& subModule); + + /** + * @param gameName the game short name to support multiple game sources + * @brief toggle endorsement state of the mod + * @param modID id of the mod (assumed to be for the current game) + * @param endorse true if the mod should be endorsed, false for un-endorse + * @param receiver the object to receive the result asynchronously via a signal + * (nxmFilesAvailable) + * @param userData user data to be returned with the result + * @return int an id to identify the request + */ + int requestToggleEndorsement(QString gameName, int modID, QString modVersion, + bool endorse, QObject* receiver, QVariant userData, + const QString& subModule) + { + return requestToggleEndorsement(gameName, modID, modVersion, endorse, receiver, + userData, subModule, getGame(gameName)); + } + + /** + * @param gameName the game short name to support multiple game sources + * @brief toggle endorsement state of the mod + * @param modID id of the mod + * @param endorse true if the mod should be endorsed, false for un-endorse + * @param receiver the object to receive the result asynchronously via a signal + * (nxmFilesAvailable) + * @param userData user data to be returned with the result + * @param game the game with which the mods are associated + * @return int an id to identify the request + */ + int requestToggleEndorsement(QString gameName, int modID, QString modVersion, + bool endorse, QObject* receiver, QVariant userData, + const QString& subModule, + MOBase::IPluginGame const* game); + + int requestTrackingInfo(QObject* receiver, QVariant userData, + const QString& subModule); + + /** + * @param gameName the game short name to support multiple game sources + * @brief toggle tracking state of the mod + * @param modID id of the mod + * @param track true if the mod should be tracked, false for not tracked + * @param receiver the object to receive the result asynchronously via a signal + * (nxmFilesAvailable) + * @param userData user data to be returned with the result + * @param game the game with which the mods are associated + * @return int an id to identify the request + */ + int requestToggleTracking(QString gameName, int modID, bool track, QObject* receiver, + QVariant userData, const QString& subModule) + { + return requestToggleTracking(gameName, modID, track, receiver, userData, subModule, + getGame(gameName)); + } + + /** + * @param gameName the game short name to support multiple game sources + * @brief toggle tracking state of the mod + * @param modID id of the mod + * @param track true if the mod should be tracked, false for not tracked + * @param receiver the object to receive the result asynchronously via a signal + * (nxmFilesAvailable) + * @param userData user data to be returned with the result + * @param game the game with which the mods are associated + * @return int an id to identify the request + */ + int requestToggleTracking(QString gameName, int modID, bool track, QObject* receiver, + QVariant userData, const QString& subModule, + MOBase::IPluginGame const* game); + + /** + * @param gameName the game short name to support multiple game sources + * @brief toggle tracking state of the mod + * @param modID id of the mod + * @param track true if the mod should be tracked, false for not tracked + * @param receiver the object to receive the result asynchronously via a signal + * (nxmFilesAvailable) + * @param userData user data to be returned with the result + * @param game the game with which the mods are associated + * @return int an id to identify the request + */ + int requestGameInfo(QString gameName, QObject* receiver, QVariant userData, + const QString& subModule) + { + return requestGameInfo(gameName, receiver, userData, subModule, getGame(gameName)); + } + + /** + * @param gameName the game short name to support multiple game sources + * @brief toggle tracking state of the mod + * @param modID id of the mod + * @param track true if the mod should be tracked, false for not tracked + * @param receiver the object to receive the result asynchronously via a signal + * (nxmFilesAvailable) + * @param userData user data to be returned with the result + * @param game the game with which the mods are associated + * @return int an id to identify the request + */ + int requestGameInfo(QString gameName, QObject* receiver, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game); + + /** + * + */ + int requestInfoFromMd5(QString gameName, QByteArray& hash, QObject* receiver, + QVariant userData, const QString& subModule) + { + return requestInfoFromMd5(gameName, hash, receiver, userData, subModule, + getGame(gameName)); + } + + /** + * + */ + int requestInfoFromMd5(QString gameName, QByteArray& hash, QObject* receiver, + QVariant userData, const QString& subModule, + MOBase::IPluginGame const* game); + + /** + * @param directory the directory to store cache files + **/ + void setCacheDirectory(const QString& directory); + + /** + * @brief called when the log-in completes. This was, requests waiting for the log-in + * can be run + */ + void loginCompleted(); + + std::vector> + getGameChoices(const MOBase::IPluginGame* game); + + APIUserAccount getAPIUserAccount() const; + APIStats getAPIStats() const; + +public: + /** + * @brief guess the mod id from a filename as delivered by Nexus + * @param fileName name of the file + * @return the guessed mod id + * @note this currently doesn't fit well with the remaining interface but this is the + * best place for the function + */ + static void interpretNexusFileName(const QString& fileName, QString& modName, + int& modID, bool query); + + /** + * @brief get the currently managed game + */ + MOBase::IPluginGame const* managedGame() const; + + /** + * @brief see if the passed URL is related to the current game + * + * Arguably, this should optionally take a gameplugin pointer + */ + bool isURLGameRelated(QUrl const& url) const; + + /** + * @brief Get the nexus page for the current game + * + * Arguably, this should optionally take a gameplugin pointer + */ + QString getGameURL(QString gameName) const; + + /** + * @brief Get the URL for the mod web page + * @param modID + */ + QString getModURL(int modID, QString gameName) const; + + /** + * @brief Checks if the specified URL might correspond to a nexus mod + * @param modID + * @param url + * @return + */ + bool isModURL(int modID, QString const& url) const; + + void setPluginContainer(PluginContainer* pluginContainer); + +signals: + + void requestNXMDownload(const QString& url); + + void needLogin(); + + void nxmDescriptionAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID); + void nxmModInfoAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID); + void nxmUpdateInfoAvailable(QString gameName, QVariant userData, QVariant resultData, + int requestID); + void nxmUpdatesAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID); + void nxmFilesAvailable(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID); + void nxmFileInfoAvailable(QString gameName, int modID, int fileID, QVariant userData, + QVariant resultData, int requestID); + void nxmFileInfoFromMd5Available(QString gameName, QVariant userData, + QVariant resultData, int requestID); + void nxmDownloadURLsAvailable(QString gameName, int modID, int fileID, + QVariant userData, QVariant resultData, int requestID); + void nxmEndorsementsAvailable(QVariant userData, QVariant resultData, int requestID); + void nxmEndorsementToggled(QString gameName, int modID, QVariant userData, + QVariant resultData, int requestID); + void nxmTrackedModsAvailable(QVariant userData, QVariant resultData, int requestID); + void nxmTrackingToggled(QString gameName, int modID, QVariant userData, bool tracked, + int requestID); + void nxmGameInfoAvailable(QString gameName, QVariant userData, QVariant resultData, + int requestID); + void nxmRequestFailed(QString gameName, int modID, int fileID, QVariant userData, + int requestID, int errorCode, const QString& errorString); + void requestsChanged(const APIStats& stats, const APIUserAccount& user); + +public slots: + + void setUserAccount(const APIUserAccount& user); + +private slots: + + void requestFinished(); + void requestError(QNetworkReply::NetworkError error); + void requestTimeout(); + + void downloadRequestedNXM(const QString& url); + + void fakeFiles(); + +private: + struct NXMRequestInfo + { + int m_ModID; + QString m_ModVersion; + std::vector m_ModIDList; + int m_FileID; + QNetworkReply* m_Reply; + enum Type + { + TYPE_DESCRIPTION, + TYPE_MODINFO, + TYPE_FILES, + TYPE_FILEINFO, + TYPE_DOWNLOADURL, + TYPE_ENDORSEMENTS, + TYPE_TOGGLEENDORSEMENT, + TYPE_GETUPDATES, + TYPE_CHECKUPDATES, + TYPE_TOGGLETRACKING, + TYPE_TRACKEDMODS, + TYPE_FILEINFO_MD5, + TYPE_GAMEINFO, + } m_Type; + UpdatePeriod m_UpdatePeriod; + QVariant m_UserData; + QTimer* m_Timeout; + QString m_URL; + QString m_SubModule; + QString m_GameName; + int m_NexusGameID; + bool m_Reroute; + int m_ID; + int m_Endorse; + int m_Track; + QByteArray m_Hash; + QMap> m_AllowedErrors; + bool m_IgnoreGenericErrorHandler; + + NXMRequestInfo(int modID, Type type, QVariant userData, const QString& subModule, + MOBase::IPluginGame const* game); + NXMRequestInfo(int modID, QString modVersion, Type type, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game); + NXMRequestInfo(int modID, int fileID, Type type, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game); + NXMRequestInfo(Type type, QVariant userData, const QString& subModule, + MOBase::IPluginGame const* game); + NXMRequestInfo(Type type, QVariant userData, const QString& subModule); + NXMRequestInfo(UpdatePeriod period, Type type, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game); + NXMRequestInfo(QByteArray& hash, Type type, QVariant userData, + const QString& subModule, MOBase::IPluginGame const* game); + + private: + static QAtomicInt s_NextID; + }; + + static const int MAX_ACTIVE_DOWNLOADS = 6; + +private: + void nextRequest(); + void requestFinished(std::list::iterator iter); + MOBase::IPluginGame* getGame(QString gameName) const; + QString getOldModsURL(QString gameName) const; + +private: + QNetworkDiskCache* m_DiskCache; + NXMAccessManager* m_AccessManager; + std::list m_ActiveRequest; + QQueue m_RequestQueue; + PluginContainer* m_PluginContainer; + APIUserAccount m_User; +}; + +#endif // NEXUSINTERFACE_H diff --git a/src/src/nexusmanualkey.ui b/src/src/nexusmanualkey.ui new file mode 100644 index 0000000..847bdd6 --- /dev/null +++ b/src/src/nexusmanualkey.ui @@ -0,0 +1,231 @@ + + + NexusManualKeyDialog + + + + 0 + 0 + 452 + 236 + + + + Manual Nexus API Key + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 1. Get your personal API key + + + + + + + Open Browser + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 2. Enter your personal API key + + + + + + + Qt::Horizontal + + + + 115 + 20 + + + + + + + + Paste + + + + + + + Clear + + + + + + + + + + + 0 + 0 + + + + + 1 + 1 + + + + Enter API key here + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + <b>Sharing this key with anyone could get your Nexus account banned. You can always revoke this key from your account on the Nexus website.</b> + + + true + + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + NexusManualKeyDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + NexusManualKeyDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/src/noeditdelegate.cpp b/src/src/noeditdelegate.cpp new file mode 100644 index 0000000..edea57a --- /dev/null +++ b/src/src/noeditdelegate.cpp @@ -0,0 +1,9 @@ +#include "noeditdelegate.h" + +NoEditDelegate::NoEditDelegate(QObject* parent) : QStyledItemDelegate(parent) {} + +QWidget* NoEditDelegate::createEditor(QWidget*, const QStyleOptionViewItem&, + const QModelIndex&) const +{ + return nullptr; +} diff --git a/src/src/noeditdelegate.h b/src/src/noeditdelegate.h new file mode 100644 index 0000000..8a55eef --- /dev/null +++ b/src/src/noeditdelegate.h @@ -0,0 +1,14 @@ +#ifndef NOEDITDELEGATE_H +#define NOEDITDELEGATE_H + +#include + +class NoEditDelegate : public QStyledItemDelegate +{ +public: + NoEditDelegate(QObject* parent = nullptr); + virtual QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem& option, + const QModelIndex& index) const; +}; + +#endif // NOEDITDELEGATE_H diff --git a/src/src/nxmaccessmanager.cpp b/src/src/nxmaccessmanager.cpp new file mode 100644 index 0000000..3a68b62 --- /dev/null +++ b/src/src/nxmaccessmanager.cpp @@ -0,0 +1,987 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "nxmaccessmanager.h" +#include "iplugingame.h" +#include "nexusinterface.h" +#include "nxmurl.h" +#include "persistentcookiejar.h" +#include "report.h" +#include "selfupdater.h" +#include "settings.h" +#include "utility.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; +using namespace std::chrono_literals; + +const QString NexusBaseUrl("https://api.nexusmods.com/v1"); +const QString NexusSSO("wss://sso.nexusmods.com"); +const QString + NexusSSOPage("https://www.nexusmods.com/sso?id=%1&application=modorganizer2"); + +ValidationProgressDialog::ValidationProgressDialog(Settings* s, NexusKeyValidator& v) + : m_settings(s), m_validator(v), m_updateTimer(nullptr), m_first(true) +{ + ui.reset(new Ui::ValidationProgressDialog); + ui->setupUi(this); + + connect(ui->hide, &QPushButton::clicked, [&] { + onHide(); + }); + connect(ui->cancel, &QPushButton::clicked, [&] { + onCancel(); + }); +} + +void ValidationProgressDialog::setParentWidget(QWidget* w) +{ + const auto wasVisible = isVisible(); + + hide(); + setParent(w, windowFlags() | Qt::Dialog); + setModal(false); + + if (w && wasVisible) { + setVisible(true); + raise(); + } +} + +void ValidationProgressDialog::start() +{ + if (!m_updateTimer) { + m_updateTimer = new QTimer(this); + connect(m_updateTimer, &QTimer::timeout, [&] { + onTimer(); + }); + m_updateTimer->setInterval(100ms); + } + + updateProgress(); + m_updateTimer->start(); + + show(); +} + +void ValidationProgressDialog::stop() +{ + if (m_updateTimer) { + m_updateTimer->stop(); + } + + hide(); +} + +void ValidationProgressDialog::showEvent(QShowEvent* e) +{ + if (m_first) { + if (m_settings) { + m_settings->geometry().centerOnMainWindowMonitor(this); + } + + m_first = false; + } +} + +void ValidationProgressDialog::closeEvent(QCloseEvent* e) +{ + hide(); + e->ignore(); +} + +void ValidationProgressDialog::onHide() +{ + hide(); +} + +void ValidationProgressDialog::onCancel() +{ + m_validator.cancel(); +} + +void ValidationProgressDialog::onTimer() +{ + updateProgress(); +} + +void ValidationProgressDialog::updateProgress() +{ + const auto* current = m_validator.currentAttempt(); + + if (current) { + ui->progress->setRange(0, current->timeout().count()); + ui->progress->setValue(current->elapsed().elapsed() / 1000); + } else { + // indeterminate + ui->progress->setRange(0, 0); + } + + if (const auto* a = m_validator.lastAttempt()) { + ui->label->setText(a->message() + ". " + tr("Trying again...")); + } else if (current) { + ui->label->setText(tr("Connecting to Nexus...")); + } else { + ui->label->setText("?"); + } +} + +NexusSSOLogin::NexusSSOLogin() : m_keyReceived(false), m_active(false) +{ + m_timeout.setInterval(10s); + m_timeout.setSingleShot(true); + + QObject::connect(&m_socket, &QWebSocket::connected, [&] { + onConnected(); + }); + + QObject::connect(&m_socket, + qOverload(&QWebSocket::error), + [&](auto&& e) { + onError(e); + }); + + QObject::connect(&m_socket, &QWebSocket::sslErrors, [&](auto&& errors) { + onSslErrors(errors); + }); + + QObject::connect(&m_socket, &QWebSocket::textMessageReceived, [&](auto&& s) { + onMessage(s); + }); + + QObject::connect(&m_socket, &QWebSocket::disconnected, [&] { + onDisconnected(); + }); + + QObject::connect(&m_timeout, &QTimer::timeout, [&] { + onTimeout(); + }); +} + +QString NexusSSOLogin::stateToString(States s, const QString& e) +{ + switch (s) { + case ConnectingToSSO: + return QObject::tr("Connecting to Nexus..."); + + case WaitingForToken: + return QObject::tr("Waiting for Nexus..."); + + case WaitingForBrowser: + return QObject::tr("Opened Nexus in browser.") + "\n" + + QObject::tr("Switch to your browser and accept the request."); + + case Finished: + return QObject::tr("Finished."); + + case Timeout: + return QObject::tr("No answer from Nexus.") + "\n" + + QObject::tr("A firewall might be blocking Mod Organizer."); + + case ClosedByRemote: + return QObject::tr("Nexus closed the connection.") + "\n" + + QObject::tr("A firewall might be blocking Mod Organizer."); + + case Cancelled: + return QObject::tr("Cancelled."); + + case Error: // fall-through + default: { + if (e.isEmpty()) { + return QString("%1").arg(s); + } else { + return e; + } + } + } +} + +void NexusSSOLogin::start() +{ + m_active = true; + setState(ConnectingToSSO); + m_timeout.start(); + m_socket.open(NexusSSO); +} + +void NexusSSOLogin::cancel() +{ + if (m_active) { + abort(); + setState(Cancelled); + } +} + +void NexusSSOLogin::close() +{ + if (m_active) { + m_active = false; + m_timeout.stop(); + m_socket.close(); + } +} + +void NexusSSOLogin::abort() +{ + m_active = false; + m_timeout.stop(); + m_socket.abort(); +} + +bool NexusSSOLogin::isActive() const +{ + return m_active; +} + +void NexusSSOLogin::setState(States s, const QString& error) +{ + if (stateChanged) { + stateChanged(s, error); + } +} + +void NexusSSOLogin::onConnected() +{ + setState(WaitingForToken); + + m_keyReceived = false; + + boost::uuids::random_generator generator; + boost::uuids::uuid sessionId = generator(); + m_guid = boost::uuids::to_string(sessionId).c_str(); + + QJsonObject data; + data.insert(QString("id"), QJsonValue(m_guid)); + data.insert(QString("protocol"), 2); + + const QString message = QJsonDocument(data).toJson(); + m_socket.sendTextMessage(message); +} + +void NexusSSOLogin::onMessage(const QString& s) +{ + const QJsonDocument doc = QJsonDocument::fromJson(s.toUtf8()); + const QVariantMap root = doc.object().toVariantMap(); + + if (!root["success"].toBool()) { + close(); + + setState(Error, QString("There was a problem with SSO initialization: %1") + .arg(root["error"].toString())); + + return; + } + + const QVariantMap data = root["data"].toMap(); + + if (data.contains("connection_token")) { + // first answer + + // open browser + const QUrl url = NexusSSOPage.arg(m_guid); + shell::Open(url); + + m_timeout.stop(); + setState(WaitingForBrowser); + } else { + // second answer + const auto key = data["api_key"].toString(); + close(); + + if (keyChanged) { + keyChanged(key); + } + + setState(Finished); + } +} + +void NexusSSOLogin::onDisconnected() +{ + if (m_active) { + if (!m_keyReceived) { + close(); + setState(ClosedByRemote); + } else { + m_active = false; + } + } +} + +void NexusSSOLogin::onError(QAbstractSocket::SocketError e) +{ + if (m_active) { + close(); + setState(Error, m_socket.errorString()); + } +} + +void NexusSSOLogin::onSslErrors(const QList& errors) +{ + if (m_active) { + for (const auto& e : errors) { + setState(Error, e.errorString()); + } + } +} + +void NexusSSOLogin::onTimeout() +{ + abort(); + setState(Timeout); +} + +ValidationAttempt::ValidationAttempt(std::chrono::seconds timeout) + : m_reply(nullptr), m_result(None) +{ + m_timeout.setSingleShot(true); + m_timeout.setInterval(timeout); + + QObject::connect(&m_timeout, &QTimer::timeout, [&] { + onTimeout(); + }); +} + +void ValidationAttempt::start(NXMAccessManager& m, const QString& key) +{ + if (!sendRequest(m, key)) { + return; + } + + m_elapsed.start(); + m_timeout.start(); + + log::debug("nexus: attempt started with timeout of {} seconds", timeout().count()); +} + +bool ValidationAttempt::sendRequest(NXMAccessManager& m, const QString& key) +{ + const QString requestUrl(NexusBaseUrl + "/users/validate"); + QNetworkRequest request(requestUrl); + + request.setRawHeader("APIKEY", key.toUtf8()); + request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, + m.userAgent().toUtf8()); + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, + "application/json"); + request.setRawHeader("Protocol-Version", "1.0.0"); + request.setRawHeader("Application-Name", "MO2"); + request.setRawHeader("Application-Version", m.MOVersion().toUtf8()); + + m_reply = m.get(request); + + if (!m_reply) { + setFailure(SoftError, QObject::tr("Failed to request %1").arg(requestUrl)); + return false; + } + + QObject::connect(m_reply, &QNetworkReply::finished, [&] { + onFinished(); + }); + + QObject::connect(m_reply, &QNetworkReply::sslErrors, [&](auto&& errors) { + onSslErrors(errors); + }); + + return true; +} + +void ValidationAttempt::cancel() +{ + if (!m_reply || m_result != None) { + // not running + return; + } + + setFailure(Cancelled, QObject::tr("Cancelled")); + + if (m_reply) { + m_reply->abort(); + } + + cleanup(); +} + +bool ValidationAttempt::done() const +{ + return (m_result != None); +} + +ValidationAttempt::Result ValidationAttempt::result() const +{ + return m_result; +} + +const QString& ValidationAttempt::message() const +{ + return m_message; +} + +std::chrono::seconds ValidationAttempt::timeout() const +{ + return std::chrono::duration_cast( + m_timeout.intervalAsDuration()); +} + +QElapsedTimer ValidationAttempt::elapsed() const +{ + return m_elapsed; +} + +void ValidationAttempt::onFinished() +{ + if (m_result == Cancelled) { + return; + } + + log::debug("nexus: request has finished"); + + if (!m_reply) { + // shouldn't happen + log::error("nexus: reply is null"); + setFailure(HardError, QObject::tr("Internal error")); + return; + } + + const auto code = + m_reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (code == 0) { + // request wasn't even sent + log::error("nexus: code is 0"); + setFailure(SoftError, m_reply->errorString()); + return; + } + + const auto doc = QJsonDocument::fromJson(m_reply->readAll()); + const auto headers = m_reply->rawHeaderPairs(); + const auto httpError = m_reply->errorString(); + + const QJsonObject data = doc.object(); + + if (code != 200) { + // http request failed + + QString s = m_reply->errorString(); + + const auto nexusMessage = data.value("message").toString(); + if (!nexusMessage.isEmpty()) { + if (!s.isEmpty()) { + s += ", "; + } + + s += nexusMessage; + } + + if (s.isEmpty()) { + s = QObject::tr("HTTP code %1").arg(code); + } else { + s += QString(" (%1)").arg(code); + } + + setFailure(SoftError, s); + return; + } + + if (doc.isNull()) { + setFailure(HardError, QObject::tr("Invalid JSON")); + return; + } + + if (!data.contains("user_id")) { + setFailure(HardError, QObject::tr("Bad response")); + return; + } + + const int id = data.value("user_id").toInt(); + const QString key = data.value("key").toString(); + const QString name = data.value("name").toString(); + const bool premium = data.value("is_premium").toBool(); + + if (key.isEmpty()) { + setFailure(HardError, QObject::tr("API key is empty")); + return; + } + + const auto user = + APIUserAccount() + .apiKey(key) + .id(QString("%1").arg(id)) + .name(name) + .type(premium ? APIUserAccountTypes::Premium : APIUserAccountTypes::Regular) + .limits(NexusInterface::parseLimits(headers)); + + setSuccess(user); +} + +void ValidationAttempt::onSslErrors(const QList& errors) +{ + log::error("nexus: ssl errors"); + + for (auto& e : errors) { + log::error(" . {}", e.errorString()); + } + + setFailure(HardError, QObject::tr("SSL error")); +} + +void ValidationAttempt::onTimeout() +{ + setFailure(SoftError, QObject::tr("Timed out")); +} + +void ValidationAttempt::setFailure(Result r, const QString& error) +{ + if (r != Cancelled) { + // don't spam the log + log::error("nexus: {}", error); + } + + cleanup(); + + m_result = r; + m_message = error; + + if (failure) { + failure(); + } +} + +void ValidationAttempt::setSuccess(const APIUserAccount& user) +{ + log::debug("nexus connection successful"); + cleanup(); + + m_result = Success; + m_message = ""; + + if (success) { + success(user); + } +} + +void ValidationAttempt::cleanup() +{ + m_timeout.stop(); + + if (m_reply) { + m_reply->disconnect(); + m_reply->deleteLater(); + m_reply = nullptr; + } +} + +NexusKeyValidator::NexusKeyValidator(Settings* s, NXMAccessManager& am) + : m_settings(s), m_manager(am) +{} + +NexusKeyValidator::~NexusKeyValidator() +{ + cancel(); +} + +std::vector NexusKeyValidator::getTimeouts() const +{ + if (m_settings) { + return m_settings->nexus().validationTimeouts(); + } else { + return {10s, 15s, 20s}; + } +} + +void NexusKeyValidator::start(const QString& key, Behaviour b) +{ + if (isActive()) { + log::debug("nexus: trying to start while ongoing; ignoring"); + return; + } + + m_key = key; + + const auto timeouts = getTimeouts(); + + switch (b) { + case OneShot: { + createAttempts({timeouts[0]}); + break; + } + + case Retry: { + createAttempts(timeouts); + break; + } + } + + nextTry(); +} + +void NexusKeyValidator::createAttempts( + const std::vector& timeouts) +{ + m_attempts.clear(); + + for (auto&& t : timeouts) { + m_attempts.push_back(std::make_unique(t)); + } +} + +void NexusKeyValidator::cancel() +{ + log::debug("nexus: connection cancelled"); + + for (auto&& a : m_attempts) { + a->cancel(); + } +} + +bool NexusKeyValidator::isActive() const +{ + for (auto&& a : m_attempts) { + if (!a->done()) { + return true; + } + } + + return false; +} + +const ValidationAttempt* NexusKeyValidator::lastAttempt() const +{ + const ValidationAttempt* last = nullptr; + + for (auto&& a : m_attempts) { + if (a->done()) { + last = a.get(); + } else { + break; + } + } + + return last; +} + +const ValidationAttempt* NexusKeyValidator::currentAttempt() const +{ + for (auto&& a : m_attempts) { + if (!a->done()) { + return a.get(); + } + } + + return nullptr; +} + +bool NexusKeyValidator::nextTry() +{ + for (auto&& a : m_attempts) { + if (!a->done()) { + a->success = [&](auto&& user) { + onAttemptSuccess(*a, user); + }; + a->failure = [&] { + onAttemptFailure(*a); + }; + + a->start(m_manager, m_key); + return true; + } + } + + // no more + return false; +} + +void NexusKeyValidator::onAttemptSuccess(const ValidationAttempt& a, + const APIUserAccount& u) +{ + if (attemptFinished) { + attemptFinished(a); + } + + setFinished(ValidationAttempt::Success, "", u); +} + +void NexusKeyValidator::onAttemptFailure(const ValidationAttempt& a) +{ + if (attemptFinished) { + attemptFinished(a); + } + + switch (a.result()) { + case ValidationAttempt::SoftError: { + if (!nextTry()) { + setFinished(a.result(), a.message(), {}); + } + + break; + } + + case ValidationAttempt::HardError: { + cancel(); + setFinished(a.result(), a.message(), {}); + break; + } + + case ValidationAttempt::Cancelled: { + setFinished(ValidationAttempt::Cancelled, QObject::tr("Cancelled"), {}); + break; + } + } +} + +void NexusKeyValidator::setFinished(ValidationAttempt::Result r, const QString& message, + std::optional user) +{ + if (finished) { + finished(r, message, user); + } +} + +NXMAccessManager::NXMAccessManager(QObject* parent, Settings* s, + const QString& moVersion) + : QNetworkAccessManager(parent), m_Settings(s), m_MOVersion(moVersion), + m_validator(s, *this), m_validationState(NotChecked) +{ + m_validator.finished = [&](auto&& r, auto&& m, auto&& u) { + onValidatorFinished(r, m, u); + }; + + m_validator.attemptFinished = [&](auto&& a) { + onValidatorAttemptFinished(a); + }; + + if (m_Settings) { + setCookieJar(new PersistentCookieJar(QDir::fromNativeSeparators( + m_Settings->paths().cache() + "/nexus_cookies.dat"))); + } +} + +void NXMAccessManager::setTopLevelWidget(QWidget* w) +{ + if (w) { + if (m_ProgressDialog) { + m_ProgressDialog->setParentWidget(w); + } + } else { + m_ProgressDialog.reset(); + m_validator.cancel(); + } +} + +QNetworkReply* +NXMAccessManager::createRequest(QNetworkAccessManager::Operation operation, + const QNetworkRequest& request, QIODevice* device) +{ + if (request.url().scheme() != "nxm") { + return QNetworkAccessManager::createRequest(operation, request, device); + } + if (operation == GetOperation) { + emit requestNXMDownload(request.url().toString()); + + // eat the request, everything else will be done by the download manager + return QNetworkAccessManager::createRequest(QNetworkAccessManager::GetOperation, + QNetworkRequest(QUrl())); + } else if (operation == PostOperation) { + return QNetworkAccessManager::createRequest(operation, request, device); + ; + } else { + return QNetworkAccessManager::createRequest(operation, request, device); + } +} + +void NXMAccessManager::showCookies() const +{ + QUrl url(NexusBaseUrl + "/"); + for (const QNetworkCookie& cookie : cookieJar()->cookiesForUrl(url)) { + log::debug("{} - {} (expires: {})", cookie.name().constData(), + cookie.value().constData(), cookie.expirationDate().toString()); + } +} + +void NXMAccessManager::clearCookies() +{ + PersistentCookieJar* jar = qobject_cast(cookieJar()); + if (jar != nullptr) { + jar->clear(); + } else { + log::warn("failed to clear cookies, invalid cookie jar"); + } +} + +void NXMAccessManager::startValidationCheck(const QString& key) +{ + m_validationState = NotChecked; + m_validator.start(key, NexusKeyValidator::Retry); + + if (m_ProgressDialog) { + // don't show the progress dialog on startup for the first attempt; the + // dialog will be shown in onValidatorAttemptFinished() if it failed + startProgress(); + } +} + +void NXMAccessManager::onValidatorFinished(ValidationAttempt::Result r, + const QString& message, + std::optional user) +{ + stopProgress(); + + if (user) { + m_validationState = Valid; + emit credentialsReceived(*user); + emit validateSuccessful(true); + } else { + if (r == ValidationAttempt::Cancelled) { + m_validationState = NotChecked; + } else { + m_validationState = Invalid; + emit validateFailed(message); + } + } +} + +void NXMAccessManager::onValidatorAttemptFinished(const ValidationAttempt& a) +{ + if (!m_ProgressDialog) { + switch (a.result()) { + case ValidationAttempt::SoftError: + case ValidationAttempt::HardError: { + startProgress(); + break; + } + + case ValidationAttempt::None: + case ValidationAttempt::Success: + case ValidationAttempt::Cancelled: + default: { + // don't show the dialog + break; + } + } + } +} + +bool NXMAccessManager::validated() const +{ + if (m_validationState == Valid) { + return true; + } + + if (m_validator.isActive()) { + const_cast(this)->startProgress(); + } + + return false; +} + +void NXMAccessManager::refuseValidation() +{ + m_validationState = Invalid; +} + +bool NXMAccessManager::validateAttempted() const +{ + return (m_validationState != NotChecked); +} + +bool NXMAccessManager::validateWaiting() const +{ + return m_validator.isActive(); +} + +void NXMAccessManager::apiCheck(const QString& apiKey, bool force) +{ + if (m_validator.isActive()) { + return; + } + + if (m_Settings && m_Settings->network().offlineMode()) { + m_validationState = NotChecked; + return; + } + + if (force) { + m_validationState = NotChecked; + } + + if (m_validationState == Valid) { + emit validateSuccessful(false); + return; + } + + startValidationCheck(apiKey); +} + +const QString& NXMAccessManager::MOVersion() const +{ + return m_MOVersion; +} + +QString NXMAccessManager::userAgent(const QString& subModule) const +{ + QStringList comments; + QString os; + if (QSysInfo::productType() == "windows") + comments << ((QSysInfo::kernelType() == "winnt") ? "Windows_NT " : "Windows ") + + QSysInfo::kernelVersion(); + else + comments << QSysInfo::kernelType().left(1).toUpper() + QSysInfo::kernelType().mid(1) + << QSysInfo::productType().left(1).toUpper() + + QSysInfo::kernelType().mid(1) + " " + QSysInfo::productVersion(); + if (!subModule.isEmpty()) { + comments << "module: " + subModule; + } + comments << ((QSysInfo::buildCpuArchitecture() == "x86_64") ? "x64" : "x86"); + + return QString("Mod Organizer/%1 (%2) Qt/%3") + .arg(m_MOVersion, comments.join("; "), qVersion()); +} + +void NXMAccessManager::clearApiKey() +{ + m_validator.cancel(); + emit credentialsReceived(APIUserAccount()); +} + +void NXMAccessManager::startProgress() +{ + if (!m_ProgressDialog) { + m_ProgressDialog.reset(new ValidationProgressDialog(m_Settings, m_validator)); + } + + m_ProgressDialog->start(); +} + +void NXMAccessManager::stopProgress() +{ + if (m_ProgressDialog) { + m_ProgressDialog->stop(); + } +} diff --git a/src/src/nxmaccessmanager.h b/src/src/nxmaccessmanager.h new file mode 100644 index 0000000..2a511dc --- /dev/null +++ b/src/src/nxmaccessmanager.h @@ -0,0 +1,285 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef NXMACCESSMANAGER_H +#define NXMACCESSMANAGER_H + +#include "apiuseraccount.h" +#include "ui_validationprogressdialog.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace MOBase +{ +class IPluginGame; +} +class NXMAccessManager; +class Settings; + +class NexusSSOLogin +{ +public: + enum States + { + ConnectingToSSO, + WaitingForToken, + WaitingForBrowser, + Finished, + Timeout, + ClosedByRemote, + Cancelled, + Error + }; + + std::function keyChanged; + std::function stateChanged; + + static QString stateToString(States s, const QString& e); + + NexusSSOLogin(); + + void start(); + void cancel(); + + bool isActive() const; + +private: + QWebSocket m_socket; + QString m_guid; + bool m_keyReceived; + bool m_active; + QTimer m_timeout; + + void setState(States s, const QString& error = {}); + + void close(); + void abort(); + + void onConnected(); + void onMessage(const QString& s); + void onDisconnected(); + void onError(QAbstractSocket::SocketError e); + void onSslErrors(const QList& errors); + void onTimeout(); +}; + +class ValidationAttempt +{ +public: + enum Result + { + None, + Success, + SoftError, + HardError, + Cancelled + }; + + std::function success; + std::function failure; + + ValidationAttempt(std::chrono::seconds timeout); + ValidationAttempt(const ValidationAttempt&) = delete; + ValidationAttempt& operator=(const ValidationAttempt&) = delete; + + void start(NXMAccessManager& m, const QString& key); + void cancel(); + + bool done() const; + Result result() const; + const QString& message() const; + std::chrono::seconds timeout() const; + QElapsedTimer elapsed() const; + +private: + QNetworkReply* m_reply; + Result m_result; + QString m_message; + QTimer m_timeout; + QElapsedTimer m_elapsed; + + bool sendRequest(NXMAccessManager& m, const QString& key); + + void onFinished(); + void onSslErrors(const QList& errors); + void onTimeout(); + + void setFailure(Result r, const QString& error); + void setSuccess(const APIUserAccount& user); + + void cleanup(); +}; + +class NexusKeyValidator +{ +public: + enum Behaviour + { + OneShot = 0, + Retry + }; + + using FinishedCallback = void(ValidationAttempt::Result, const QString&, + std::optional); + + std::function finished; + std::function attemptFinished; + + NexusKeyValidator(Settings* s, NXMAccessManager& am); + ~NexusKeyValidator(); + + void start(const QString& key, Behaviour b); + void cancel(); + + bool isActive() const; + const ValidationAttempt* lastAttempt() const; + const ValidationAttempt* currentAttempt() const; + +private: + Settings* m_settings; + NXMAccessManager& m_manager; + QString m_key; + std::vector> m_attempts; + + void createAttempts(const std::vector& timeouts); + std::vector getTimeouts() const; + + bool nextTry(); + void onAttemptSuccess(const ValidationAttempt& a, const APIUserAccount& u); + void onAttemptFailure(const ValidationAttempt& a); + + void setFinished(ValidationAttempt::Result r, const QString& message, + std::optional user); +}; + +class ValidationProgressDialog : public QDialog +{ + Q_OBJECT; + +public: + ValidationProgressDialog(Settings* s, NexusKeyValidator& v); + + void setParentWidget(QWidget* w); + + void start(); + void stop(); + +protected: + void showEvent(QShowEvent* e) override; + void closeEvent(QCloseEvent* e) override; + +private: + std::unique_ptr ui; + Settings* m_settings; + NexusKeyValidator& m_validator; + QTimer* m_updateTimer; + bool m_first; + + void onHide(); + void onCancel(); + void onTimer(); + void updateProgress(); +}; + +/** + * @brief access manager extended to handle nxm links + **/ +class NXMAccessManager : public QNetworkAccessManager +{ + Q_OBJECT +public: + NXMAccessManager(QObject* parent, Settings* s, const QString& moVersion); + + void setTopLevelWidget(QWidget* w); + + bool validated() const; + + bool validateAttempted() const; + bool validateWaiting() const; + + void apiCheck(const QString& apiKey, bool force = false); + + void showCookies() const; + + void clearCookies(); + + QString userAgent(const QString& subModule = QString()) const; + const QString& MOVersion() const; + + void clearApiKey(); + + void refuseValidation(); + +signals: + + /** + * @brief emitted when a nxm:// link is opened + * + * @param url the nxm-link + **/ + void requestNXMDownload(const QString& url); + + /** + * @brief emitted after a successful login or if login was not necessary + * + * @param necessary true if a login was necessary and succeeded, false if the user is + *still logged in + **/ + void validateSuccessful(bool necessary); + void validateFailed(const QString& message); + void credentialsReceived(const APIUserAccount& user); + +protected: + virtual QNetworkReply* createRequest(QNetworkAccessManager::Operation operation, + const QNetworkRequest& request, + QIODevice* device); + +private: + enum States + { + NotChecked, + Valid, + Invalid + }; + + QWidget* m_TopLevel; + Settings* m_Settings; + mutable std::unique_ptr m_ProgressDialog; + QString m_MOVersion; + NexusKeyValidator m_validator; + States m_validationState; + + void startValidationCheck(const QString& key); + + void onValidatorFinished(ValidationAttempt::Result r, const QString& message, + std::optional); + + void onValidatorAttemptFinished(const ValidationAttempt& a); + + void startProgress(); + void stopProgress(); +}; + +#endif // NXMACCESSMANAGER_H diff --git a/src/src/nxmhandler_linux.cpp b/src/src/nxmhandler_linux.cpp new file mode 100644 index 0000000..eb9f1df --- /dev/null +++ b/src/src/nxmhandler_linux.cpp @@ -0,0 +1,345 @@ +#include "nxmhandler_linux.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; + +namespace +{ +QString ensureDir(const QString& path) +{ + QDir dir(path); + if (!dir.exists() && !QDir().mkpath(path)) { + return {}; + } + return path; +} + +bool writeTextFile(const QString& path, const QString& content) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + return false; + } + + QTextStream stream(&file); + stream << content; + return stream.status() == QTextStream::Ok; +} + +void updateMimeAppsList(const QString& path, const QString& mimeType, + const QString& desktopFile) +{ + const QString entry = mimeType + "=" + desktopFile; + + QStringList lines; + { + QFile file(path); + if (file.open(QIODevice::ReadOnly | QIODevice::Text)) { + const auto content = QString::fromUtf8(file.readAll()); + lines = content.split('\n'); + while (!lines.isEmpty() && lines.back().isEmpty()) { + lines.removeLast(); + } + } + } + + int defaultHeader = -1; + bool replaced = false; + + for (int i = 0; i <= lines.size(); ++i) { + const bool atEnd = i == lines.size(); + const QString trimmed = + atEnd ? QString() : lines.at(i).trimmed(); + + if (!atEnd && trimmed.startsWith('[')) { + if (trimmed == "[Default Applications]") { + defaultHeader = i; + } + continue; + } + + if (defaultHeader >= 0 && (atEnd || trimmed.startsWith('['))) { + const int sectionEnd = atEnd ? lines.size() : i; + for (int j = defaultHeader + 1; j < sectionEnd; ++j) { + const QString item = lines.at(j).trimmed(); + if (item.startsWith(mimeType + "=")) { + lines[j] = entry; + replaced = true; + break; + } + } + if (!replaced) { + lines.insert(defaultHeader + 1, entry); + } + break; + } + } + + if (defaultHeader < 0) { + if (!lines.isEmpty() && !lines.back().isEmpty()) { + lines.append(QString()); + } + lines.append("[Default Applications]"); + lines.append(entry); + } + + writeTextFile(path, lines.join('\n') + "\n"); +} +} // namespace + +std::optional NxmLink::parse(const QString& url) +{ + const QUrl parsed(url); + if (!parsed.isValid() || parsed.scheme().compare("nxm", Qt::CaseInsensitive) != 0) { + return {}; + } + + const QString gameDomain = parsed.host().trimmed(); + if (gameDomain.isEmpty()) { + return {}; + } + + const QStringList parts = + parsed.path().split('/', Qt::SkipEmptyParts); + + if (parts.size() != 4 || parts[0] != "mods" || parts[2] != "files") { + return {}; + } + + bool modOk = false; + bool fileOk = false; + const uint64_t modId = parts[1].toULongLong(&modOk); + const uint64_t fileId = parts[3].toULongLong(&fileOk); + if (!modOk || !fileOk) { + return {}; + } + + const QUrlQuery query(parsed); + const QString key = query.queryItemValue("key"); + if (key.isEmpty()) { + return {}; + } + + bool expiresOk = false; + const uint64_t expires = query.queryItemValue("expires").toULongLong(&expiresOk); + if (!expiresOk) { + return {}; + } + + return NxmLink{gameDomain, modId, fileId, key, expires}; +} + +QString NxmLink::lookupKey() const +{ + return QString("%1:%2:%3").arg(game_domain).arg(mod_id).arg(file_id); +} + +NxmHandlerLinux::NxmHandlerLinux(QObject* parent) : QObject(parent) +{ + qRegisterMetaType("NxmLink"); +} + +NxmHandlerLinux::~NxmHandlerLinux() +{ + if (m_server != nullptr) { + m_server->close(); + delete m_server; + m_server = nullptr; + } +} + +QString NxmHandlerLinux::socketPath() +{ + const QString runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR"); + if (!runtimeDir.isEmpty()) { + return QDir(runtimeDir).filePath("mo2-nxm.sock"); + } + + return "/tmp/mo2-nxm.sock"; +} + +void NxmHandlerLinux::registerHandler() const +{ + const bool flatpak = QFile::exists(QStringLiteral("/.flatpak-info")); + + const QString home = QDir::homePath(); + if (home.isEmpty()) { + log::error("cannot register nxm handler: home path is empty"); + return; + } + + const QString appsDir = ensureDir(home + "/.local/share/applications"); + const QString configDir = ensureDir(home + "/.config"); + + if (appsDir.isEmpty() || configDir.isEmpty()) { + log::error("cannot register nxm handler: failed to create required directories"); + return; + } + + QString execLine; + + if (flatpak) { + // In Flatpak the desktop file is invoked on the HOST, so use flatpak run + execLine = "flatpak run com.fluorine.manager nxm-handle %u"; + } else { + // Non-Flatpak: create a wrapper script and point the desktop file at it + const QString localBin = ensureDir(home + "/.local/bin"); + if (localBin.isEmpty()) { + log::error("cannot register nxm handler: failed to create ~/.local/bin"); + return; + } + + const QString wrapperPath = localBin + "/mo2-nxm-handler"; + const QString executable = QCoreApplication::applicationFilePath(); + const QString wrapper = + QString("#!/bin/sh\nexec \"%1\" nxm-handle \"$@\"\n").arg(executable); + + if (!writeTextFile(wrapperPath, wrapper)) { + log::error("failed to write nxm wrapper script '{}'", wrapperPath); + return; + } + + QFile::setPermissions(wrapperPath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner | + QFileDevice::ExeOwner | QFileDevice::ReadGroup | + QFileDevice::ExeGroup | QFileDevice::ReadOther | + QFileDevice::ExeOther); + + execLine = "mo2-nxm-handler nxm-handle %u"; + } + + const QString desktopPath = appsDir + "/mo2-nxm-handler.desktop"; + const QString desktop = QString("[Desktop Entry]\n" + "Type=Application\n" + "Name=Mod Organizer 2 NXM Handler\n" + "Exec=%1\n" + "MimeType=x-scheme-handler/nxm;\n" + "NoDisplay=true\n").arg(execLine); + + if (!writeTextFile(desktopPath, desktop)) { + log::error("failed to write nxm desktop entry '{}'", desktopPath); + return; + } + + updateMimeAppsList(configDir + "/mimeapps.list", "x-scheme-handler/nxm", + "mo2-nxm-handler.desktop"); + updateMimeAppsList(appsDir + "/mimeapps.list", "x-scheme-handler/nxm", + "mo2-nxm-handler.desktop"); + + if (flatpak) { + // update-desktop-database doesn't exist inside the sandbox; run it on the host + QProcess p; + p.start(QStringLiteral("flatpak-spawn"), + {QStringLiteral("--host"), QStringLiteral("update-desktop-database"), + appsDir}); + if (!p.waitForFinished(5000) || p.exitCode() != 0) { + log::warn("update-desktop-database on host exited with code {}", p.exitCode()); + } + } else { + const auto result = + QProcess::execute("update-desktop-database", QStringList() << appsDir); + if (result != 0) { + log::warn("update-desktop-database exited with code {}", result); + } + } +} + +bool NxmHandlerLinux::startListener() +{ + if (m_server != nullptr && m_server->isListening()) { + return true; + } + + if (m_server == nullptr) { + m_server = new QLocalServer(this); + connect(m_server, &QLocalServer::newConnection, this, + &NxmHandlerLinux::onNewConnection); + } else { + m_server->close(); + } + + const QString path = socketPath(); + + QLocalServer::removeServer(path); + if (QFileInfo::exists(path)) { + QFile::remove(path); + } + + if (!m_server->listen(path)) { + log::error("failed to start nxm listener on '{}': {}", path, + m_server->errorString()); + return false; + } + + log::info("nxm listener started on '{}'", path); + return true; +} + +void NxmHandlerLinux::onNewConnection() +{ + if (m_server == nullptr) { + return; + } + + while (QLocalSocket* socket = m_server->nextPendingConnection()) { + connect(socket, &QLocalSocket::readyRead, this, [this, socket] { + processSocketData(socket); + }); + connect(socket, &QLocalSocket::disconnected, socket, &QObject::deleteLater); + } +} + +void NxmHandlerLinux::processSocketData(QLocalSocket* socket) +{ + while (socket->canReadLine()) { + const QString line = QString::fromUtf8(socket->readLine()).trimmed(); + if (line.isEmpty()) { + continue; + } + + const auto link = NxmLink::parse(line); + if (!link) { + log::warn("received invalid nxm url on socket: {}", line); + continue; + } + + emit nxmReceived(*link); + } +} + +bool NxmHandlerLinux::sendToSocket(const QString& url) +{ + QLocalSocket socket; + socket.connectToServer(socketPath(), QIODevice::WriteOnly); + if (!socket.waitForConnected(1500)) { + return false; + } + + QByteArray payload = url.toUtf8(); + payload.append('\n'); + + if (socket.write(payload) != payload.size()) { + socket.abort(); + return false; + } + + if (!socket.waitForBytesWritten(1500)) { + socket.abort(); + return false; + } + + socket.disconnectFromServer(); + return true; +} diff --git a/src/src/nxmhandler_linux.h b/src/src/nxmhandler_linux.h new file mode 100644 index 0000000..0d2fd4d --- /dev/null +++ b/src/src/nxmhandler_linux.h @@ -0,0 +1,52 @@ +#ifndef NXMHANDLER_LINUX_H +#define NXMHANDLER_LINUX_H + +#include +#include + +#include +#include + +struct NxmLink +{ + QString game_domain; + uint64_t mod_id = 0; + uint64_t file_id = 0; + QString key; + uint64_t expires = 0; + + static std::optional parse(const QString& url); + QString lookupKey() const; +}; + +Q_DECLARE_METATYPE(NxmLink) + +class QLocalServer; +class QLocalSocket; + +class NxmHandlerLinux : public QObject +{ + Q_OBJECT + +public: + explicit NxmHandlerLinux(QObject* parent = nullptr); + ~NxmHandlerLinux() override; + + void registerHandler() const; + bool startListener(); + + static bool sendToSocket(const QString& url); + static QString socketPath(); + +signals: + void nxmReceived(NxmLink link); + +private: + void onNewConnection(); + void processSocketData(QLocalSocket* socket); + +private: + QLocalServer* m_server = nullptr; +}; + +#endif // NXMHANDLER_LINUX_H diff --git a/src/src/organizer_en.ts b/src/src/organizer_en.ts new file mode 100644 index 0000000..b4d8529 --- /dev/null +++ b/src/src/organizer_en.ts @@ -0,0 +1,10084 @@ + + + + + AboutDialog + + + + About + + + + + Revision: + + + + + usvfs: + + + + + <html><head/><body><p>Source code can be found at <a href="https://github.com/ModOrganizer2/modorganizer">GitHub</a>.</p></body></html> + + + + + Used Software + + + + + Thanks + + + + + Lead Developers && Maintainers + + + + + LePresidente (Project Lead) + + + + + Holt59 + + + + + MO2 Developers && Contributors + + + + + Translators + + + + + Cyb3r (Dutch) + + + + + fruttyx (French) + + + + + Yoplala (French) + + + + + Faron (German) + + + + + yohru (Japanese) + + + + + Mordan (Greek) + + + + + Yoosk (Polish) + + + + + Brgodfx (Portuguese) + + + + + Dasinho (Portuguese) + + + + + Jax (Swedish) + + + + + Nubbie (Swedish) + + + + + ...and all other contributors! + + + + + Other Supporters && Contributors + + + + + Close + + + + + No license + + + + + ActivateModsDialog + + + Activate Mods + + + + + This is a list of esps, esms, and esls that were active when the save game was created. + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">This is a list of esps, esms, and esls that were active when the save game was created.</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">For each esp, the right column contains the mod (or mods) that can be enabled to make the missing esps/esms available.</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">If you hit Ok, all the mods selected in the right columns and all missing esps that have become available will be activated.</span></p></body></html> + + + + + Missing ESP + + + + + Mod + + + + + not found + + + + + AdvancedConflictListModel + + + Overwrites + + + + + File + + + + + Overwritten By + + + + + BrowserDialog + + + Some Page + + + + + Search + + + + + new + + + + + failed to start download + + + + + CategoriesDialog + + + Categories + + + + + Refresh from Nexus + + + + + <-- Import Nexus Cats + + + + + ID + + + + + Internal ID for the category. + + + + + Internal ID for the category. The categories a mod belongs to are stored by this ID. It is recommended you use new IDs for categories you add instead of re-using existing ones. + + + + + Name + + + + + + The display name of the category. + + + + + Parent ID + + + + + If set, the category is defined as a sub-category of another one. Parent ID needs to be a valid category ID. + + + + + Nexus Categories + + + + + Comma-Separated list of Nexus IDs to be matched to the internal ID. + + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + <html><head><meta name="qrichtext" content="1" /><style type="text/css"> + p, li { white-space: pre-wrap; } + </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">You can match one or multiple nexus categories to an internal ID. Whenever you download a mod from a Nexus Page, Mod Organizer will try to resolve the category defined on the Nexus to one available in MO.</span></p> + <p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8pt;"></p> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">To find out a category id used by the nexus, visit the categories list of the nexus page and hover over the links there.</span></p></body></html> + + + + + + Drag & drop nexus categories from this pane onto the target category on the left. + + + + + Nexus Categories (Drag && Drop to Assign) + + + + + Error %1: Request to Nexus failed: %2 + + + + + Add + + + + + Remove + + + + + Remove Nexus Mapping(s) + + + + + CategoryFactory + + + invalid category id {0} + + + + + + invalid category line {0}: {1} + + + + + invalid category line {0}: {1} ({2} cells) + + + + + invalid nexus ID {} + + + + + invalid category id {} + + + + + invalid nexus category line {0}: {1} ({2} cells) + + + + + Failed to save custom categories + + + + + Failed to save nexus category mappings + + + + + + + + invalid category index: %1 + + + + + {} is no valid category id + + + + + invalid category id: %1 + + + + + nexus category id {0} maps to internal {1} + + + + + nexus category id {} not mapped + + + + + CategoryImportDialog + + + Nexus Category Import + + + + + <h3>How do you want to import the categories?</h3> + + + + + Import Strategy + + + + + Merge + + + + + Replace + + + + + Options + + + + + Assign nexus mappings + + + + + Remap existing mappings + + + + + ConflictsTab + + + &Execute + + + + + Execute with &VFS + + + + + &Open + + + + + Open with &VFS + + + + + &Preview + + + + + &Go to... + + + + + Open in &Explorer + + + + + &Hide + + + + + CreateInstanceDialog + + + Creating an instance + + + + + Creating a new instance + + + + + <h3>What is an instance?</h3> +<p>An instance is a full set of mods, downloads, profiles and configuration for a game. Each game must be managed in its own instance. Mod Organizer can freely switch between instances.</p> + + + + + <html><head/><body><p><a href="https://github.com/ModOrganizer2/modorganizer/wiki/Instances">More information</a></p></body></html> + + + + + Never show this page again + + + + + <h3>Select the type of instance to create.</h3> + + + + + Create a global instance + + + + + Global instances are stored in %1, but some paths can be changed to be on a different drive if necessary. A single installation of Mod Organizer can manage multiple global instances. + + + + + Create a portable instance + + + + + A portable instance stores everything in Mod Organizer's installation folder, currently %1. There can only be one portable instance per installation of Mod Organizer. + + + + + A portable instance already exists. + + + + + <h3>Select the game to manage.</h3> + + + + + Show all supported games + + + + + Filter + + + + + <h3>Select the game edition.</h3> + + + + + This game has multiple variants. The correct one must be selected or Mod Organizer will not be able to launch the game properly. + + + + + <h3>Customize the name for this <span style="white-space: nowrap;">%1</span> instance.</h3> + + + + + Instance name + + + + + There is already an instance with this name. + + + + + The name contains invalid characters. It must be a valid folder name. + + + + + <h3>Configure your profile settings.</h3> + + + + + Use profile-specific game INI files + + + + + Use profile-specific save games + + + + + Automatic archive invalidation + + + + + <h3>Select a folder where the data should be stored.</h3> + + + + + This includes downloads, mods, profiles and overwrite for your <b>%1</b> instance. If there is enough space on this drive, you should use the default folder. + + + + + + + + + + ... + + + + + Location + + + + + Warning: This folder already exists. + + + + + + + + + + Folder + + + + + Warning: The folder contains invalid characters. + + + + + Mods + + + + + Overwrite + + + + + Base directory + + + + + Warning: The folder %1 already exists. + + + + + Downloads + + + + + Profiles + + + + + Use %BASE_DIR% to refer to the Base Directory. + + + + + Warning: The folder %1 contains invalid characters. + + + + + Show advanced options + + + + + <h3>Link Mod Organizer with your Nexus account</h3> + + + + + Linking with Nexus allows you to download mods directly from Mod Organizer and automatically check for updates. This is optional. + + + + + Connect to Nexus + + + + + Enter API Key Manually + + + + + <h3>Confirmation</h3> + + + + + The instance is about to be created. Review the information below and press 'Finish'. + + + + + Instance creation log + + + + + Launch the new instance + + + + + < Back + + + + + Next > + + + + + Cancel + + + + + Setting up instance %1 + + + + + Setting up an instance %1 + + + + + Creating instance... + + + + + Writing %1... + + + + + Format error. + + + + + Error %1. + + + + + Done. + + + + + Finish + + + + + CredentialsDialog + + + Login + + + + + This feature may not work unless you're logged in with Nexus + + + + + Username + + + + + Password + + + + + Remember + + + + + Never ask again + + + + + DirectoryRefresher + + + failed to read mod (%1): %2 + + + + + DisableProxyPluginDialog + + + Really disable plugin? + + + + + Disabling the '%1' plugin will prevent the following plugins from working: + + + + + Plugin + + + + + Description + + + + + Do you want to continue? You will need to restart Mod Organizer for the change to take effect. + + + + + Yes + + + + + No + + + + + DownloadList + + + Name + + + + + Mod name + + + + + Version + + + + + Nexus ID + + + + + Size + + + + + Status + + + + + Filetime + + + + + Source Game + + + + + < game %1 mod %2 file %3 > + + + + + Unknown + + + + + Pending + + + + + Started + + + + + Canceling + + + + + Pausing + + + + + Canceled + + + + + Paused + + + + + Error + + + + + + + Fetching Info + + + + + Downloaded + + + + + Installed + + + + + Uninstalled + + + + + Pending download + + + + + Information missing, please select "Query Info" from the context menu to re-retrieve. + + + + + DownloadListView + + + Install + + + + + Query Info + + + + + Visit on Nexus + + + + + Open File + + + + + Open Meta File + + + + + + + Reveal in Explorer + + + + + + Delete... + + + + + Un-Hide + + + + + Hide + + + + + + Cancel + + + + + Pause + + + + + Resume + + + + + Delete Installed Downloads... + + + + + Delete Uninstalled Downloads... + + + + + Delete All Downloads... + + + + + Hide Installed... + + + + + Hide Uninstalled... + + + + + Hide All... + + + + + Un-Hide All... + + + + + Delete download + + + + + Move to the Recycle Bin + + + + + + + Delete Files? + + + + + This will remove all finished downloads from this list and from disk. + +Are you absolutely sure you want to proceed? + + + + + This will remove all installed downloads from this list and from disk. + +Are you absolutely sure you want to proceed? + + + + + This will remove all uninstalled downloads from this list and from disk. + +Are you absolutely sure you want to proceed? + + + + + + + Hide Files? + + + + + This will remove all finished downloads from this list (but NOT from disk). + + + + + This will remove all installed downloads from this list (but NOT from disk). + + + + + This will remove all uninstalled downloads from this list (but NOT from disk). + + + + + DownloadManager + + + failed to rename "%1" to "%2" + + + + + Memory allocation error (in refreshing directory). + + + + + Query Metadata + + + + + There are %1 downloads with incomplete metadata. + +Do you want to fetch all incomplete metadata? +API requests will be consumed, and Mod Organizer may stutter. + + + + + failed to download %1: could not open output file: %2 + + + + + Download again? + + + + + A file with the same name "%1" has already been downloaded. Do you want to download it again? The new file will receive a different name. + + + + + Wrong Game + + + + + The download link is for a mod for "%1" but this instance of MO has been set up for "%2". + + + + + There is already a download queued for this file. + +Mod %1 +File %2 + + + + + Already Queued + + + + + There is already a download started for this file. + +Mod %1: %2 +File %3: %4 + + + + + Already Started + + + + + + remove: invalid download index %1 + + + + + failed to delete %1 + + + + + failed to delete meta file for %1 + + + + + restore: invalid download index: %1 + + + + + cancel: invalid download index %1 + + + + + pause: invalid download index %1 + + + + + resume: invalid download index %1 + + + + + resume (int): invalid download index %1 + + + + + No known download urls. Sorry, this download can't be resumed. + + + + + + query: invalid download index %1 + + + + + Please enter the Nexus mod ID + + + + + Mod ID: + + + + + Please select the source game code for %1 + + + + + Hashing download file '%1' + + + + + Cancel + + + + + VisitNexus: invalid download index %1 + + + + + Nexus ID for this Mod is unknown + + + + + OpenFile: invalid download index %1 + + + + + OpenFileInDownloadsFolder: invalid download index %1 + + + + + get pending: invalid download index %1 + + + + + get path: invalid download index %1 + + + + + Main + + + + + Update + + + + + Optional + + + + + Old + + + + + Miscellaneous + + + + + Deleted + + + + + Archived + + + + + Unknown + + + + + display name: invalid download index %1 + + + + + file name: invalid download index %1 + + + + + file time: invalid download index %1 + + + + + file size: invalid download index %1 + + + + + progress: invalid download index %1 + + + + + state: invalid download index %1 + + + + + infocomplete: invalid download index %1 + + + + + + + mod id: invalid download index %1 + + + + + ishidden: invalid download index %1 + + + + + file info: invalid download index %1 + + + + + mark installed: invalid download index %1 + + + + + mark uninstalled: invalid download index %1 + + + + + %1% - %2 - ~%3 + + + + + Memory allocation error (in processing progress event). + + + + + Memory allocation error (in processing downloaded data). + + + + + Information updated + + + + + + No matching file found on Nexus! Maybe this file is no longer available or it was renamed? + + + + + No file on Nexus matches the selected file by name. Please manually choose the correct one. + + + + + No download server available. Please try again later. + + + + + Failed to request file info from nexus: %1 + + + + + Warning: Content type is: %1 + + + + + Download header content length: %1 downloaded file size: %2 + + + + + Download failed: %1 (%2) + + + + + We were unable to download the file due to errors after four retries. There may be an issue with the Nexus servers. + + + + + failed to re-open %1 + + + + + Unable to write download to drive (return %1). +Check the drive's available storage. + +Canceling download "%2"... + + + + + DownloadsTab + + + Query Metadata + + + + + Cannot query metadata while offline mode is enabled. Do you want to disable offline mode? + + + + + EditExecutablesDialog + + + Modify Executables + + + + + Executables + + + + + + + Add an executable + + + + + Add + + + + + + + Remove the selected executable + + + + + Remove + + + + + + + Move the executable up in the list + + + + + Up + + + + + + + Move the executable down in the list + + + + + Down + + + + + + + Adds the executables provided by the game plugin and moves any existing executables out of the way + + + + + Reset + + + + + List of configured executables + + + + + This is a list of your configured executables. Executables in grey are automatically recognised and can not be modified. + + + + + Title + + + + + + Name of the executable. This is only for display purposes. + + + + + Binary + + + + + + Binary to run + + + + + Browse filesystem + + + + + Browse filesystem for the executable to run. + + + + + + ... + + + + + Start in + + + + + Arguments + + + + + + Arguments to pass to the application + + + + + Allow the Steam AppID to be used for this executable to be changed. + + + + + Allow the Steam AppID to be used for this executable to be changed. +Every game/tool distributed through Steam has a unique ID. MO needs to know this ID to start those programs directly, otherwise the program is started by steam and then MO will not work. By default, MO will use the AppID for the game. +Right now the only case I know of where this needs to be overwritten is for the Skyrim Creation Kit which has its own AppID. This overwrite is already preconfigured. + + + + + Overwrite Steam AppID + + + + + Steam AppID to use for this executable that differs from the games AppID. + + + + + Steam AppID to use for this executable that differs from the games AppID. +Every game/tool distributed through Steam has a unique ID. MO needs to know this ID to start those programs directly, otherwise the program is started by steam and then MO will not work. By default, MO will use the AppID for the game (usually 72850). +Right now the only case I know of where this needs to be overwritten is for the Skyrim Creation Kit which has its own AppID (usually 202480). This overwrite is already preconfigured. + + + + + If this is enabled, new files are created in the specified mod instead of the "Overwrite" mod. + + + + + Create files in mod instead of overwrite (*) + + + + + If this is enabled, the configured libraries will be automatically loaded when this executable is launched. + + + + + Force load libraries (*) + + + + + Configure Libraries + + + + + Use application's icon for desktop shortcuts + + + + + + This executable will not appear in the list, on the toolbar or in the menu. It will still be visible in this dialog. + + + + + Hide in user interface + + + + + (*) Profile specific + + + + + Add from file... + + + + + Add empty + + + + + Clone selected + + + + + Empty output mod + + + + + The output mod for %2 is empty. + + + + + Output mod not found + + + + + The output mod '%1' for %2 does not exist. + + + + + Reset plugin executables + + + + + This will restore all the executables provided by the game plugin. If there are existing executables with the same names, they will be automatically renamed and left unchanged. + + + + + + New Executable + + + + + Select a directory + + + + + Executables (*.exe *.bat *.jar) + + + + + All Files (*.*) + + + + + Select an executable + + + + + Java required + + + + + MO requires Java to run this application. If you already have it installed, select javaw.exe from that installation as the binary. + + + + + FileTree + + + Enter Name + + + + + Enter a name for the executable + + + + + Not an executable + + + + + This is not a recognized executable. + + + + + + File '%1' does not exist, you may need to refresh. + + + + + (only has %1 file(s)) + + + + + %1 file(s) selected + + + + + &Add as Executable + + + + + Add this file to the executables list + + + + + This file is not executable + + + + + Reveal in E&xplorer + + + + + Opens the file in Explorer + + + + + + + + + + + This file is in an archive + + + + + Open &Mod Info + + + + + Opens the Mod Info Window + + + + + This file is not in a managed mod + + + + + &Un-Hide + + + + + Un-hides the file + + + + + &Hide + + + + + Hides the file + + + + + &Execute + + + + + Launches this program + + + + + Execute with &VFS + + + + + Launches this program hooked to the VFS + + + + + &Open + + + + + Opens this file with its default handler + + + + + Open with &VFS + + + + + Opens this file with its default handler hooked to the VFS + + + + + &Preview + + + + + Previews this file within Mod Organizer + + + + + This file is in an archive or has no preview handler associated with it + + + + + &Save Tree to Text File... + + + + + Writes the list of files to a text file + + + + + &Refresh + + + + + Refreshes the list + + + + + Ex&pand All + + + + + &Collapse All + + + + + FileTreeModel + + + Name + + + + + Mod + + + + + Type + + + + + Size + + + + + Date modified + + + + + Directory + + + + + + Virtual path + + + + + Real path + + + + + From + + + + + + Also in + + + + + FileTreeTab + + + &New Folder + + + + + &Open/Execute + + + + + + Open with &VFS + + + + + &Preview + + + + + Open in &Explorer + + + + + &Rename + + + + + &Delete + + + + + &Hide + + + + + &Unhide + + + + + + New Folder + + + + + Failed to create "%1" + + + + + Are you sure you want to delete "%1"? + + + + + Are you sure you want to delete the selected files? + + + + + Confirm + + + + + Failed to delete %1 + + + + + &Execute + + + + + Execute with &VFS + + + + + &Open + + + + + FilterList + + + Filter separators + + + + + Show separators + + + + + Hide separators + + + + + Contains %1 + + + + + ForcedLoadDialog + + + Forced Load Settings + + + + + + Adds a row to the table. + + + + + Add Row + + + + + + Deletes the selected row from the table. + + + + + Delete Row + + + + + ForcedLoadDialogWidget + + + + If checked, the specified library will be force loaded for the specified process. + + + + + + The name of the process that should be forced to load a library. + + + + + Process name + + + + + + Browse for a process. + + + + + + ... + + + + + + The path to the library to be loaded. This may be a path relative to the managed game's directory or may be an absolute path to somewhere else. + + + + + Library to load + + + + + + Browse for a library. + + + + + GeneralConflictsTab + + + <table cellspacing="5"><tr><th>Type</th><th>%1</th><th>Total</th><th>Percent</th></tr><tr><td>Loose files:&emsp;</td><td align=right>%2</td><td align=right>%3</td><td align=right>%4%</td></tr><tr><td>Archive files:&emsp;</td><td align=right>%5</td><td align=right>%6</td><td align=right>%7%</td></tr><tr><td>Combined:&emsp;</td><td align=right>%8</td><td align=right>%9</td><td align=right>%10%</td></tr></table> + + + + + Winning + + + + + Losing + + + + + Non conflicting + + + + + InstallationManager + + + Password required + + + + + Password + + + + + + Extraction failed: %1 + + + + + Extracting files + + + + + Failed to create backup + + + + + Mod Name + + + + + Name + + + + + Invalid name + + + + + The name you entered is invalid, please enter a different one. + + + + + File format "%1" not supported + + + + + No category found + + + + + This Nexus category has not yet been mapped. Do you wish to proceed without setting a category, proceed and disable automatic Nexus mappings, or stop and configure your category mappings? + + + + + &Proceed + + + + + &Disable + + + + + &Stop && Configure + + + + + Invalid file tree returned by plugin. + + + + + Installation failed + + + + + Something went wrong while installing this mod. + + + + + None of the available installer plugins were able to handle that archive. +This is likely due to a corrupted or incompatible download or unrecognized archive format. + + + + + no error + + + + + 7z.dll not found + + + + + 7z.dll isn't valid + + + + + archive not found + + + + + failed to open archive + + + + + unsupported archive type + + + + + internal library error + + + + + archive invalid + + + + + unknown archive error + + + + + InstanceManagerDialog + + + Instance manager + + + + + Create new instance + + + + + <html><head/><body><p><a href="https://github.com/ModOrganizer2/modorganizer/wiki/Instances">What is an instance?</a></p></body></html> + + + + + Filter + + + + + Name + + + + + + + Explore + + + + + Game + + + + + Location + + + + + Rename + + + + + Game location + + + + + Base folder + + + + + Convert to portable + + + + + Convert to global + + + + + Open INI + + + + + Delete instance... + + + + + Switch to this instance + + + + + Close + + + + + Switching instances + + + + + Mod Organizer must restart to manage the instance '%1'. + + + + + This confirmation can be disabled in the settings. + + + + + Restart Mod Organizer + + + + + + Cancel + + + + + + Rename instance + + + + + The active instance cannot be renamed. + + + + + Instance name + + + + + Error + + + + + Failed to rename "%1" to "%2": %3 + + + + + + + Deleting instance + + + + + The active instance cannot be deleted. + + + + + These files and folders will be deleted + + + + + All checked items will be deleted. + + + + + Move to the recycle bin + + + + + Delete permanently + + + + + Nothing to delete. + + + + + A portable instance already exists. + + + + + ListDialog + + + Select an item... + + + + + Filter + + + + + LogList + + + Copy + + + + + &Copy all + + + + + C&lear all + + + + + &Open logs folder + + + + + &Level + + + + + &Debug + + + + + &Info + + + + + &Warnings + + + + + &Errors + + + + + Loot + + + Loot failed to run + + + + + No errors were reported. The log below might have more information. + + + + + + Errors + + + + + Warnings + + + + + failed to start loot + + + + + Loot failed. Exit code was: 0x%1 + + + + + LootDialog + + + LOOT + + + + + Progress + + + + + about:blank + + + + + Details + + + + + Open JSON report + + + + + Stopping LOOT... + + + + + Running LOOT... + + + + + MOApplication + + + Failed to create log folder. + + + + + Failed to set up data paths. + + + + + You need to select an instance before trying to download mods. + + + + + Download started + + + + + This shortcut or command line is for instance '%1', but the current instance is '%2'. + + + + + This shortcut or command line is for profile '%1', but the current profile is '%2'. + + + + + an error occurred: %1 + + + + + an error occurred + + + + + MOMultiProcess + + + SHM error: %1 + + + + + failed to connect to running process: %1 + + + + + failed to communicate with running process: %1 + + + + + failed to receive data from secondary process: %1 + + + + + MainWindow + + + Filters + + + + + State of the filter, can either be Disabled, Enabled or Inverted. + + + + + Filter to apply to the modlist. + + + + + Clear + + + + + Edit... + + + + + Display mods that match all selected categories. + + + + + And + + + + + Display mods that match at least one of the selected categories + + + + + Or + + + + + + Filter: only show the separators that match the current filters + Show: always show separators + Hide: never show separators + + + + + + Profile + + + + + Pick a module collection + + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + <html><head><meta name="qrichtext" content="1" /><style type="text/css"> + p, li { white-space: pre-wrap; } + </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Create profiles here. Each profile contains its own list of active mods and esps. This way you can quickly switch between setups for different playthroughs.</span></p> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Please note that right now your esp load order is not kept separate for different profiles.</span></p></body></html> + + + + + + Open list options... + + + + + Refresh list. This is usually not necessary unless you modified data outside the program. + + + + + Show Open Folders menu... + + + + + Restore Backup... + + + + + Create Backup + + + + + + Active: + + + + + This provides statistics about the mod list. The total number of active mod is normally displayed. Other statistics may be accessed with the tooltip of this counter. + + + + + This is a list of installed mods. Use the checkboxes to activate/deactivate mods and drag & drop mods to change their "installation" orders. + + + + + + + + Filter + + + + + Clear all Filters + + + + + No groups + + + + + Categories + + + + + Nexus IDs + + + + + Pick a program to run. + + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + <html><head><meta name="qrichtext" content="1" /><style type="text/css"> + p, li { white-space: pre-wrap; } + </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Choose the program to run. Once you start using ModOrganizer, you should always run your game and tools from here or through shortcuts created here, otherwise mods installed through MO will not be visible.</span></p> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">You can add new Tools to this list, but I can't promise tools I haven't tested will work.</span></p></body></html> + + + + + + Run program + + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + <html><head><meta name="qrichtext" content="1" /><style type="text/css"> + p, li { white-space: pre-wrap; } + </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Run the selected program with ModOrganizer enabled.</span></p></body></html> + + + + + + Run + + + + + Create a shortcut in your start menu or on the desktop to the specified program + + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + <html><head><meta name="qrichtext" content="1" /><style type="text/css"> + p, li { white-space: pre-wrap; } + </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">This creates a start menu shortcut that directly starts the selected program with the MO active.</span></p></body></html> + + + + + + Shortcut + + + + + Plugins + + + + + + + Sort the plugins using LOOT. + + + + + Sort + + + + + + Restore a backup. + + + + + + Create a backup. + + + + + <html><head/><body><p>This provides statistics about the plugin list. The total number of active plugins is normally displayed. Other statistics may be accessed with the tooltip of this counter.</p></body></html> + + + + + + List of available esp/esm files. + + + + + + Filter the list of plugins. + + + + + + Archives + + + + + <html><head/><body><p>BSAs / BA2s are bundles of game assets (textures, scripts, etc.). By default, the engine loads these bundles in a separate step from loose files. <p>Their load order is specified by the priority of the corresponding plugin (right pane, plugins tab).</p><p>If there is a matching plugin, the game will load them no matter what.</p></body></html> + + + + + <html><head/><body><p>Currently detected archives. (<a href="#">What is an archive?</a>)</p></body></html> + + + + + List of available BS Archives. Archives not checked here are not managed by MO and ignore installation order. + + + + + + BSA files are archives (comparable to .zip files) that contain data assets (meshes, textures, ...) to be used by the game. As such they "compete" with loose files in your data directory over which is loaded. + By default, BSAs that share their base name with an enabled ESP (i.e. plugin.esp and plugin.bsa) are automatically loaded and will have precedence over all loose files, the installation order you set up to the left is then ignored! + + BSAs checked here are loaded in such a way that your installation order is obeyed properly. + + + + + + Data + + + + + + Refresh the data structure. + + + + + + Refresh + + + + + This is an overview of your data directory as visible to the game (and tools). + + + + + + Filter the list so that only conflicts are displayed. + + + + + Conflicts only + + + + + + Filter the list so that files from archives are shown. + + + + + + Filter the list so that hidden files are shown. + + + + + + Hidden files + + + + + + Filter the Data tree. + + + + + Saves + + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + <html><head><meta name="qrichtext" content="1" /><style type="text/css"> + p, li { white-space: pre-wrap; } + </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">This is a list of all save games for this game. Hover over a list entry to get detailed information about the save including a list of esps/esms that were used at the time this save was created but aren't active now.</span></p> + <p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8pt;"></p> + <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">If you click &quot;Fix Mods...&quot; in the context menu, MO will try to activate all mods and esps to fix those missing esps. It will not disable anything!</span></p></body></html> + + + + + + + Name + + + + + File + + + + + Downloads + + + + + + Refresh the downloads. + + + + + Query Metadata + + + + + This is a list of mods you downloaded from Nexus. Double click one to install it. You can also drag an archive into here. + + + + + + Show downloads marked as hidden. + + + + + + Filter the list of downloads. + + + + + Main ToolBar + + + + + &File + + + + + + &Tools + + + + + + + &Help + + + + + &View + + + + + &Toolbars + + + + + &Run + + + + + + Log + + + + + Install &Mod... + + + + + Install &Mod + + + + + + Install a new mod from an archive + + + + + Ctrl+M + + + + + &Profiles... + + + + + &Profiles + + + + + + Configure profiles + + + + + Ctrl+P + + + + + &Executables... + + + + + &Executables + + + + + + Configure the executables that can be started through Mod Organizer + + + + + Ctrl+E + + + + + &Tool Plugins + + + + + Tools + + + + + Ctrl+I + + + + + &Settings... + + + + + &Settings + + + + + + Configure settings and workarounds + + + + + Ctrl+S + + + + + + Visit &Nexus + + + + + + Visit the Nexus website in your browser for more mods + + + + + Ctrl+N + + + + + + + + Browse Mod Page + + + + + + &Update Mod Organizer + + + + + + Mod Organizer is up-to-date + + + + + &Notifications... + + + + + + Open the notifications dialog + + + + + This button will be highlighted on the toolbar if MO discovered potential problems in your setup and provide tips on how to fix them. + + + + + + Show help options + + + + + Ctrl+H + + + + + + &Endorse ModOrganizer + + + + + + + Endorse Mod Organizer + + + + + + Manage Instan&ces... + + + + + + Open the instance manager window to manage a different instance + + + + + + E&xit + + + + + + Exits Mod Organizer + + + + + M&ain Toolbar + + + + + &Small Icons + + + + + Lar&ge Icons + + + + + &Icons Only + + + + + &Text Only + + + + + I&cons and Text + + + + + M&edium Icons + + + + + &Menu + + + + + Status &bar + + + + + &Refresh + + + + + F5 + + + + + Toolbar and Menu + + + + + Desktop + + + + + Start Menu + + + + + Crash on exit + + + + + MO crashed while exiting. Some settings may not be saved. + +Error: %1 + + + + + There are notifications to read + + + + + There are no notifications + + + + + Endorse + + + + + Won't Endorse + + + + + First Steps + Translation strings for tutorial names + + + + + Conflict Resolution + + + + + Overview + + + + + Help on UI + + + + + Documentation + + + + + + Game Support Wiki + + + + + Chat on Discord + + + + + Report Issue + + + + + Tutorials + + + + + About + + + + + About Qt + + + + + Please enter a name for the new profile + + + + + failed to create profile: %1 + + + + + Show tutorial? + + + + + You are starting Mod Organizer for the first time. Do you want to show a tutorial of its basic features? If you choose no you can always start the tutorial from the "Help" menu. + + + + + Never ask to show tutorials + + + + + Do you know how to mod this game? Do you need to learn? There's a game support wiki available! Click OK to open the wiki. In the future, you can access this link from the "Help" menu. + + + + + Category Setup + + + + + Please choose how to handle the default category setup. + +If you've already connected to Nexus, you can automatically import Nexus categories for this game (if applicable). Otherwise, use the old Mod Organizer default category structure, or leave the categories blank (for manual setup). + + + + + + &Import Nexus Categories + + + + + Use &Old Category Defaults + + + + + Do &Nothing + + + + + This is your first time running version 2.5 or higher with an old MO2 instance. The category system now relies on an updated system to map Nexus categories. + +In order to assign Nexus categories automatically, you will need to import the Nexus categories for the currently managed game and map them to your preferred category structure. + +You can either manually open the category editor, via the Settings dialog or the category filter sidebar, and set up the mappings as you see fit, or you can automatically import and map the categories as defined on Nexus. + +As a final option, you can disable Nexus category mapping altogether, which can be changed at any time in the Settings dialog. + + + + + &Open Categories Dialog + + + + + &Disable Nexus Mappings + + + + + &Close + + + + + &Don't show this again + + + + + Downloads in progress + + + + + There are still downloads in progress, do you really want to quit? + + + + + Plugin "%1" failed: %2 + + + + + Plugin "%1" failed + + + + + <Edit...> + + + + + (no executables) + + + + + This bsa is enabled in the ini file so it may be required! + + + + + Activating Network Proxy + + + + + Notice: Your current MO version (%1) is lower than the previously used one (%2). The GUI may not downgrade gracefully, so you may experience oddities. However, there should be no serious issues. + + + + + failed to change origin name: %1 + + + + + failed to move "%1" from mod "%2" to "%3": %4 + + + + + Open Game folder + + + + + Open MyGames folder + + + + + Open INIs folder + + + + + Open Instance folder + + + + + Open Mods folder + + + + + Open Profile folder + + + + + Open Downloads folder + + + + + Open MO2 Install folder + + + + + Open MO2 Plugins folder + + + + + Open MO2 Stylesheets folder + + + + + Open MO2 Logs folder + + + + + Restart Mod Organizer + + + + + Mod Organizer must restart to finish configuration changes + + + + + Restart + + + + + Continue + + + + + Some things might be weird. + + + + + Can't change download directory while downloads are in progress! + + + + + Update available + + + + + Do you want to endorse Mod Organizer on %1 now? + + + + + Abstain from Endorsing Mod Organizer + + + + + Are you sure you want to abstain from endorsing Mod Organizer 2? +You will have to visit the mod page on the %1 Nexus site to change your mind. + + + + + Thank you for endorsing MO2! :) + + + + + Please reconsider endorsing MO2 on Nexus! + + + + + There is no supported sort mechanism for this game. You will probably have to use a third-party tool. + + + + + None of your %1 mods appear to have had recent file updates. + + + + + All of your mods have been checked recently. We restrict update checks to help preserve your available API requests. + + + + + Thank you! + + + + + Thank you for your endorsement! + + + + + Mod ID %1 no longer seems to be available on Nexus. + + + + + Error %1: Request to Nexus failed: %2 + + + + + + failed to read %1: %2 + + + + + Error + + + + + failed to extract %1 (errorcode %2) + + + + + Extract BSA + + + + + This archive contains invalid hashes. Some files may be broken. + + + + + Extract... + + + + + Remove '%1' from the toolbar + + + + + Backup of load order created + + + + + Choose backup to restore + + + + + This file might be left over following a crash or power loss event. Check its contents before restoring. + + + + + No Backups + + + + + There are no backups to restore + + + + + + Restore failed + + + + + + Failed to restore the backup. Errorcode: %1 + + + + + Backup of mod list created + + + + + A file with the same name has already been downloaded. What would you like to do? + + + + + Overwrite + + + + + Rename new file + + + + + Ignore file + + + + + MessageDialog + + + + Placeholder + + + + + ModInfo + + + invalid mod index: %1 + + + + + remove: invalid mod index %1 + + + + + remove: failed to delete mod '%1' directory + + + + + The update check has found a mod with a Nexus ID and source game of %1, but this game is not a valid Nexus source. + + + + + All of your mods have been checked recently. We restrict update checks to help preserve your available API requests. + + + + + You have mods that haven't been checked within the last month using the new API. These mods must be checked before we can use the bulk update API. This will consume significantly more API requests than usual. You will need to rerun the update check once complete in order to parse the remaining mods. + + + + + ModInfoBackup + + + This is the backup of a mod + + + + + ModInfoDialog + + + Mod Info + + + + + + Text Files + + + + + A list of text-files in the mod directory. + + + + + A list of text-files in the mod directory like readmes. + + + + + INI Files + + + + + Ini Files + + + + + This is a list of .ini files in the mod. + + + + + This is a list of .ini files in the mod. These are usually used to configure the behaviour of mods if there are configurable parameters. + + + + + Images + + + + + Show .dds files + + + + + Open with Preview Plugin + + + + + Open in Explorer + + + + + 0x0 + + + + + + Optional Plugins + + + + + List of esps, esms, and esls that will not be loaded by the game. + + + + + List of esps, esms, and esls contained in this mod that currently will not be loaded by the game. They will not even appear in the plugin list in the main MO-window. +They usually contain optional functionality, see the readme. + +Most mods do not have optional plugins, so chances are good you are looking at an empty list. + + + + + Move a plugin to the data directory. + + + + + This moves a plugin to the data directory so it can be enabled in the main window. Please note that the plugin merely becomes "available", it will not necessarily be loaded! That is configured in the main window of MO. + + + + + Make the selected plugin in the right list unavailable. + + + + + The selected plugin (in the right list) will be pushed into a subdirectory of the mod and will thus become "invisible" to the game. It can then no longer be activated. + + + + + Available Plugins + + + + + Plugins in the data directory and thus visible to the game. + + + + + <html><head/><body><p>These are the plugins that are in the (virtual) data directory of your game and will thus be selectable in the plugin list in the main window.</p></body></html> + + + + + Conflicts + + + + + General + + + + + + Files that exist in other mods but are overwritten by this mod + + + + + Winning file conflicts: + + + + + + Files that are unused because they are overwritten by other mods + + + + + Losing file conflicts: + + + + + + Files that have no conflicts + + + + + The following files have no conflicts + + + + + Advanced + + + + + Whether files that have no conflicts should be visible in the list + + + + + Show files that have no conflicts + + + + + Shows all mods overwriting or being overwritten by this mod + + + + + Show all conflicting mods + + + + + Shows only the nearest conflicting mods, in order of priority + + + + + Show nearest conflicting mod + + + + + Filter + + + + + Categories + + + + + Primary Category + + + + + Nexus Info + + + + + Mod ID + + + + + Mod ID for this mod on Nexus. + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Mod ID for this mod on Nexus. This is filled in automatically if you downloaded and installed the mod from inside MO. Otherwise you can enter it manually. To find the correct id, find the mod on nexus. The URL will look like this: <a href=" https://www.nexusmods.com/skyrimspecialedition/mods/6194"><span style=" text-decoration: underline; color:#0000ff;">https://www.nexusmods.com/skyrimspecialedition/mods/6194</span></a>. In this example, 6194 is the id you're looking for. Besides: The above is the link to Mod Organizer 2 on Nexus. Why not go there now and endorse us?</p></body></html> + + + + + Source Game + + + + + Source game for this mod. + + + + + <html><head/><body><p>Source game for this mod. This determines where the mod was downloaded from and decides where to fetch info, version updates, and send endorsements. Changing this will likely require you to enter a new Mod ID.</p></body></html> + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Installed Version of the Mod. The tooltip will contain the current version available on nexus. The installed version is only set if you installed the mod through MO.</span></p></body></html> + + + + + Version + + + + + Category + + + + + + Refresh + + + + + Refresh all information from Nexus. + + + + + + Open in Browser + + + + + Endorse + + + + + Track + + + + + about:blank + + + + + Use Custom URL + + + + + Notes + + + + + + + Enter comments about the mod here. These are displayed in the notes column of the mod list. + + + + + Set Color + + + + + Reset Color + + + + + + + Enter notes about the mod here. These can be viewed in the mod list by hovering over the notes column or the flags column. + + + + + Filetree + + + + + Open Mod in Explorer + + + + + A directory view of this mod + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">This is a modifiable directory view of the mod directory. You can move around files using drag &amp; drop and rename them (double click).</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Changes happen immediately on disc, so do</span><span style=" font-size:8pt; font-weight:600;"> be careful</span><span style=" font-size:8pt;">.</span></p></body></html> + + + + + Previous + + + + + Next + + + + + Close + + + + + ModInfoForeign + + + This pseudo mod represents content managed outside MO. It isn't modified by MO. + + + + + DLC: + + + + + Creation Club: + + + + + Unmanaged: + + + + + ModInfoOverwrite + + + This pseudo mod contains files from the virtual data tree that got modified (i.e. by the construction kit) + + + + + ModInfoRegular + + + %1 contains no esp/esm/esl and no asset (textures, meshes, interface, ...) directory + + + + + Categories: <br> + + + + + ModInfoSeparator + + + This is a Separator + + + + + ModList + + + Backup + + + + + Separator + + + + + No valid game data + + + + + Not endorsed yet + + + + + This mod is for a different<br> game, make sure it's compatible or it could cause crashes. + + + + + Mod is being tracked on the website + + + + + Contains hidden files + + + + + Overwrites loose files + + + + + Overwritten loose files + + + + + Loose files Overwrites & Overwritten + + + + + Redundant + + + + + Overwrites an archive with loose files + + + + + Archive is overwritten by loose files + + + + + Overwrites another archive file + + + + + Overwritten by another archive file + + + + + Archive files overwrites & overwritten + + + + + Non-MO + + + + + invalid + + + + + installed version: "%1", newest version: "%2" + + + + + The newest version on Nexus seems to be older than the one you have installed. This could either mean the version you have has been withdrawn (i.e. due to a bug) or the author uses a non-standard versioning scheme and that newest version is actually newer. Either way you may want to "upgrade". + + + + + This file has been marked as "Old". There is most likely an updated version of this file available. + + + + + This file has been marked as "Deleted"! You may want to check for an update or remove the nexus ID from this mod! + + + + + %1 minute(s) and %2 second(s) + + + + + This mod will be available to check in %2. + + + + + Categories: <br> + + + + + Invalid name + + + + + Name is already in use by another mod + + + + + Confirm + + + + + Are you sure you want to remove "%1"? + + + + + Conflicts + + + + + Flags + + + + + Content + + + + + Mod Name + + + + + Version + + + + + Priority + + + + + Category + + + + + Source Game + + + + + Nexus ID + + + + + Installation + + + + + Notes + + + + + + unknown + + + + + Name of your mods + + + + + Version of the mod (if available) + + + + + Installation priority of your mod. The higher, the more "important" it is and thus overwrites files from mods with lower priority. + + + + + Primary category of the mod. + + + + + The source game which was the origin of this mod. + + + + + Id of the mod as used on Nexus. + + + + + Indicators of file conflicts between mods. + + + + + Emblems to highlight things that might require attention. + + + + + Depicts the content of the mod: + + + + + Time this mod was installed + + + + + User notes about the mod + + + + + ModListChangeCategoryMenu + + + Change Categories + + + + + ModListContextMenu + + + All Mods + + + + + Collapse all + + + + + Collapse others + + + + + Expand all + + + + + Information... + + + + + Send to... + + + + + Lowest priority + + + + + Highest priority + + + + + Priority... + + + + + Separator... + + + + + First conflict + + + + + Last conflict + + + + + Sync to Mods... + + + + + Create Mod... + + + + + Move content to Mod... + + + + + Clear Overwrite... + + + + + + + Open in Explorer + + + + + Rename Separator... + + + + + Remove Separator... + + + + + + Select Color... + + + + + + Reset Color + + + + + Restore Backup + + + + + Remove Backup... + + + + + + Ignore missing data + + + + + + Mark as converted/working + + + + + + Visit on Nexus + + + + + + Visit on %1 + + + + + Change versioning scheme + + + + + Force-check updates + + + + + Un-ignore update + + + + + Ignore update + + + + + Enable selected + + + + + Disable selected + + + + + Rename Mod... + + + + + Reinstall Mod + + + + + Remove Mod... + + + + + Create Backup + + + + + Restore hidden files + + + + + Un-Endorse + + + + + + Endorse + + + + + Won't endorse + + + + + Endorsement state unknown + + + + + Remap Category (From Nexus) + + + + + Start tracking + + + + + Stop tracking + + + + + Tracked state unknown + + + + + ModListGlobalContextMenu + + + Install mod above... + + + + + Create empty mod above + + + + + Install mod inside... + + + + + Create empty mod inside + + + + + Install mod below... + + + + + Create empty mod below + + + + + Create separator above + + + + + Install mod... + + + + + Create empty mod + + + + + Create separator + + + + + Collapse all + + + + + Expand all + + + + + Enable all + + + + + Disable all + + + + + Enable all matching mods + + + + + Disable all matching mods + + + + + Check for updates + + + + + Auto assign categories + + + + + Refresh + + + + + Export to csv... + + + + + ModListPrimaryCategoryMenu + + + Primary Category + + + + + ModListSortProxy + + + Drag&Drop is only supported when sorting by priority + + + + + ModListView + + + <table cellspacing="5"><tr><th>Type</th><th>All</th><th>Visible</th><tr><td>Enabled mods:&emsp;</td><td align=right>%1 / %2</td><td align=right>%3 / %4</td></tr><tr><td>Unmanaged/DLCs:&emsp;</td><td align=right>%5</td><td align=right>%6</td></tr><tr><td>Mod backups:&emsp;</td><td align=right>%7</td><td align=right>%8</td></tr><tr><td>Separators:&emsp;</td><td align=right>%9</td><td align=right>%10</td></tr></table> + + + + + Copy Folder... + + + + + This will copy the content of %1 to a new mod. +Please enter the name: + + + + + A mod with this name already exists. + + + + + Exception: + + + + + Unknown exception + + + + + <Multiple> + + + + + ModListViewActions + + + Choose Mod + + + + + Mod Archive + + + + + + Create Mod... + + + + + This will create an empty mod. +Please enter a name: + + + + + + A mod with this name already exists + + + + + Create Separator... + + + + + This will create a new separator. +Please enter a name: + + + + + A separator with this name already exists + + + + + Really enable %1 mod(s)? + + + + + Really disable %1 mod(s)? + + + + + + + Confirm + + + + + + You are not currently authenticated with Nexus. Please do so under Settings -> Nexus. + + + + + + + Are you sure? + + + + + This action will remove any existing categories on any mod with a valid Nexus category mapping. Are you certain you want to proceed? + + + + + &Don't show this again + + + + + Export to csv + + + + + CSV (Comma Separated Values) is a format that can be imported in programs like Excel to create a spreadsheet. +You can also use online editors and converters instead. + + + + + Select what mods you want export: + + + + + All installed mods + + + + + Only active (checked) mods from your current profile + + + + + All currently visible mods in the mod list + + + + + Choose what Columns to export: + + + + + Mod_Priority + + + + + Mod_Name + + + + + Notes_column + + + + + Mod_Status + + + + + Primary_Category + + + + + Nexus_ID + + + + + Mod_Nexus_URL + + + + + Mod_Version + + + + + Install_Date + + + + + Download_File_Name + + + + + export failed: %1 + + + + + Failed to display overwrite dialog: %1 + + + + + Set Priority + + + + + Set the priority of the selected mods + + + + + failed to rename mod: %1 + + + + + Remove the following mods?<br><ul>%1</ul> + + + + + failed to remove mod: %1 + + + + + Continue? + + + + + The versioning scheme decides which version is considered newer than another. +This function will guess the versioning scheme under the assumption that the installed version is outdated. + + + + + Sorry + + + + + I don't know a versioning scheme where %1 is newer than %2. + + + + + Opening Nexus Links + + + + + You are trying to open %1 links to Nexus Mods. Are you sure you want to do this? + + + + + + Opening Web Pages + + + + + + You are trying to open %1 Web Pages. Are you sure you want to do this? + + + + + + + Failed + + + + + Installation file no longer exists + + + + + Mods installed with old versions of MO can't be reinstalled in this way. + + + + + Failed to create backup. + + + + + Restore all hidden files in the following mods?<br><ul>%1</ul> + + + + + About to restore all hidden files in: + + + + + + Endorsing multiple mods will take a while. Please wait... + + + + + Overwrite? + + + + + This will replace the existing mod "%1". Continue? + + + + + failed to remove mod "%1" + + + + + failed to rename "%1" to "%2" + + + + + Move successful. + + + + + This will move all files from overwrite into a new, regular mod. +Please enter a name: + + + + + About to recursively delete: + + + + + + MotDDialog + + + Message of the Day + + + + + OK + + + + + NexusConnectionUI + + + Connected. + + + + + Not connected. + + + + + Disconnected. + + + + + Checking API key... + + + + + Received API key. + + + + + Received user account information + + + + + Linked with Nexus successfully. + + + + + Failed to set API key + + + + + NexusInterface + + + Please pick the mod ID for "%1" + + + + + You must authorize MO2 in Settings -> Nexus to use the Nexus API. + + + + + You've exceeded the Nexus API rate limit and requests are now being throttled. Your next batch of requests will be available in approximately %1 minutes and %2 seconds. + + + + + Aborting download: Either you clicked on a premium-only link and your account is not premium, or the download link was generated by a different account than the one stored in Mod Organizer. + + + + + empty response + + + + + invalid response + + + + + NexusManualKeyDialog + + + Manual Nexus API Key + + + + + 1. Get your personal API key + + + + + Open Browser + + + + + 2. Enter your personal API key + + + + + Paste + + + + + Clear + + + + + Enter API key here + + + + + <b>Sharing this key with anyone could get your Nexus account banned. You can always revoke this key from your account on the Nexus website.</b> + + + + + NexusTab + + + Current Version: %1 + + + + + No update available + + + + + Tracked + + + + + Untracked + + + + + + <div style="text-align: center;"> + <p>This mod does not have a valid Nexus ID. You can add a custom web + page for it in the "Custom URL" box below.</p> + </div> + + + + + NoConflictListModel + + + File + + + + + OrganizerCore + + + File is write protected + + + + + Invalid file format (probably a bug) + + + + + Unknown error %1 + + + + + Failed to write settings + + + + + An error occurred trying to write back MO settings to %1: %2 + + + + + Download started + + + + + Download failed + + + + + The selected profile '%1' does not exist. The profile '%2' will be used instead + + + + + Installation cancelled + + + + + Another installation is currently in progress. + + + + + Installation successful + + + + + Configure Mod + + + + + This mod contains ini tweaks. Do you want to configure them now? + + + + + mod not found: %1 + + + + + Extraction cancelled + + + + + The installation was cancelled while extracting files. If this was prior to a FOMOD setup, this warning may be ignored. However, if this was during installation, the mod will likely be missing files. + + + + + file not found: %1 + + + + + + failed to generate preview for %1 + + + + + Sorry + + + + + Sorry, can't preview anything. This function currently does not support extracting from bsas. + + + + + File '%1' not found. + + + + + Failed to generate preview for %1 + + + + + Failed to refresh list of esps: %1 + + + + + Multiple esps/esls activated, please check that they don't conflict. + + + + + You need to be logged in with Nexus + + + + + Download? + + + + + A download has been started but no installed page plugin recognizes it. +If you download anyway no information (i.e. version) will be associated with the download. +Continue? + + + + + + failed to update mod list: %1 + + + + + + login successful + + + + + Login failed + + + + + Login failed, try again? + + + + + login failed: %1. Download will not be associated with an account + + + + + login failed: %1 + + + + + login failed: %1. You need to log-in with Nexus to update MO. + + + + + MO1 "Script Extender" load mechanism has left hook.dll in your game folder + + + + + + Description missing + + + + + <a href="%1">hook.dll</a> has been found in your game folder (right click to copy the full path). This is most likely a leftover of setting the ModOrganizer 1 load mechanism to "Script Extender", in which case you must remove this file either by changing the load mechanism in ModOrganizer 1 or manually removing the file, otherwise the game is likely to crash and burn. + + + + + failed to save load order: %1 + + + + + Error + + + + + The designated write target "%1" is not enabled. + + + + + OverwriteConflictListModel + + + File + + + + + Overwritten Mods + + + + + OverwriteFileSystemModel + + + Overwrites + + + + + not implemented + + + + + OverwriteInfoDialog + + + Overwrite + + + + + Open in Explorer + + + + + You can use drag&drop to move files and directories to regular mods. + + + + + &Delete + + + + + &Rename + + + + + &Open + + + + + &New Folder + + + + + mod not found: %1 + + + + + Failed to delete "%1" + + + + + + + + Confirm + + + + + + Are you sure you want to delete "%1"? + + + + + + Are you sure you want to delete the selected files? + + + + + + New Folder + + + + + Failed to create "%1" + + + + + OverwrittenConflictListModel + + + File + + + + + Providing Mod + + + + + PluginContainer + + + Plugin error + + + + + Mod Organizer failed to load the plugin '%1' last time it was started. + + + + + The plugin can be skipped for this session, blacklisted, or loaded normally, in which case it might fail again. Blacklisted plugins can be re-enabled later in the settings. + + + + + Skip this plugin + + + + + Blacklist this plugin + + + + + Load this plugin + + + + + Some plugins could not be loaded + + + + + + Description missing + + + + + The following plugins could not be loaded. The reason may be missing dependencies (i.e. python) or an outdated version: + + + + + PluginList + + + Name + + + + + Priority + + + + + Mod Index + + + + + Flags + + + + + + Form Version + + + + + + Header Version + + + + + + Author + + + + + + Description + + + + + + unknown + + + + + Name of the plugin + + + + + Emblems to highlight things that might require attention. + + + + + Load priority of plugins. The higher, the more "important" it is and thus overwrites data from plugins with lower priority. + + + + + Determines the formids of objects originating from this mods. + + + + + Form version of the plugin. + + + + + Header version of the plugin. + + + + + Author of the plugin. + + + + + Description of the plugin. + + + + + failed to update esp info for file %1 (source id: %2), error: %3 + + + + + Plugin not found: %1 + + + + + Origin + + + + + This plugin can't be disabled or moved (enforced by the game). + + + + + This plugin can't be disabled (enforced by the game). + + + + + Missing Masters + + + + + Enabled Masters + + + + + Loads Archives + + + + + There are Archives connected to this plugin. Their assets will be added to your game, overwriting in case of conflicts following the plugin order. Loose files will always overwrite assets from Archives. (This flag only checks for Archives from the same mod as the plugin) + + + + + Loads INI settings + + + + + There is an ini file connected to this plugin. Its settings will be added to your game settings, overwriting in case of conflicts. + + + + + This %1 is flagged as a light plugin (ESL). It will adhere to the %1 load order but the records will be loaded in ESL space (FE/FF). You can have up to 4096 light plugins in addition to other plugin types. + + + + + This ESM is flagged as a medium plugin (ESH). It adheres to the ESM load order but loads records in ESH space (FD). You can have 256 medium plugins in addition to other plugin types. + + + + + This plugin has the blueprint flag. This forces it to load after every other non-blueprint plugin. Blueprint plugins will adhere to standard load order rules with other blueprint plugins. + + + + + WARNING: This plugin is both light and medium flagged. This could indicate that the file was saved improperly and may have mismatched record references. Use it at your own risk. + + + + + This is a dummy plugin. It contains no records and is typically used to load a paired archive file. + + + + + Light plugins (ESL) are not supported by this game. + + + + + This game does not currently permit custom plugin loading. There may be manual workarounds. + + + + + Incompatible with %1 + + + + + Depends on missing %1 + + + + + Warning + + + + + Error + + + + + failed to restore load order for %1 + + + + + PluginListContextMenu + + + Enable selected + + + + + Disable selected + + + + + Enable all + + + + + + Confirm + + + + + Really enable all plugins? + + + + + Disable all + + + + + Really disable all plugins? + + + + + Unlock load order + + + + + Lock load order + + + + + Open Origin in Explorer + + + + + Open Origin Info... + + + + + Send to... + + + + + Top + + + + + Bottom + + + + + Priority... + + + + + Set Priority + + + + + Set the priority of the selected plugins + + + + + PluginListSortProxy + + + Drag&Drop is only supported when sorting by priority or mod index + + + + + PluginListView + + + <table cellspacing="6"><tr><th>Type</th><th>Active </th><th>Total</th></tr><tr><td>All plugins:</td><td align=right>%1 </td><td align=right>%2</td></tr><tr><td>ESMs:</td><td align=right>%3 </td><td align=right>%4</td></tr><tr><td>ESPs:</td><td align=right>%5 </td><td align=right>%6</td></tr><tr><td>ESMs+ESPs:</td><td align=right>%7 </td><td align=right>%8</td></tr> + + + + + <tr><td>ESHs:</td><td align=right>%1 </td><td align=right>%2</td></tr> + + + + + <tr><td>ESLs:</td><td align=right>%1 </td><td align=right>%2</td></tr> + + + + + <tr><td>Blueprint masters:</td><td align=right>%1 </td><td align=right>%2</td></tr> + + + + + + Sorting plugins + + + + + + Are you sure you want to sort your plugins list? + + + + + Note: You are currently in offline mode and LOOT will not update the master list. + + + + + Exception: + + + + + Unknown exception + + + + + PluginTypeName + + + Diagnose + + + + + Game + + + + + Installer + + + + + Mod Page + + + + + Preview + + + + + Tool + + + + + Proxy + + + + + File Mapper + + + + + PreviewDialog + + + Preview + + + + + Close + + + + + ProblemsDialog + + + Notifications + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Click a notification above to get more details...</p></body></html> + + + + + Close + + + + + Fix + + + + + No guided fix + + + + + (There are no notifications) + + + + + Profile + + + invalid profile name: %1 + + + + + failed to create %1 + + + + + failed to write mod list: %1 + + + + + failed to update tweaked ini file, wrong settings may be used: %1 + + + + + failed to create tweaked ini: %1 + + + + + failed to open %1 + + + + + "%1" is missing or inaccessible + + + + + + + + + invalid mod index: %1 + + + + + A mod named "overwrite" was detected, disabled, and moved to the highest priority on the mod list. You may want to rename this mod and enable it again. + + + + + Delete profile-specific save games? + + + + + Do you want to delete the profile-specific save games? (If you select "No", the save games will show up again if you re-enable profile-specific save games) + + + + + Missing profile-specific game INI files! + + + + + Some of your profile-specific game INI files were missing. They will now be copied from the vanilla game folder. You might want to double-check your settings. + +Missing files: + + + + + + Delete profile-specific game INI files? + + + + + Do you want to delete the profile-specific game INI files? (If you select "No", the INI files will be used again if you re-enable profile-specific game INI files.) + + + + + ProfileInputDialog + + + Dialog + + + + + Please enter a name for the new profile + + + + + If checked, the new profile will use the default game INI settings. + + + + + If checked, the new profile will use the default game INI settings instead of the "global" settings. Global settings are the settings you configure when running the game launcher directly, without MO. + + + + + Default Game INI Settings + + + + + ProfilesDialog + + + Profiles + + + + + List of Profiles + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">This is the list of profiles. Each profile contains its own list and installation order of enabled mods (from a shared pool), its own list and load order of enabled plugins (esps/esms), a copy of the games ini-file and an optional savegame filter.</span></p></body></html> + + + + + <html><head/><body><p>If checked, save games are stored locally to this profile and will not appear when starting with a different profile.</p></body></html> + + + + + If checked, save games are local to this profile and will not appear when starting with a different profile. + + + + + Use profile-specific Save Games + + + + + <html><head/><body><p>If checked MO2 will use his own profile-specific game INI files, so that the &quot;Global&quot; ones in MyGames can be left vanilla. This different set of INI files is then offered to the game instead of the default one.</p></body></html> + + + + + <html><head/><body><p>If checked, MO2 will use a local set of game INI files (configuration and settings files), different from the default ones found in MyGames. This way changes to the INI settings will only affect this profile and the Global INI files can remain vanilla. MO2 will then show the profile INI files to the game instead of the Global ones.</p></body></html> + + + + + Use profile-specific Game INI Files + + + + + This ensures data files from mods are actually used. You want to enable this unless you use a different tool for Archive Invalidation. + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The games Oblivion, Fallout 3 and Fallout NV contain a bug which prevents texture and mesh replacers (that is: all modifications to meshes and textures already in game) from working.</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The Mod Organizer uses a workaround called &quot;BSA redirection&quot; (google is your friend) to fix this issue reliably and without further work. Simply activate and forget.</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8pt;"></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">With Skyrim this bug seems to be fixed to a degree but whether a mod becomes active still depends on file dates. Therefore, it still makes sense to activate this.</span></p></body></html> + + + + + Automatic Archive Invalidation + + + + + + Create a new profile from scratch + + + + + Create + + + + + Clone the selected profile + + + + + This creates a new profile with the same settings and active mods as the selected one. + + + + + Copy + + + + + + Delete the selected Profile. This can not be un-done! + + + + + Remove + + + + + Rename + + + + + + Transfer save games to the selected profile. + + + + + Transfer Saves + + + + + Select + + + + + Close + + + + + Archive invalidation isn't required for this game. + + + + + This game does not support profile-specific game saves. + + + + + + failed to create profile: %1 + + + + + Name + + + + + Please enter a name for the new profile + + + + + failed to copy profile: %1 + + + + + Invalid name + + + + + Invalid profile name + + + + + Deleting active profile + + + + + Unable to delete active profile. Please change to a different profile first. + + + + + Confirm + + + + + Are you sure you want to remove this profile (including profile-specific save games, if any)? + + + + + Profile broken + + + + + This profile you're about to delete seems to be broken or the path is invalid. I'm about to delete the following folder: "%1". Proceed? + + + + + Renaming active profile + + + + + The active profile cannot be renamed. Please change to a different profile first. + + + + + Rename Profile + + + + + New Name + + + + + failed to change archive invalidation state: %1 + + + + + failed to determine if invalidation is active: %1 + + + + + QObject + + + Regular + + + + + Premium + + + + + + None + + + + + Active + + + + + Update available + + + + + Has category + + + + + Conflicted + + + + + Has hidden files + + + + + Endorsed + + + + + Has backup + + + + + Managed + + + + + Has valid game data + + + + + Has Nexus ID + + + + + Tracked on Nexus + + + + + Is overwritten (loose files) + + + + + Is overwriting (loose files) + + + + + Is overwritten (archives) + + + + + Is overwriting (archives) + + + + + Mod contains selected file + + + + + Plugin is contained in selected mod + + + + + Plugin is master of selected plugin + + + + + failed to start application: %1 + + + + + Executable '%1' not found in instance '%2'. + + + + + Failed to run '%1'. The logs might have more information. + + + + + Failed to run '%1'. The logs might have more information. %2 + + + + + Download URL must start with https:// + + + + + Download started + + + + + Creating %1 + + + + + Global + + + + + + + Portable + + + + + + Instance type: %1 + + + + + ? + + + + + Find game installation for %1 + + + + + Find game installation + + + + + + + Unrecognized game + + + + + The folder %1 does not seem to contain a game Mod Organizer can manage. + + + + + See details for the list of supported games. + + + + + No installation found + + + + + Browse... + + + + + The folder must contain a valid game installation + + + + + + Microsoft Store game + + + + + The folder %1 seems to be a Microsoft Store game install. Games installed through the Microsoft Store are not supported by Mod Organizer and will not work properly. + + + + + + + Use this folder for %1 + + + + + Use this folder + + + + + + + I know what I'm doing + + + + + + + + + + + + + + + + + + Cancel + + + + + The folder %1 does not seem to contain an installation for <span style="white-space: nowrap; font-weight: bold;">%2</span> or for any other game Mod Organizer can manage. + + + + + + Incorrect game + + + + + The folder %1 seems to contain an installation for <span style="white-space: nowrap; font-weight: bold;">%2</span>, not <span style="white-space: nowrap; font-weight: bold;">%3</span>. + + + + + Manage %1 instead + + + + + Instance location: %1 + + + + + Instance name: %1 + + + + + Profile settings: + + + + + Local INIs: %1 + + + + + + + yes + + + + + + + no + + + + + Local Saves: %1 + + + + + Automatic Archive Invalidation: %1 + + + + + + Base directory: %1 + + + + + Downloads + + + + + Mods + + + + + Profiles + + + + + Overwrite + + + + + Game: %1 + + + + + Game location: %1 + + + + + invalid field name "%1" + + + + + invalid type for "%1" (should be integer) + + + + + invalid type for "%1" (should be string) + + + + + invalid type for "%1" (should be float) + + + + + no fields set up yet! + + + + + field not set "%1" + + + + + invalid character in field "%1" + + + + + empty field name + + + + + Disabling the '%1' plugin will prevent the following %2 plugin(s) from working: + + + + + + + + No menu available + + + + + Selected files must be in the same directory + + + + + The hidden file "%1" already exists. Replace it? + + + + + The visible file "%1" already exists. Replace it? + + + + + Replace file? + + + + + + File operation failed + + + + + Failed to remove "%1": %2 + + + + + Failed to rename file: %1. + +Source: +"%2" + +Destination: +"%3" + + + + + Disabled because + + + + + invalid 7-zip32.dll: %1 + + + + + Cannot open instance '%1', failed to read INI file %2. + + + + + Cannot open instance '%1', the managed game was not found in the INI file %2. Select the game managed by this instance. + + + + + Cannot open instance '%1', the game plugin '%2' doesn't exist. It may have been deleted by an antivirus. Select another instance. + + + + + Cannot open instance '%1', the game directory '%2' doesn't exist or the game plugin '%3' doesn't recognize it. Select the game managed by this instance. + + + + + The instance name must be a valid folder name. + + + + + An instance with this name already exists. + + + + + + + + + + + Error + + + + + + Failed to create "%1". Your user account probably lacks permission. + + + + + General messages + + + + + Plugins + + + + + No messages. + + + + + Incompatibilities + + + + + Missing masters + + + + + Verified clean by %1 + + + + + %1 found %2 ITM record(s), %3 deleted reference(s) and %4 deleted navmesh(es). + + + + + + + Warning + + + + + failed to run loot: %1 + + + + + Checking masterlist existence + + + + + Updating masterlist + + + + + Loading lists + + + + + Reading plugins + + + + + Sorting plugins + + + + + Writing loadorder.txt + + + + + Parsing loot messages + + + + + Done + + + + + Mod Organizer + + + + + An instance of Mod Organizer is already running + + + + + <Unmanaged> + + + + + Please use "Help" from the toolbar to get usage instructions to all elements + + + + + Visit %1 on Nexus + + + + + + <Manage...> + + + + + failed to parse profile %1: %2 + + + + + Instance at '%1' not found. Select another instance. + + + + + Instance at '%1' not found. You must create a new instance + + + + + File Exists + + + + + A file with that name exists, please enter a new one + + + + + + Failed to move file + + + + + Failed to create directory "optional" + + + + + Save changes? + + + + + Save changes to "%1"? + + + + + Connecting to Nexus... + + + + + Waiting for Nexus... + + + + + Opened Nexus in browser. + + + + + Switch to your browser and accept the request. + + + + + Finished. + + + + + No answer from Nexus. + + + + + + A firewall might be blocking Mod Organizer. + + + + + Nexus closed the connection. + + + + + Cancelled. + + + + + Failed to request %1 + + + + + + Cancelled + + + + + Internal error + + + + + HTTP code %1 + + + + + Invalid JSON + + + + + Bad response + + + + + API key is empty + + + + + SSL error + + + + + Timed out + + + + + One of the configured MO2 directories (profiles, mods, or overwrite) is on a path containing a symbolic (or other) link. This is likely to be incompatible with MO2's virtual filesystem. + + + + + failed to initialize plugin %1: %2 + + + + + failed to access %1 + + + + + failed to set file time %1 + + + + + + + No profile set + + + + + This shortcut is for instance '%1' but Mod Organizer is currently running for '%2'. Exit Mod Organizer before running the shortcut or change the active instance. + + + + + Executable '%1' does not exist in instance '%2'. + + + + + failed to create %1 + + + + + Before you can use ModOrganizer, you need to create at least one profile. ATTENTION: Run the game at least once before creating a profile! + + + + + '%1': file is blocked (%2) + + + + + '%1' seems to be missing, an antivirus may have deleted it + + + + + %1 is loaded. +This program is known to cause issues with Mod Organizer, such as freezing or blank windows. Consider uninstalling it. + + + + + %1 is loaded. This program is known to cause issues with Mod Organizer and its virtual filesystem, such script extenders or others programs refusing to run. Consider uninstalling it. + + + + + + + attempt to store setting for unknown plugin "%1" + + + + + Failed + + + + + Failed to start the helper application: %1 + + + + + + Debug + + + + + + Info (recommended) + + + + + Trace + + + + + Mini (recommended) + + + + + Data + + + + + Full + + + + + Confirm? + + + + + This will reset all the choices you made to dialogs and make them all visible again. Continue? + + + + + + + Enter API Key Manually + + + + + + + Connect to Nexus + + + + + + + + + N/A + + + + + Executables (*.exe) + + + + + All Files (*.*) + + + + + Select the browser executable + + + + + Failed to create "%1", you may not have the necessary permissions. Path remains unchanged. + + + + + Select base directory + + + + + Select download directory + + + + + Select mod directory + + + + + Select cache directory + + + + + Select profiles directory + + + + + Select overwrite directory + + + + + Select game executable + + + + + The given path was not recognized as a valid game installation. The current game plugin requires the executable to be in a "%1" subfolder of the game directory. + + + + + Cannot disable plugin + + + + + The '%1' plugin is used by the current game plugin and cannot disabled. + + + + + <p>Disabling the '%1' plugin will also disable the following plugins:</p><ul>%1</ul><p>Do you want to continue?</p> + + + + + Really disable plugin? + + + + + This plugin is required for Mod Organizer to work properly and cannot be disabled. + + + + + Executables Blacklist + + + + + Enter one executable per line to be blacklisted from the virtual file system. +Mods and other virtualized files will not be visible to these executables and +any executables launched by them. + +Example: + Chrome.exe + Firefox.exe + + + + + Skip File Suffixes + + + + + Enter one file suffix per line to be skipped / ignored from the virtual file system. +Not to be confused with file extensions, file suffixes are simply how the filename ends. + +Example: + .txt - Would skip all files that end with .txt, <any text>.txt + some_file.txt - Would skip all files that end with some_file.txt, <any text>some_file.txt + + + + + Skip Directories + + + + + Enter one directory per line to be skipped / ignored from the virtual file system. + +Example: + .git + instructions + + + + + + + Restart Mod Organizer + + + + + Geometries will be reset to their default values. + + + + + unexpected end of path + + + + + invalid file index for remove + + + + + invalid file index for remove (for origin) + + + + + invalid origin name: + + + + + failed to change name lookup from {0} to {1} + + + + + This error typically happens because an antivirus has deleted critical files from Mod Organizer's installation folder or has made them generally inaccessible. Add an exclusion for Mod Organizer's installation folder in your antivirus, reinstall Mod Organizer and try again. + + + + + This error typically happens because an antivirus is preventing Mod Organizer from starting programs. Add an exclusion for Mod Organizer's installation folder in your antivirus and try again. + + + + + The file '%1' does not exist. + + + + + The working directory '%1' does not exist. + + + + + + + + Cannot start Steam + + + + + The path to the Steam executable cannot be found. You might try reinstalling Steam. + + + + + + + Continue without starting Steam + + + + + + The program may fail to launch. + + + + + Cannot launch program + + + + + + + Cannot start %1 + + + + + Cannot launch helper + + + + + + Elevation required + + + + + This program is requesting to run as administrator but Mod Organizer itself is not running as administrator. Running programs as administrator is typically unnecessary as long as the game and Mod Organizer have been installed outside "Program Files". + +You can restart Mod Organizer as administrator and try launching the program again. + + + + + + Restart Mod Organizer as administrator + + + + + + You must allow "helper.exe" to make changes to the system. + + + + + Launch Steam + + + + + This program requires Steam + + + + + Mod Organizer has detected that this program likely requires Steam to be running to function properly. + + + + + Start Steam + + + + + + The program might fail to run. + + + + + Steam is running as administrator + + + + + Running Steam as administrator is typically unnecessary and can cause problems when Mod Organizer itself is not running as administrator. + +You can restart Mod Organizer as administrator and try launching the program again. + + + + + + + Continue + + + + + Event Log not running + + + + + The Event Log service is not running + + + + + The Windows Event Log service is not running. This can prevent USVFS from running properly and your mods may not be recognized by the program being launched. + + + + + + Your mods might not work. + + + + + Blacklisted program + + + + + The program %1 is blacklisted + + + + + The program you are attempting to launch is blacklisted in the virtual filesystem. This will likely prevent it from seeing any mods, INI files or any other virtualized files. + + + + + Change the blacklist + + + + + Waiting + + + + + Please press OK once you're logged into steam. + + + + + Select binary + + + + + Binary + + + + + This tracks the number of queued Nexus API requests, as well as the remaining daily and hourly requests. The Nexus API limits you to a pool of requests per day and requests per hour. It is dynamically updated every time a request is completed. If you run out of requests, you will be unable to queue downloads, check updates, parse mod info, or even log in. Both pools must be consumed before this happens. + + + + + Loading... + + + + + &Save + + + + + &Word wrap + + + + + &Open in Explorer + + + + + Mod Organizer is locked while the application is running. + + + + + Mod Organizer is currently running an application. + + + + + The application must run to completion because its output is required. + + + + + Mod Organizer is waiting on an application to close before exiting. + + + + + Unlock + + + + + Exit Now + + + + + QueryOverwriteDialog + + + Mod Exists + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">This mod seems to be installed already, what would you like to do?</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /><span style=" font-weight:600;">Merge:</span> Add files from this archive overwriting existing ones.</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-weight:600;">Replace:</span> Completely replace the existing mod (old files are deleted).</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-weight:600;">Rename:</span> Install this as a separate mod with a new name<span style=" font-style:italic;"> (recommended)</span>.</p></body></html> + + + + + Keep Backup + + + + + Merge + + + + + Replace + + + + + Rename + + + + + Cancel + + + + + SaveTextAsDialog + + + Dialog + + + + + Copy To Clipboard + + + + + Save As... + + + + + Close + + + + + Save CSV + + + + + Text Files + + + + + failed to open "%1" for writing + + + + + SavesTab + + + %1 more + + + + + Confirm + + + + + Are you sure you want to remove the following %n save(s)?<br><ul>%1</ul><br>Removed saves will be sent to the Recycle Bin. + + + + + + + + Fix enabled mods... + + + + + Delete %n save(s) + + + + + + + + Open in Explorer... + + + + + SelectionDialog + + + Select + + + + + Placeholder + + + + + Cancel + + + + + SelfUpdater + + + Download failed + + + + + Failed to find correct download, please try again later. + + + + + Update + + + + + Download in progress + + + + + Download failed: %1 + + + + + Failed to install update: %1 + + + + + Failed to start %1: %2 + + + + + Error + + + + + SettingsDialog + + + Settings + + + + + General + + + + + Language + + + + + <a href="https://discord.gg/ewUVAqyrQX">Help translate Mod Organizer on Discord</a> + + + + + + The language of the user interface. + + + + + Download List + + + + + + Show meta information instead of file names in the download list. + + + + + Show meta information + + + + + + Make the download list more compact. + + + + + Compact list + + + + + + Automatically hide downloads after successful installation. + + + + + Hide downloads after installation + + + + + Updates + + + + + + Check for Mod Organizer updates on Github on startup. + + + + + Check for updates + + + + + + Update to non-stable releases. + + + + + Update to beta versions + + + + + Profile Defaults + + + + + Local INIs + + + + + Local Saves + + + + + Automatic Archive Invalidation + + + + + Miscellaneous + + + + + + Dialogs will always be centered on the main window, but will remember their size. + + + + + Always center dialogs + + + + + Show confirmation when changing instance + + + + + + Show the menubar when the Alt key is pressed + + + + + Show menubar when pressing Alt + + + + + Whether double-clicking on a file opens the preview window or launches the program associated with it. This applies to the Data tab as well as the Conflicts and Filetree tabs in the mod info window. + + + + + Open previews on double-click + + + + + + Reset all choices made in dialogs. + + + + + Reset Dialog Choices + + + + + + Modify the categories available to arrange your mods. + + + + + Configure Mod Categories + + + + + Theme + + + + + Style + + + + + + Visual theme of the user interface. + + + + + Explore... + + + + + Colors + + + + + + Reset all colors to their default value. + + + + + Reset Colors + + + + + Mod List + + + + + + Colors set on separators will also be shown in the mod list scrollbar at the location of the separator. This can be useful for quickly navigating to a specific separator. + + + + + Show mod list separator colors on the scrollbar + + + + + Disable this to no longer display mods installed outside MO in the mod list (left pane). Assets from those mods will then be treated as having lowest mod priority together with the original game content. + + + + + By default Mod Organizer will display esp+bsa bundles installed with foreign tools as mods (left pane). This allows you to control their priority in relation to other mods. This is particularly useful if you also use Steam Workshop to install mods. +However, if you installed loose file mods outside MO which conflict with BSAs also installed outside MO those conflicts can't be resolved correctly. + +If you disable this feature, MO will only display official DLCs this way. Please note that plugins (esps, esms, and esls) displayed in the right pane are completely unaffected by this feature. + + + + + Display mods installed outside MO + + + + + + Save the current filters when closing MO2 and restore them on startup. + + + + + Remember selected filters after restarting MO + + + + + + Check if updates are available for mods after installing them. + + + + + Check for updates when installing mods + + + + + + Automatically collapse separators, categories or nexus ids after a delay when hovering them during drag. + + + + + Automatically collapse items during drag on hover + + + + + Collapsible Separators + + + + + + Highlight collapsed separators based on conflicts and plugins from mods inside them. + + + + + on separators + + + + + Enable when sorting by + + + + + Show conflicts and plugins + + + + + + When selecting a collapsed separator, highlight conflicting mods and plugins from mods inside the separator. + + + + + from separators + + + + + ascending priority + + + + + descending priority + + + + + Show icons on separators + + + + + conflicts + + + + + flags + + + + + content + + + + + version + + + + + + Do not share the collapse/expanded state of separators between profiles. + + + + + Profile-specific collapse states for separators + + + + + Paths + + + + + + + + + ... + + + + + Caches + + + + + Overwrite + + + + + + Directory where downloads are stored. + + + + + Downloads + + + + + Profiles + + + + + Directory where mods are stored. + + + + + Directory where mods are stored. Please note that changing this will break all associations of profiles with mods that don't exist in the new location (with the same name). + + + + + Mods + + + + + Managed Game + + + + + Base Directory + + + + + Use %BASE_DIR% to refer to the Base Directory. + + + + + All directories must be writable. + + + + + Nexus + + + + + Nexus Account + + + + + User ID: + + + + + Name: + + + + + Account: + + + + + Statistics + + + + + Daily requests: + + + + + Hourly requests: + + + + + Nexus Connection + + + + + Connect to Nexus + + + + + Manually enter the API key and try to login + + + + + Enter API Key Manually + + + + + Clear the stored Nexus API key and force reauthorization. + + + + + Disconnect from Nexus + + + + + + Options + + + + + Endorsement Integration + + + + + Tracked Integration + + + + + Use Nexus category mappings + + + + + + <html><head/><body><p>By default, a counter is displayed in the bottom right corner. This informs the user of their remaining API requests. The Nexus API becomes unusable once these API requests run out. Checking this option will hide that counter.</p></body></html> + + + + + Hide API Request Counter + + + + + Associate with "Download with manager" links + + + + + Remove cache and cookies. + + + + + Clear Cache + + + + + Servers + + + + + Known Servers (updated on download) + + + + + Preferred Servers (Drag & Drop) + + + + + Plugins + + + + + Author: + + + + + Version: + + + + + Description: + + + + + Enabled + + + + + Key + + + + + Value + + + + + No plugin found. + + + + + Blacklisted Plugins (use <del> to remove): + + + + + Workarounds + + + + + If checked, files (i.e. esps, esms and bsas) belonging to the core game can not be disabled in the UI. (default: on) + + + + + If checked, files (i.e. esps, esms and bsas) belonging to the core game can not be disabled in the UI. (default: on) +Uncheck this if you want to use Mod Organizer with total conversions (like Nehrim) but be aware that the game will crash if required files are not enabled. + + + + + Force-enable game files + + + + + Enable parsing of Archives. This is an Experimental Feature. Has negative effects on performance and known incorrectness. + + + + + <html><head/><body><p>By default, MO will parse archive files (BSA, BA2) to calculate conflicts between the contents of the archive files and other loose files. This process has a noticeable cost in performance.</p><p>This feature should not be confused with the archive management feature offered by MO1. MO2 will only show conflicts with archives and will NOT load them into the game or program.</p><p>If you disable this feature, MO will only display conflicts between loose files.</p></body></html> + + + + + Enable archives parsing (experimental) + + + + + + Disable this to prevent the GUI from being locked when running an executable. This may result in abnormal behavior. + + + + + Lock GUI when running executable + + + + + Steam + + + + + Password + + + + + Username + + + + + Steam App ID + + + + + The Steam AppID for your game + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The Steam App ID is required to directly start some games. For Skyrim, if this is not set or wrong, the &quot;Mod Organizer&quot; load mechanism may not work properly.</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The preset for this is the App ID of the &quot;regular&quot; version so in most cases, you should be set.</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">If you think you have a different version (GotY or something), follow these steps to get to the id:</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">1. Navigate to the game library in steam</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">2. right-click on the game you need the id for and choose </span><span style=" font-size:8pt; font-weight:600;">Create desktop shortcut</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">3. right-click on the newly created shortcut on your desktop and select </span><span style=" font-size:8pt; font-weight:600;">Properties</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">4. in the URL-field you should see something like this: </span><span style=" font-size:8pt; font-style:italic;">steam://rungameid/22380</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">22380 is the id you're looking for.</span></p></body></html> + + + + + Network + + + + + Disable automatic internet features + + + + + Disable automatic internet features. This does not affect features that are explicitly invoked by the user (like checking mods for updates, endorsing, opening the web browser) + + + + + Offline Mode + + + + + Use a proxy for network connections. + + + + + Use a proxy for network connections. This uses the system-wide settings which can be configured in Internet Explorer. Please note that MO will start up a few seconds slower on some systems when using a proxy. + + + + + Use System HTTP Proxy + + + + + + + + + + Use "%1" as a placeholder for the URL. + + + + + Custom browser + + + + + + Resets the window geometries for all windows. This can be useful if a window becomes too small or too large, if a column becomes too thin or too wide, and in similar situations. + + + + + Reset Window Geometries + + + + + + + For Skyrim, this can be used instead of Archive Invalidation. It should make AI redundant for all Profiles. + For the other games this is not a sufficient replacement for AI! + + + + + + Back-date BSAs + + + + + Add executables to the blacklist to prevent them from +accessing the virtual file system. This is useful to prevent +unintended programs from being hooked. Hooking unintended +programs may affect the execution of these programs or the +programs you are intentionally running. + + + + + Add executables to the blacklist to prevent them from accessing the virtual file system. This is useful to prevent unintended programs from being hooked. Hooking unintended programs may affect the execution of these programs or the programs you are intentionally running. + + + + + Executables Blacklist + + + + + + Files to skip or ignore from the virtual file system. + + + + + Skip File Suffixes + + + + + + Directories to skip or ignore from the virtual file system. + + + + + Skip Directories + + + + + These are workarounds for problems with Mod Organizer. Please make sure you read the help text before changing anything here. + + + + + Diagnostics + + + + + Logs and Crashes + + + + + Log Level + + + + + Decides the amount of data printed to "ModOrganizer.log" + + + + + + Decides the amount of data printed to "ModOrganizer.log". + "Debug" produces very useful information for finding problems. There is usually no noteworthy performance impact but the file may become rather large. If this is a problem you may prefer the "Info" level for regular use. On the "Error" level the log file usually remains empty. + + + + + + Crash Dumps + + + + + Decides which type of crash dumps are collected when injected processes crash. + + + + + + Decides which type of crash dumps are collected when injected processes crash. + "None" Disables the generation of crash dumps by MO. + "Mini" Default level which generates small dumps (only stack traces). + "Data" Much larger dumps with additional information which may be need (also data segments). + "Full" Even larger dumps with a full memory dump of the process. + + + + + + Max Dumps To Keep + + + + + Maximum number of crash dumps to keep on disk. Use 0 for unlimited. + + + + + + Maximum number of crash dumps to keep on disk. Use 0 for unlimited. + Set "Crash Dumps" above to None to disable crash dump collection. + + + + + + Integrated LOOT + + + + + LOOT Log Level + + + + + Click a link to open the location + + + + + + Logs and crash dumps are stored under your current instance in the <a href="LOGS_FULL_PATH">LOGS_DIR</a> + and <a href="DUMPS_FULL_PATH">DUMPS_DIR</a> folders. + Sending logs and/or crash dumps to the developers can help investigate issues. + It is recommended to compress large log and dmp files before sending. + + + + + + Confirm + + + + + Changing the mod directory affects all your profiles! Mods not present (or named differently) in the new location will be disabled in all profiles. There is no way to undo this unless you backed up your profiles manually. Proceed? + + + + + StatusBar + + + Unknown game + + + + + SyncOverwriteDialog + + + Sync Overwrite + + + + + Name + + + + + Sync To + + + + + <don't sync> + + + + + failed to remove %1 + + + + + failed to move %1 to %2 + + + + + T + + + Plugin + + + + + TransferSavesDialog + + + Transfer Save Games + + + + + Global Characters + + + + + This is a list of characters in the global location. + + + + + This is a list of characters in the global location. + +On Windows Vista/Windows 7: + C:\Users\[UserName]\Documents\My Games\Skyrim\Saves + +On Windows XP: + C:\Documents and Settings\[UserName]\My Documents\My Games\Skyrim\Saves + + + + + + This is a list of save games for the selected character in the global location. + +On Windows Vista/Windows 7: + C:\Users\[UserName]\Documents\My Games\Skyrim\Saves + +On Windows XP: + C:\Documents and Settings[UserName]\My Documents\My Games\Skyrim\Saves + + + + + + + Move -> + + + + + Copy -> + + + + + <- Move + + + + + <- Copy + + + + + Done + + + + + Profile Characters + + + + + Characters for profile %1 + + + + + Overwrite + + + + + Overwrite the file "%1" + + + + + Confirm + + + + + TutorialCanceller + + + Exit Tutorial + + + + + UpdateDialog + + + Changelog + + + + + Install + + + + + Cancel + + + + + about:blank + + + + + Mod Organizer %1 is available. The current version is %2. Updating will not affect your mods or profiles. + + + + + UsvfsConnector + + + Preparing vfs + + + + + ValidationProgressDialog + + + Validating Nexus Connection + + + + + + Connecting to Nexus... + + + + + Cancel + + + + + Hide + + + + + Trying again... + + + + + tutorial_conflictresolution_main + + + Welcome to the conflict resolution tutorial. This tutorial is very dense with information, so take it slow and feel free to revisit as needed - until you have a solid grasp of the types of conflicts and tools to manage them. + + + + + Some info primarily applies to Bethesda Game Studios games. If this does not apply to you, you can exit the tutorial when you reach the section about "record conflicts". + + + + + There are multiple types of conflicts you may encounter when dealing with mods. This tutorial will try to cover and explain how to deal with all of them. + + + + + First up are file conflicts. These occur when two mods contain the same file. Most commonly this happens when several mods replace the same standard asset from the game, like the texture of an armor. + + + + + As an example, say you install "Mod A" which contains stylish new iron and leather armor. Then you install "Mod B" which contains sexy ebony and leather armor. Obviously there is a conflict now: which leather armor to use? + + + + + If you were to install the mods manually, when installing "Mod B" you would be asked if you want to overwrite conflicting files. If you choose yes, you get the leather armor from "Mod B" otherwise you keep the one from "Mod A". If you later decide you made the wrong choice, you have to reinstall one of the mods. + + + + + With MO, both "Mod A" and "Mod B" are installed independently, without overwriting any files. Initially, "Mod B" gets to provide the leather armor because it's automatically assigned the higher priority. + + + + + However, you can change the mod priority at any time by dragging and dropping mods in the list. If you assign "Mod A" a higher priority, it provides the leather armor, no re-installation required. Since the priorities of mods in this list are treated as if the mods were installed in that order, this can generally be referred to as "installation order". + + + + + If the "Flags" column is enabled in the mod list, it will show you which mods are involved in a conflict. + + + + + <img src="qrc:///MO/gui/emblem_conflict_overwrite" /> indicates that the mod overwrites files that are also available in another mod. + + + + + <img src="qrc:///MO/gui/emblem_conflict_overwritten" /> indicates that the mod is <b>partially</b> overwritten by another. + + + + + <img src="qrc:///MO/gui/emblem_conflict_mixed" /> indicates that both of the these are true. + + + + + <img src="qrc:///MO/gui/emblem_conflict_redundant" /> indicates that the mod is completely overwritten. It provides no active files and is essentially disabled. + + + + + There are two ways to see the individual files involved in a conflict: + + + + + Option A: Switch to the "Data" tab. + + + + + In the "Data" tab, if you check the highlighted control, the tree will only show conflicted files. In the right column, it displays which mod is currently providing the file (due to having the highest priority), and if you hover your mouse over that info, it will list which other mods contain it. + + + + + Option B: Open the <i>Information</i> dialog of an <b>enabled</b> mod by either double-clicking it or selecting <i>Information...</i> from the right-click menu. + + + + + That is everything you need to know about file conflicts. The second type of conflict to deal with is "record conflicts". + + + + + In the "First Steps" tutorial, you learned how plugins contain changes to the game world, like modifications to the terrain or existing NPCs. Each of these changes is stored in a record, hence the name "record conflict". For example, when two mods try to change the same location, only one change can become active. + + + + + Similar to the mod list, you will have to choose which plugins will be loaded last and take priority over other records. This time around however, choosing an incorrect order can cause your game to become unstable, as there may be strict dependencies between plugin files and records. + + + + + Please open the "Plugins" tab... + + + + + As with mods, you can drag and drop plugins to change their priority, thus deciding which plugins take precedence in regards to conflicts. This is commonly called the "load order". But how do you know how to order the plugins? + + + + + Unlike with file conflicts, MO can provide only minimal help, indicating whether required "master" plugins are present in the load order. The good news is, there is a perfect tool for that called LOOT. + + + + + MO has a built in integration with LOOT which can be used via the "Sort" button for any supported game. This will attempt to use any configuration set up within the main LOOT application. + + + + + If LOOT has been installed, Mod Organizer should detect it for any supported game and automatically add it to the available tools. + + + + + When you run LOOT, it will automatically re-organize plugins for best compatibility (overwriting your manual changes). It will also notate the plugin list with information about patches, incompatibilities, and other useful info. This is true in both the main LOOT application and for the integration within Mod Organizer. + + + + + The final type of conflicts are a subset of "record conflicts". We will call these "lists conflicts". As briefly mentioned earlier, these types of record conflicts can be merged so you may be able to get all modifications in your game. + + + + + One common example of such records are leveled lists that contain all the items that may spawn at a specific character level. Traditionally, if multiple mods add items to such a list, only one of these mods will actually take effect. In some cases, there are community-made patches to resolve these issues. + + + + + Fortunately, there are also tools to merge many types of records so that they can all take effect. For Oblivion, Skyrim, and Fallout 4, look for Wrye Bash. For Fallout 3 and New Vegas, you can use Wrye Flash. These can create a "bashed patch," which is a plugin that combines many mergeable records from all of your mods. There are other, similar tools for more specific tasks such as 'Synergy'. + + + + + Finally, advanced users may consider using 'xEdit', a robust tool for comparing, modifying, and cleaning plugin records. With this you can create your own custom patch files to merge any combination of plugins and records. + + + + + This completes the tutorial. Hopefully you have a better grasp on the intricacies of conflict resolution. Good luck, and happy modding! + + + + + tutorial_conflictresolution_modinfo + + + Please switch to the "Conflicts"-Tab. + + + + + Here you can see two lists: a list of files that this mod overwrites that are also provided by other mods, and a list of files in this mod which are overwritten by one or more other mods. + + + + + Please close the information dialog. + + + + + tutorial_firststeps_browser + + + This is a fully featured browser that is set up to open the correct nexus page for your game. You can download any mod using the "DOWNLOAD WITH MANAGER"-button or the "manual"-link and it will be downloaded by MO. + + + + + tutorial_firststeps_main + + + Welcome to the Mod Organizer Tutorial! This will guide you through the most common features of MO2. + +It is highly recommended for first-time users to complete the tutorial from beginning to end to properly demonstrate key components of the tool. + + + + + Before we continue with the step-by-step tutorial, here are a few ways you can receive help with Mod Organizer. + + + + + The highlighted button will display potential problems detected with your setup and may suggest solutions. (Click it and then close the window to proceed.) + + + + + + +It appears you have one now, however you can hold off on clearing it until after completing the tutorial. + + + + + This button contains additional information about the application, links to other sources of help, and further tutorials. (Open the menu to proceed.) + + + + + Finally, there are tooltips and extra information available all across Mod Organizer. If there is a control you don't understand, please try hovering over it for a short description. Alternatively, you can use "Help on UI" from the Help menu to click on some controls and get a comprehensive explanation. + + + + + This list displays all mods installed through MO2. It also displays installed DLCs and any 'unmanaged' mods installed outside MO2. You have limited control over those. + + + + + Before we start installing mods, let's have a quick look at the settings. (Open the settings dialog to proceed via the highlighted button.) + + + + + Now it's time to install a mod! + +(This will be a requirement in order to demonstrate other features later.) + + + + + There are a few ways to get mods into Mod Organizer. If you associated MO with NXM links in the settings you can now use your regular browser to send downloads from Nexus to MO. Click on "Nexus" to open nexus, find a mod and click the green download buttons on Nexus saying "Download with Manager". + + + + + You can also install mods from disk using the "Install Mod" button. + + + + + Downloads will appear on the "Downloads" tab here. You have to download and install at least one mod to proceed. + + + + + Great, you just installed your first mod. Please note that the installation procedure may differ based on how a mod was packaged. + + + + + Now you know all about downloading and installing mods, but they are not enabled yet... + + + + + Install a few more mods if you want, then enable them by checking them in the left pane. Mods that aren't enabled have no effect on the game whatsoever. + + + + + For some mods, enabling it on the left pane is all you have to do... + + + + + ...but for some games they may contain plugins. These are files the game must load and are required to change or add aspects of the game (new weapons, armors, quests, areas, ...). + +Please open the "Plugins" tab to get a list of plugins. + + + + + You may notice some plugins are grayed out. These are part of the main game and can't be disabled. + + + + + A single mod may contain zero, one, or multiple plugin files. Some or all may be optional. If in doubt, please consult the documentation of the individual mod. To do so, right-click the mod and select "Information". + + + + + Now you know how to download, install, and enable mods. + +It's important you always start the game from inside MO, otherwise the mods you installed here won't work. + + + + + This combobox lets you choose <i>what</i> to start. This is how you will launch the game or any other tool which must access the game's mod directories. If a tool is not listed here, you can also configure these options, but that is an advanced topic. + + + + + This completes the basic tutorial. Feel free to play the game and try out your new mods! Once you have installed a larger number, you may want to continue with the tutorial on conflict resolution. + + + + + tutorial_firststeps_modinfo + + + This dialog tries to expose as much information about a mod as possible. Depending on the mod, this may include readmes, screenshots, optional plugins and so on. If a certain type of information was not found in a mod, the corresponding tab is grayed out. + + + + + If you installed the mod from Nexus, the corresponding tab should give you direct access to the mod page description, which can be refreshed directly from Nexus. + + + + + We may revisit this screen in later tutorials. + + + + + tutorial_firststeps_settings + + + It is possible to download files directly from Nexus. + +Please open the "Nexus" tab. + + + + + Clicking on this button should register Nexus "Download with Manager" buttons to download with Mod Organizer. + + + + + Use this interface to obtain an API key from NexusMods. This is used for all API connections - downloads, updates etc. MO2 uses the Windows Credential Manager to store this data securely. If the SSO page on Nexus is failing, use the manual entry and copy the API key from your profile. + + + + + tutorial_primer_main + + + This window shows all the mods that are installed. The column headers can be used for sorting. Only checked mods are active in the current profile. + + + + + Each profile is a separate set of enabled mods and ini settings. + + + + + Perform various actions on your mod list, such as refreshing data and checking for mod updates. + + + + + Quick access to various directories, such as your MO2 mods, profiles, saves, and your active game location. + + + + + Restore a mod list backup. + + + + + Create a backup of your current mod list. + + + + + Running counter of your active mods. Hover to see a more detailed breakdown. + + + + + The dropdown allows various ways of grouping the mods shown in the mod list. + + + + + Show/hide the category pane. + + + + + Quickly filter the mod list as you type. + + + + + Switch between information views. + + + + + This shows mod categories and some meta categories (in angle-brackets). Select some to filter the mod list. For example select "<Checked>" to show only active mods. + + + + + Customizable list for choosing the program to run. + + + + + When this button is clicked, Mod Organizer creates a virtual directory structure then runs the program selected to the left. + + + + + Will create a shortcut for quick access. The shortcut can be placed in the toolbar at the top, in the Start Menu or on the Windows Desktop. + + + + + Log messages produced by MO. Please note that messages with a light bulb usually don't require your attention. + + + + + Indicator of your current NexusMods API request limits. + + + + + Change/manage MO2 instances or switch to portable mode. + + + + + Browse to and manually install a mod from an archive on your computer. + + + + + + Automatically open NexusMods to browse and install mods via the API. + + + + + Manage your MO2 profiles. + + + + + Refresh everything. + + + + + Open the executable editor to add and modify applications you wish to run with MO2. + + + + + Select from a collection of additional tools, such as an INI editor, integrated FNIS updater, and more. + + + + + Configure Mod Organizer. + + + + + See the status of and/or endorse MO2 on NexusMods. + + + + + Notifications about the current setup. + + + + + Activates if there is an update for MO. Please note that if, for any reason, MO can't communicate with NMM, this will not work either. + + + + + Access more information about MO2, including these tutorials, a link to the development discord, information about the devs and dependencies. + + + + + Plugins (esp/esm/esl files) of the mods in the current profile. They need to be checked to be loaded. + + + + + Automatically sort plugins using the bundled LOOT application. + + + + + Restore a backup of your plugin list order. + + + + + Save a backup of your plugin list order. + + + + + Counter of your total active plugins. Hover to see a breakdown of plugin types. + + + + + Quickly filter plugin list as you type. + + + + + All the asset archives (.bsa files) for all active mods. + + + + + The directory tree and all files that the program will see. + + + + + Save game browser. Shows all the saves for the current profile and whether or not the current mod-load status is correct. + + + + + Shows the mods that have been downloaded and if they’ve been installed. + + + + + Click to quit + + + + + tutorial_window_installer + + + This mod has been packaged in a way that Mod Organizer did not automatically recognize... + + + + + You can use drag&drop on this list to fix the structure of the mod. + + + + + The correct structure replicates the data-directory of the game. That means esps, the "meshes"- or "textures"-directory and so on should be directly below "<data>". + + + + + You can also disable files and directories that you don't want to unpack. + + + + + From the context menu (right-click) you can open textfiles, in case you want to access a readme. + + + + + This text will turn green if MO thinks the structure looks good. + + + + diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp new file mode 100644 index 0000000..a239447 --- /dev/null +++ b/src/src/organizercore.cpp @@ -0,0 +1,2422 @@ +#include "organizercore.h" +#include "categoriesdialog.h" +#include "credentialsdialog.h" +#include "delayedfilewriter.h" +#include "directoryrefresher.h" +#include "env.h" +#include "envfs.h" +#include "envmodule.h" +#include "filedialogmemory.h" +#include "guessedvalue.h" +#include "imodinterface.h" +#include "imoinfo.h" +#include "instancemanager.h" +#include "iplugingame.h" +#include "iuserinterface.h" +#include "messagedialog.h" +#include "modlistsortproxy.h" +#include "modrepositoryfileinfo.h" +#include "nexusinterface.h" +#include "nxmaccessmanager.h" +#include "plugincontainer.h" +#include "previewdialog.h" +#include "profile.h" +#include "shared/appconfig.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "shared/util.h" +#include "spawn.h" +#include "syncoverwritedialog.h" +#include "virtualfiletree.h" +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#else +#include "fluorineconfig.h" +#include "wineprefix.h" +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include // for qUtf8Printable, etc + +#ifdef _WIN32 +#include +#include +#include // for _tcsicmp +#include +#endif + +#include +#include +#include // for memset, wcsrchr + +#include +#include +#include +#include +#include +#include //for wstring +#include +#include +#include + +#include + +#include "organizerproxy.h" + +using namespace MOShared; +using namespace MOBase; + +static env::CoreDumpTypes g_coreDumpType = env::CoreDumpTypes::Mini; + +template +QStringList toStringList(InputIterator current, InputIterator end) +{ + QStringList result; + for (; current != end; ++current) { + result.append(*current); + } + return result; +} + +#ifndef _WIN32 +QString resolveWinePrefixPath(const Settings& settings, + const IPluginGame* managedGame) +{ + Q_UNUSED(managedGame); + + if (auto cfg = FluorineConfig::load(); cfg.has_value() && cfg->prefixExists()) { + return cfg->prefix_path.trimmed(); + } + + const QSettings instanceSettings(settings.filename(), QSettings::IniFormat); + for (const auto& key : {"Settings/proton_prefix_path", "Settings/prefix_path", + "Proton/prefix_path", "fluorine/prefix_path"}) { + const QString value = instanceSettings.value(key).toString().trimmed(); + if (!value.isEmpty()) { + return value; + } + } + + return {}; +} + +QString resolveWineDataDirName(const IPluginGame* managedGame) +{ + if (managedGame != nullptr) { + const QString docsLeaf = managedGame->documentsDirectory().dirName().trimmed(); + if (!docsLeaf.isEmpty()) { + return docsLeaf; + } + return managedGame->gameName(); + } + return {}; +} + +QString resolveSaveRelativePath(std::shared_ptr profile, + const IPluginGame* managedGame, + MOBase::LocalSavegames* localSaves) +{ + if (profile == nullptr || managedGame == nullptr) { + return "Saves"; + } + + const QString profileSaveDir = + QDir(profile->absolutePath()).filePath("saves"); + const QString gameDocumentsDir = + QDir::cleanPath(managedGame->documentsDirectory().absolutePath()); + + if (localSaves != nullptr) { + const MappingType mappings = localSaves->mappings(QDir(profileSaveDir)); + for (const auto& mapping : mappings) { + if (!mapping.isDirectory) { + continue; + } + + const QString source = QDir::cleanPath(mapping.source); + const QString destination = QDir::cleanPath(mapping.destination); + if (source == QDir::cleanPath(profileSaveDir) && + destination.startsWith(gameDocumentsDir, Qt::CaseInsensitive)) { + const QString relative = + QDir(gameDocumentsDir).relativeFilePath(destination); + if (!relative.isEmpty() && relative != ".") { + return relative; + } + } + } + } + + const auto iniFiles = managedGame->iniFiles(); + if (iniFiles.isEmpty()) { + return "Saves"; + } + + const QString iniPath = profile->absoluteIniFilePath(iniFiles[0]); + if (!QFileInfo::exists(iniPath)) { + return "Saves"; + } + + QSettings ini(iniPath, QSettings::IniFormat); + const QString savePath = ini.value("General/SLocalSavePath").toString().trimmed(); + return savePath.isEmpty() ? "Saves" : savePath; +} +#endif + +OrganizerCore::OrganizerCore(Settings& settings) + : m_UserInterface(nullptr), m_PluginContainer(nullptr), m_GamePlugin(nullptr), + m_CurrentProfile(nullptr), m_Settings(settings), + m_Updater(&NexusInterface::instance()), m_ModList(m_PluginContainer, this), + m_PluginList(*this), + m_DirectoryRefresher(new DirectoryRefresher(this, settings.refreshThreadCount())), + m_DirectoryStructure(new DirectoryEntry(L"data", nullptr, 0)), + m_VirtualFileTree([this]() { + return VirtualFileTree::makeTree(m_DirectoryStructure); + }), + m_DownloadManager(&NexusInterface::instance(), this), m_DirectoryUpdate(false), + m_ArchivesInit(false), + m_PluginListsWriter(std::bind(&OrganizerCore::savePluginList, this)) +{ + env::setHandleCloserThreadCount(settings.refreshThreadCount()); + m_DownloadManager.setOutputDirectory(m_Settings.paths().downloads(), false); + + NexusInterface::instance().setCacheDirectory(m_Settings.paths().cache()); + + m_InstallationManager.setModsDirectory(m_Settings.paths().mods()); + m_InstallationManager.setDownloadDirectory(m_Settings.paths().downloads()); + + connect(&m_DownloadManager, SIGNAL(downloadSpeed(QString, int)), this, + SLOT(downloadSpeed(QString, int))); + connect(m_DirectoryRefresher.get(), &DirectoryRefresher::refreshed, this, + &OrganizerCore::onDirectoryRefreshed); + + connect(&m_ModList, SIGNAL(removeOrigin(QString)), this, SLOT(removeOrigin(QString))); + connect(&m_ModList, &ModList::modStatesChanged, [=] { + currentProfile()->writeModlist(); + }); + connect(&m_ModList, &ModList::modPrioritiesChanged, [this](auto&& indexes) { + modPrioritiesChanged(indexes); + }); + + connect(NexusInterface::instance().getAccessManager(), + SIGNAL(validateSuccessful(bool)), this, SLOT(loginSuccessful(bool))); + connect(NexusInterface::instance().getAccessManager(), + SIGNAL(validateFailed(QString)), this, SLOT(loginFailed(QString))); + + // This seems awfully imperative + connect(this, SIGNAL(managedGameChanged(MOBase::IPluginGame const*)), &m_Settings, + SLOT(managedGameChanged(MOBase::IPluginGame const*))); + connect(this, SIGNAL(managedGameChanged(MOBase::IPluginGame const*)), + &m_DownloadManager, SLOT(managedGameChanged(MOBase::IPluginGame const*))); + connect(this, SIGNAL(managedGameChanged(MOBase::IPluginGame const*)), &m_PluginList, + SLOT(managedGameChanged(MOBase::IPluginGame const*))); + + connect(&m_PluginList, &PluginList::writePluginsList, &m_PluginListsWriter, + &DelayedFileWriterBase::write); + + // make directory refresher run in a separate thread + m_RefresherThread.start(); + m_DirectoryRefresher->moveToThread(&m_RefresherThread); + + connect(&settings.plugins(), &PluginSettings::pluginSettingChanged, + [this](auto const&... args) { + m_PluginSettingChanged(args...); + }); +} + +OrganizerCore::~OrganizerCore() +{ + m_RefresherThread.exit(); + m_RefresherThread.wait(); + + if (m_StructureDeleter.joinable()) { + m_StructureDeleter.join(); + } + + saveCurrentProfile(); + + // profile has to be cleaned up before the modinfo-buffer is cleared + m_CurrentProfile.reset(); + + ModInfo::clear(); + m_ModList.setProfile(nullptr); + // NexusInterface::instance()->cleanup(); + + delete m_DirectoryStructure; +} + +void OrganizerCore::storeSettings() +{ + if (m_CurrentProfile != nullptr) { + m_Settings.game().setSelectedProfileName(m_CurrentProfile->name()); + } + + m_ExecutablesList.store(m_Settings); + + FileDialogMemory::save(m_Settings); + + const auto result = m_Settings.sync(); + + if (result != QSettings::NoError) { + QString reason; + + if (result == QSettings::AccessError) { + reason = tr("File is write protected"); + } else if (result == QSettings::FormatError) { + reason = tr("Invalid file format (probably a bug)"); + } else { + reason = tr("Unknown error %1").arg(result); + } + + QMessageBox::critical( + qApp->activeWindow(), tr("Failed to write settings"), + tr("An error occurred trying to write back MO settings to %1: %2") + .arg(m_Settings.filename(), reason)); + } +} + +void OrganizerCore::updateExecutablesList() +{ + if (m_PluginContainer == nullptr) { + log::error("can't update executables list now"); + return; + } + + m_ExecutablesList.load(managedGame(), m_Settings); +} + +void OrganizerCore::updateModInfoFromDisc() +{ + ModInfo::updateFromDisc(m_Settings.paths().mods(), *this, + m_Settings.interface().displayForeign(), + m_Settings.refreshThreadCount()); +} + +void OrganizerCore::setUserInterface(IUserInterface* ui) +{ + storeSettings(); + + m_UserInterface = ui; + + QWidget* w = nullptr; + if (m_UserInterface) { + w = m_UserInterface->mainWindow(); + } + + m_InstallationManager.setParentWidget(w); + m_Updater.setUserInterface(w); + m_UILocker.setUserInterface(w); + m_DownloadManager.setParentWidget(w); + + checkForUpdates(); +} + +void OrganizerCore::checkForUpdates() +{ + // this currently wouldn't work reliably if the ui isn't initialized yet to + // display the result + if (m_UserInterface != nullptr) { + m_Updater.testForUpdate(m_Settings); + } +} + +void OrganizerCore::connectPlugins(PluginContainer* container) +{ + m_PluginContainer = container; + m_Updater.setPluginContainer(m_PluginContainer); + m_InstallationManager.setPluginContainer(m_PluginContainer); + m_DownloadManager.setPluginContainer(m_PluginContainer); + m_ModList.setPluginContainer(m_PluginContainer); + + if (!m_GameName.isEmpty()) { + m_GamePlugin = m_PluginContainer->game(m_GameName); + emit managedGameChanged(m_GamePlugin); + } + + connect(m_PluginContainer, &PluginContainer::pluginEnabled, [&](IPlugin* plugin) { + m_PluginEnabled(plugin); + }); + connect(m_PluginContainer, &PluginContainer::pluginDisabled, [&](IPlugin* plugin) { + m_PluginDisabled(plugin); + }); + + connect(&m_PluginContainer->gameFeatures(), &GameFeatures::modDataContentUpdated, + [this](ModDataContent const* contentFeature) { + if (contentFeature) { + m_Contents = ModDataContentHolder(contentFeature->getAllContents()); + } else { + m_Contents = ModDataContentHolder(); + } + }); +} + +void OrganizerCore::setManagedGame(MOBase::IPluginGame* game) +{ + m_GameName = game->gameName(); + m_GamePlugin = game; + qApp->setProperty("managed_game", QVariant::fromValue(m_GamePlugin)); + emit managedGameChanged(m_GamePlugin); +} + +Settings& OrganizerCore::settings() +{ + return m_Settings; +} + +bool OrganizerCore::nexusApi(bool retry) +{ + auto* accessManager = NexusInterface::instance().getAccessManager(); + + if ((accessManager->validateAttempted() || accessManager->validated()) && !retry) { + // previous attempt, maybe even successful + return false; + } else { + QString apiKey; + if (GlobalSettings::nexusApiKey(apiKey)) { + // credentials stored or user entered them manually + log::debug("attempt to verify nexus api key"); + accessManager->apiCheck(apiKey); + return true; + } else { + // no credentials stored and user didn't enter them + accessManager->refuseValidation(); + return false; + } + } +} + +void OrganizerCore::startMOUpdate() +{ + if (nexusApi()) { + m_PostLoginTasks.append([&]() { + m_Updater.startUpdate(); + }); + } else { + m_Updater.startUpdate(); + } +} + +void OrganizerCore::downloadRequestedNXM(const QString& url) +{ + log::debug("download requested: {}", url); + if (nexusApi()) { + m_PendingDownloads.append(url); + } else { + m_DownloadManager.addNXMDownload(url); + } +} + +void OrganizerCore::userInterfaceInitialized() +{ + m_UserInterfaceInitialized(m_UserInterface->mainWindow()); +} + +void OrganizerCore::profileCreated(MOBase::IProfile* profile) +{ + m_ProfileCreated(profile); +} + +void OrganizerCore::profileRenamed(MOBase::IProfile* profile, QString const& oldName, + QString const& newName) +{ + m_ProfileRenamed(profile, oldName, newName); +} + +void OrganizerCore::profileRemoved(QString const& profileName) +{ + m_ProfileRemoved(profileName); +} + +void OrganizerCore::downloadRequested(QNetworkReply* reply, QString gameName, int modID, + const QString& fileName) +{ + try { + if (m_DownloadManager.addDownload(reply, QStringList(), fileName, gameName, modID, + 0, new ModRepositoryFileInfo(gameName, modID))) { + MessageDialog::showMessage(tr("Download started"), qApp->activeWindow()); + } + } catch (const std::exception& e) { + MessageDialog::showMessage(tr("Download failed"), qApp->activeWindow()); + log::error("exception starting download: {}", e.what()); + } +} + +void OrganizerCore::removeOrigin(const QString& name) +{ + FilesOrigin& origin = m_DirectoryStructure->getOriginByName(ToWString(name)); + origin.enable(false); + refreshLists(); +} + +void OrganizerCore::downloadSpeed(const QString& serverName, int bytesPerSecond) +{ + m_Settings.network().setDownloadSpeed(serverName, bytesPerSecond); +} + +InstallationManager* OrganizerCore::installationManager() +{ + return &m_InstallationManager; +} + +bool OrganizerCore::createDirectory(const QString& path) +{ + if (!QDir(path).exists() && !QDir().mkpath(path)) { + QMessageBox::critical(nullptr, QObject::tr("Error"), + QObject::tr("Failed to create \"%1\". Your user " + "account probably lacks permission.") + .arg(QDir::toNativeSeparators(path))); + return false; + } else { + return true; + } +} + +bool OrganizerCore::checkPathSymlinks() +{ + const bool hasSymlink = (QFileInfo(m_Settings.paths().profiles()).isSymLink() || + QFileInfo(m_Settings.paths().mods()).isSymLink() || + QFileInfo(m_Settings.paths().overwrite()).isSymLink()); + + if (hasSymlink) { + log::warn( + "{}", + QObject::tr( + "One of the configured MO2 directories (profiles, mods, or overwrite) " + "is on a path containing a symbolic (or other) link. This is likely to " + "be incompatible with MO2's virtual filesystem.")); + + return false; + } + + return true; +} + +bool OrganizerCore::bootstrap() +{ + const auto dirs = {m_Settings.paths().profiles(), m_Settings.paths().mods(), + m_Settings.paths().downloads(), m_Settings.paths().overwrite(), + QString::fromStdWString(getGlobalCoreDumpPath())}; + + for (auto&& dir : dirs) { + if (!createDirectory(dir)) { + return false; + } + } + + if (!checkPathSymlinks()) { + return false; + } + + if (!cycleDiagnostics()) { + return false; + } + + // log if there are any dmp files + const auto hasCrashDumps = !QDir(QString::fromStdWString(getGlobalCoreDumpPath())) + .entryList({"*.dmp"}, QDir::Files) + .empty(); + + if (hasCrashDumps) { + log::debug("there are crash dumps in '{}'", + QString::fromStdWString(getGlobalCoreDumpPath())); + } + + return true; +} + +void OrganizerCore::createDefaultProfile() +{ + QString profilesPath = settings().paths().profiles(); + if (QDir(profilesPath).entryList(QDir::AllDirs | QDir::NoDotAndDotDot).size() == 0) { + Profile newProf(QString::fromStdWString(AppConfig::defaultProfileName()), + managedGame(), gameFeatures(), false); + + m_ProfileCreated(&newProf); + } +} + +void OrganizerCore::createOverwriteDirectories() +{ + QString overwritePath = settings().paths().overwrite(); + for (auto modDirectory : managedGame()->getModMappings().keys()) { + if (!modDirectory.isEmpty()) { + QDir(overwritePath).mkdir(modDirectory); + } + } +} + +void OrganizerCore::prepareVFS() +{ + m_USVFS.updateMapping(fileMapping(m_CurrentProfile->name(), QString())); +} + +void OrganizerCore::updateVFSParams(log::Levels logLevel, + env::CoreDumpTypes coreDumpType, + const QString& crashDumpsPath, + std::chrono::seconds spawnDelay, + QString executableBlacklist, + const QStringList& skipFileSuffixes, + const QStringList& skipDirectories) +{ + setGlobalCoreDumpType(coreDumpType); + + m_USVFS.updateParams(logLevel, coreDumpType, crashDumpsPath, spawnDelay, + executableBlacklist, skipFileSuffixes, skipDirectories); +} + +void OrganizerCore::setLogLevel(log::Levels level) +{ + m_Settings.diagnostics().setLogLevel(level); + + updateVFSParams( + m_Settings.diagnostics().logLevel(), m_Settings.diagnostics().coreDumpType(), + QString::fromStdWString(getGlobalCoreDumpPath()), + m_Settings.diagnostics().spawnDelay(), m_Settings.executablesBlacklist(), + m_Settings.skipFileSuffixes(), m_Settings.skipDirectories()); + + log::getDefault().setLevel(m_Settings.diagnostics().logLevel()); +} + +bool OrganizerCore::cycleDiagnostics() +{ + const auto maxDumps = settings().diagnostics().maxCoreDumps(); + const auto path = QString::fromStdWString(getGlobalCoreDumpPath()); + + if (maxDumps > 0) { + removeOldFiles(path, "*.dmp", maxDumps, QDir::Time | QDir::Reversed); + } + + return true; +} + +env::CoreDumpTypes OrganizerCore::getGlobalCoreDumpType() +{ + return g_coreDumpType; +} + +void OrganizerCore::setGlobalCoreDumpType(env::CoreDumpTypes type) +{ + g_coreDumpType = type; +} + +std::wstring OrganizerCore::getGlobalCoreDumpPath() +{ + if (qApp) { + const auto dp = qApp->property("dataPath"); + if (!dp.isNull()) { + return dp.toString().toStdWString() + L"/" + AppConfig::dumpsDir(); + } + } + + return {}; +} + +void OrganizerCore::setCurrentProfile(const QString& profileName) +{ + if ((m_CurrentProfile != nullptr) && (profileName == m_CurrentProfile->name())) { + return; + } + + log::debug("selecting profile '{}'", profileName); + + QDir profileBaseDir(settings().paths().profiles()); + + const auto subdirs = profileBaseDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot); + + QString profileDir; + + // the profile name may not have the correct case, which breaks other parts + // of the ui like the profile combobox, which walks directories on its own + // + // find the real name with the correct case by walking the directories + for (auto&& dirName : subdirs) { + if (QString::compare(dirName, profileName, Qt::CaseInsensitive) == 0) { + profileDir = profileBaseDir.absoluteFilePath(dirName); + break; + } + } + + if (profileDir.isEmpty()) { + log::error("profile '{}' does not exist", profileName); + + // selected profile doesn't exist. Ensure there is at least one profile, + // then pick any one + createDefaultProfile(); + + profileDir = profileBaseDir.absoluteFilePath( + profileBaseDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot).at(0)); + + log::error("picked profile '{}' instead", QDir(profileDir).dirName()); + + reportError(tr("The selected profile '%1' does not exist. The profile '%2' will be " + "used instead") + .arg(profileName) + .arg(QDir(profileDir).dirName())); + } + + // Keep the old profile to emit signal-changed: + auto oldProfile = std::move(m_CurrentProfile); + + m_CurrentProfile = + std::make_unique(QDir(profileDir), managedGame(), gameFeatures()); + + m_ModList.setProfile(m_CurrentProfile.get()); + + if (m_CurrentProfile->invalidationActive(nullptr)) { + m_CurrentProfile->activateInvalidation(); + } else { + m_CurrentProfile->deactivateInvalidation(); + } + + m_Settings.game().setSelectedProfileName(m_CurrentProfile->name()); + + connect(m_CurrentProfile.get(), qOverload(&Profile::modStatusChanged), + [this](auto&& index) { + modStatusChanged(index); + }); + connect(m_CurrentProfile.get(), qOverload>(&Profile::modStatusChanged), + [this](auto&& indexes) { + modStatusChanged(indexes); + }); + refreshDirectoryStructure(); + + m_CurrentProfile->debugDump(); + + emit profileChanged(oldProfile.get(), m_CurrentProfile.get()); + m_ProfileChanged(oldProfile.get(), m_CurrentProfile.get()); +} + +QStringList OrganizerCore::profileNames() const +{ + QDir profilesDir(m_Settings.paths().profiles()); + return profilesDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot); +} + +std::shared_ptr +OrganizerCore::getProfile(const QString& profileName) const +{ + QDir profileDir(m_Settings.paths().profiles()); + profileDir.cd(profileName); + if (!profileDir.exists()) { + return nullptr; + } + + return std::make_shared(profileDir, managedGame(), gameFeatures()); +} + +MOBase::IModRepositoryBridge* OrganizerCore::createNexusBridge() const +{ + return new NexusBridge(m_PluginContainer); +} + +QString OrganizerCore::profileName() const +{ + if (m_CurrentProfile != nullptr) { + return m_CurrentProfile->name(); + } else { + return ""; + } +} + +QString OrganizerCore::profilePath() const +{ + if (m_CurrentProfile != nullptr) { + return m_CurrentProfile->absolutePath(); + } else { + return ""; + } +} + +QString OrganizerCore::downloadsPath() const +{ + return QDir::fromNativeSeparators(m_Settings.paths().downloads()); +} + +QString OrganizerCore::overwritePath() const +{ + return QDir::fromNativeSeparators(m_Settings.paths().overwrite()); +} + +QString OrganizerCore::basePath() const +{ + return QDir::fromNativeSeparators(m_Settings.paths().base()); +} + +QString OrganizerCore::modsPath() const +{ + return QDir::fromNativeSeparators(m_Settings.paths().mods()); +} + +MOBase::Version OrganizerCore::version() const +{ + return m_Updater.getVersion(); +} + +MOBase::IPluginGame* OrganizerCore::getGame(const QString& name) const +{ + for (IPluginGame* game : m_PluginContainer->plugins()) { + if (game != nullptr && + game->gameShortName().compare(name, Qt::CaseInsensitive) == 0) + return game; + } + return nullptr; +} + +MOBase::IModInterface* OrganizerCore::createMod(GuessedValue& name) +{ + auto result = m_InstallationManager.testOverwrite(name); + if (!result) { + return nullptr; + } + + m_InstallationManager.setModsDirectory(m_Settings.paths().mods()); + + QString targetDirectory = + QDir::fromNativeSeparators(m_Settings.paths().mods()).append("/").append(name); + + QSettings settingsFile(targetDirectory + "/meta.ini", QSettings::IniFormat); + + if (!result.merged()) { + settingsFile.setValue("modid", 0); + settingsFile.setValue("version", ""); + settingsFile.setValue("newestVersion", ""); + settingsFile.setValue("category", 0); + settingsFile.setValue("installationFile", ""); + + settingsFile.remove("installedFiles"); + settingsFile.beginWriteArray("installedFiles", 0); + settingsFile.endArray(); + } + + // shouldn't this use the existing mod in case of a merge? also, this does not refresh + // the indices in the ModInfo structure + return ModInfo::createFrom(QDir(targetDirectory), *this).data(); +} + +void OrganizerCore::modDataChanged(MOBase::IModInterface*) +{ + refresh(false); +} + +QVariant OrganizerCore::pluginSetting(const QString& pluginName, + const QString& key) const +{ + return m_Settings.plugins().setting(pluginName, key); +} + +void OrganizerCore::setPluginSetting(const QString& pluginName, const QString& key, + const QVariant& value) +{ + m_Settings.plugins().setSetting(pluginName, key, value); +} + +QVariant OrganizerCore::persistent(const QString& pluginName, const QString& key, + const QVariant& def) const +{ + return m_Settings.plugins().persistent(pluginName, key, def); +} + +void OrganizerCore::setPersistent(const QString& pluginName, const QString& key, + const QVariant& value, bool sync) +{ + m_Settings.plugins().setPersistent(pluginName, key, value, sync); +} + +QString OrganizerCore::pluginDataPath() +{ + return AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath()) + + "/data"; +} + +MOBase::IModInterface* OrganizerCore::installMod(const QString& archivePath, + int priority, bool reinstallation, + ModInfo::Ptr currentMod, + const QString& initModName) +{ + return installArchive(archivePath, reinstallation ? -1 : priority, reinstallation, + currentMod, initModName) + .get(); +} + +std::pair +OrganizerCore::doInstall(const QString& archivePath, GuessedValue modName, + ModInfo::Ptr currentMod, int priority, bool reinstallation) +{ + if (m_CurrentProfile == nullptr) { + return {-1, nullptr}; + } + + if (m_InstallationManager.isRunning()) { + QMessageBox::information(qApp->activeWindow(), tr("Installation cancelled"), + tr("Another installation is currently in progress."), + QMessageBox::Ok); + return {-1, nullptr}; + } + + bool hasIniTweaks = false; + m_CurrentProfile->writeModlistNow(); + m_InstallationManager.setModsDirectory(m_Settings.paths().mods()); + m_InstallationManager.notifyInstallationStart(archivePath, reinstallation, + currentMod); + auto result = m_InstallationManager.install(archivePath, modName, hasIniTweaks); + + if (result) { + MessageDialog::showMessage(tr("Installation successful"), qApp->activeWindow()); + + // we wait for the directory structure to be ready before notifying the mod list, + // this prevents issue with third-party plugins, e.g., if the installed mod is + // activated before the structure is ready + // + // we need to fetch modIndex() within the call back because the index is only + // valid after the call to refresh(), but we do not want to connect after + // refresh() + // + connect( + this, &OrganizerCore::directoryStructureReady, this, + [=] { + const int modIndex = ModInfo::getIndex(modName); + if (modIndex != UINT_MAX) { + const auto modInfo = ModInfo::getByIndex(modIndex); + m_ModList.notifyModInstalled(modInfo.get()); + } + }, + Qt::SingleShotConnection); + + refresh(); + + const auto modIndex = ModInfo::getIndex(modName); + ModInfo::Ptr modInfo = nullptr; + if (modIndex != UINT_MAX) { + modInfo = ModInfo::getByIndex(modIndex); + + if (priority != -1 && !result.mergedOrReplaced()) { + m_ModList.changeModPriority(modIndex, priority); + } + + if (hasIniTweaks && m_UserInterface != nullptr && + (QMessageBox::question(qApp->activeWindow(), tr("Configure Mod"), + tr("This mod contains ini tweaks. Do you " + "want to configure them now?"), + QMessageBox::Yes | QMessageBox::No) == + QMessageBox::Yes)) { + m_UserInterface->displayModInformation(modInfo, modIndex, + ModInfoTabIDs::IniFiles); + } + + m_InstallationManager.notifyInstallationEnd(result, modInfo); + } else { + reportError(tr("mod not found: %1").arg(qUtf8Printable(modName))); + } + emit modInstalled(modName); + return {modIndex, modInfo}; + } else { + if (result.result() == MOBase::IPluginInstaller::RESULT_CATEGORYREQUESTED) { + CategoriesDialog dialog(qApp->activeWindow()); + + if (dialog.exec() == QDialog::Accepted) { + dialog.commitChanges(); + refresh(); + } + } else { + m_InstallationManager.notifyInstallationEnd(result, nullptr); + if (m_InstallationManager.wasCancelled()) { + QMessageBox::information( + qApp->activeWindow(), tr("Extraction cancelled"), + tr("The installation was cancelled while extracting files. " + "If this was prior to a FOMOD setup, this warning may be ignored. " + "However, if this was during installation, the mod will likely be " + "missing " + "files."), + QMessageBox::Ok); + refresh(); + } + } + } + + return {-1, nullptr}; +} + +ModInfo::Ptr OrganizerCore::installDownload(int index, int priority) +{ + ScopedDisableDirWatcher scopedDirwatcher(&m_DownloadManager); + + try { + QString fileName = m_DownloadManager.getFilePath(index); + QString gameName = m_DownloadManager.getGameName(index); + int modID = m_DownloadManager.getModID(index); + int fileID = m_DownloadManager.getFileInfo(index)->fileID; + ModInfo::Ptr currentMod = nullptr; + GuessedValue modName; + + // see if there already are mods with the specified mod id + if (modID > 0) { + std::vector modInfo = ModInfo::getByModID(gameName, modID); + for (auto iter = modInfo.begin(); iter != modInfo.end(); ++iter) { + std::vector flags = (*iter)->getFlags(); + if (std::find(flags.begin(), flags.end(), ModInfo::FLAG_BACKUP) == + flags.end()) { + modName.update((*iter)->name(), GUESS_PRESET); + currentMod = *iter; + (*iter)->saveMeta(); + } + } + } + + const auto [modIndex, modInfo] = + doInstall(fileName, modName, currentMod, priority, false); + + if (modInfo != nullptr) { + modInfo->addInstalledFile(modID, fileID); + m_DownloadManager.markInstalled(index); + if (settings().interface().hideDownloadsAfterInstallation()) { + m_DownloadManager.removeDownload(index, false); + } + } + + return modInfo; + } catch (const std::exception& e) { + log::error("installDownload exception: type={}, what='{}'", + typeid(e).name(), e.what()); + reportError(tr("Installation failed: %1").arg(e.what())); + } + + return nullptr; +} + +ModInfo::Ptr OrganizerCore::installArchive(const QString& archivePath, int priority, + bool reinstallation, ModInfo::Ptr currentMod, + const QString& initModName) +{ + GuessedValue modName; + if (!initModName.isEmpty()) { + modName.update(initModName, GUESS_USER); + } + const auto [modIndex, modInfo] = + doInstall(archivePath, modName, currentMod, priority, reinstallation); + if (m_CurrentProfile == nullptr) { + return nullptr; + } + + if (modInfo != nullptr) { + auto dlIdx = m_DownloadManager.indexByName(QFileInfo(archivePath).fileName()); + if (dlIdx != -1) { + int modId = m_DownloadManager.getModID(dlIdx); + int fileId = m_DownloadManager.getFileInfo(dlIdx)->fileID; + modInfo->addInstalledFile(modId, fileId); + } + m_DownloadManager.markInstalled(archivePath); + } + return modInfo; +} + +QString OrganizerCore::resolvePath(const QString& fileName) const +{ + if (m_DirectoryStructure == nullptr) { + return QString(); + } + const FileEntryPtr file = + m_DirectoryStructure->searchFile(ToWString(fileName), nullptr); + if (file.get() != nullptr) { + return ToQString(file->getFullPath()); + } else { + return QString(); + } +} + +QStringList OrganizerCore::listDirectories(const QString& directoryName) const +{ + QStringList result; + DirectoryEntry* dir = m_DirectoryStructure; + if (!directoryName.isEmpty()) + dir = dir->findSubDirectoryRecursive(ToWString(directoryName)); + if (dir != nullptr) { + for (const auto& d : dir->getSubDirectories()) { + result.append(ToQString(d->getName())); + } + } + return result; +} + +QStringList +OrganizerCore::findFiles(const QString& path, + const std::function& filter) const +{ + QStringList result; + DirectoryEntry* dir = m_DirectoryStructure; + if (!path.isEmpty() && path != ".") + dir = dir->findSubDirectoryRecursive(ToWString(path)); + if (dir != nullptr) { + std::vector files = dir->getFiles(); + for (FileEntryPtr& file : files) { + QString fullPath = ToQString(file->getFullPath()); + if (filter(ToQString(file->getName()))) { + result.append(fullPath); + } + } + } + return result; +} + +QStringList OrganizerCore::getFileOrigins(const QString& fileName) const +{ + QStringList result; + const FileEntryPtr file = + m_DirectoryStructure->searchFile(ToWString(fileName), nullptr); + + if (file.get() != nullptr) { + result.append( + ToQString(m_DirectoryStructure->getOriginByID(file->getOrigin()).getName())); + foreach (const auto& i, file->getAlternatives()) { + result.append( + ToQString(m_DirectoryStructure->getOriginByID(i.originID()).getName())); + } + } + return result; +} + +QList OrganizerCore::findFileInfos( + const QString& path, + const std::function& filter) const +{ + QList result; + DirectoryEntry* dir = m_DirectoryStructure; + if (!path.isEmpty() && path != ".") + dir = dir->findSubDirectoryRecursive(ToWString(path)); + if (dir != nullptr) { + std::vector files = dir->getFiles(); + for (FileEntryPtr file : files) { + IOrganizer::FileInfo info; + info.filePath = ToQString(file->getFullPath()); + bool fromArchive = false; + info.origins.append(ToQString( + m_DirectoryStructure->getOriginByID(file->getOrigin(fromArchive)).getName())); + info.archive = fromArchive ? ToQString(file->getArchive().name()) : ""; + for (const auto& idx : file->getAlternatives()) { + info.origins.append( + ToQString(m_DirectoryStructure->getOriginByID(idx.originID()).getName())); + } + + if (filter(info)) { + result.append(info); + } + } + } + return result; +} + +DownloadManager* OrganizerCore::downloadManager() +{ + return &m_DownloadManager; +} + +PluginList* OrganizerCore::pluginList() +{ + return &m_PluginList; +} + +ModList* OrganizerCore::modList() +{ + return &m_ModList; +} + +bool OrganizerCore::previewFileWithAlternatives(QWidget* parent, QString fileName, + int selectedOrigin) +{ + fileName = QDir::fromNativeSeparators(fileName); + + // what we have is an absolute path to the file in its actual location (for the + // primary origin) what we want is the path relative to the virtual data directory + + // we need to look in the virtual directory for the file to make sure the info is up + // to date. + + // check if the file comes from the actual data folder instead of a mod + QDir gameDirectory = managedGame()->dataDirectory().absolutePath(); + QString relativePath = gameDirectory.relativeFilePath(fileName); + QDir dirRelativePath = gameDirectory.relativeFilePath(fileName); + + // if the file is on a different drive the dirRelativePath will actually be an + // absolute path so we make sure that is not the case + if (!dirRelativePath.isAbsolute() && !relativePath.startsWith("..")) { + fileName = relativePath; + } else { + // crude: we search for the next slash after the base mod directory to skip + // everything up to the data-relative directory + int offset = settings().paths().mods().size() + 1; + offset = fileName.indexOf("/", offset); + fileName = fileName.mid(offset + 1); + } + + const FileEntryPtr file = + directoryStructure()->searchFile(ToWString(fileName), nullptr); + + if (file.get() == nullptr) { + reportError(tr("file not found: %1").arg(qUtf8Printable(fileName))); + return false; + } + + // set up preview dialog + PreviewDialog preview(fileName, parent); + + auto addFunc = [&](int originId, std::wstring archiveName = L"") { + FilesOrigin& origin = directoryStructure()->getOriginByID(originId); + QString filePath = + QDir::fromNativeSeparators(ToQString(origin.getPath())) + "/" + fileName; + if (QFile::exists(filePath)) { + // it's very possible the file doesn't exist, because it's inside an archive. we + // don't support that + QWidget* wid = m_PluginContainer->previewGenerator().genPreview(filePath); + if (wid == nullptr) { + reportError(tr("failed to generate preview for %1").arg(filePath)); + } else { + preview.addVariant(ToQString(origin.getName()), wid); + } + } else if (archiveName != L"") { + auto archiveFile = directoryStructure()->searchFile(archiveName); + if (archiveFile.get() != nullptr) { + try { + libbsarch::bs_archive archiveLoader; + archiveLoader.load_from_disk(archiveFile->getFullPath()); + libbsarch::memory_blob fileData = + archiveLoader.extract_to_memory(fileName.toStdWString()); + QByteArray convertedFileData((char*)(fileData.data), fileData.size); + QWidget* wid = m_PluginContainer->previewGenerator().genArchivePreview( + convertedFileData, filePath); + if (wid == nullptr) { + reportError(tr("failed to generate preview for %1").arg(filePath)); + } else { + preview.addVariant(ToQString(origin.getName()), wid); + } + } catch (std::exception& e) { + } + } + } + }; + + if (selectedOrigin == -1) { + // don't bother with the vector of origins, just add them as they come + addFunc(file->getOrigin(), file->isFromArchive() ? file->getArchive().name() : L""); + for (const auto& alt : file->getAlternatives()) { + addFunc(alt.originID(), alt.isFromArchive() ? alt.archive().name() : L""); + } + } else { + std::vector origins; + + // start with the primary origin + origins.push_back(file->getOrigin()); + + // add other origins, push to front if it's the selected one + for (const auto& alt : file->getAlternatives()) { + if (alt.originID() == selectedOrigin) { + origins.insert(origins.begin(), alt.originID()); + } else { + origins.push_back(alt.originID()); + } + } + + // can't be empty; either the primary origin was the selected one, or it + // was one of the alternatives, which got inserted in front + + if (origins[0] != selectedOrigin) { + // sanity check, this shouldn't happen unless the caller passed an + // incorrect id + + log::warn("selected preview origin {} not found in list of alternatives", + selectedOrigin); + } + + for (int id : origins) { + addFunc(id); + } + } + + if (preview.numVariants() > 0) { + preview.exec(); + return true; + } else { + QMessageBox::information(parent, tr("Sorry"), + tr("Sorry, can't preview anything. This function " + "currently does not support extracting from bsas.")); + + return false; + } +} + +bool OrganizerCore::previewFile(QWidget* parent, const QString& originName, + const QString& path) +{ + if (!QFile::exists(path)) { + reportError(tr("File '%1' not found.").arg(path)); + return false; + } + + PreviewDialog preview(path, parent); + + QWidget* wid = m_PluginContainer->previewGenerator().genPreview(path); + if (wid == nullptr) { + reportError(tr("Failed to generate preview for %1").arg(path)); + return false; + } + + preview.addVariant(originName, wid); + preview.exec(); + + return true; +} + +boost::signals2::connection OrganizerCore::onAboutToRun( + const std::function& func) +{ + return m_AboutToRun.connect(func); +} + +boost::signals2::connection OrganizerCore::onFinishedRun( + const std::function& func) +{ + return m_FinishedRun.connect(func); +} + +boost::signals2::connection +OrganizerCore::onUserInterfaceInitialized(std::function const& func) +{ + return m_UserInterfaceInitialized.connect(func); +} + +boost::signals2::connection +OrganizerCore::onProfileCreated(std::function const& func) +{ + return m_ProfileCreated.connect(func); +} + +boost::signals2::connection OrganizerCore::onProfileRenamed( + std::function const& func) +{ + return m_ProfileRenamed.connect(func); +} + +boost::signals2::connection +OrganizerCore::onProfileRemoved(std::function const& func) +{ + return m_ProfileRemoved.connect(func); +} + +boost::signals2::connection +OrganizerCore::onProfileChanged(std::function const& func) +{ + return m_ProfileChanged.connect(func); +} + +boost::signals2::connection OrganizerCore::onPluginSettingChanged( + std::function const& func) +{ + return m_PluginSettingChanged.connect(func); +} + +boost::signals2::connection +OrganizerCore::onPluginEnabled(std::function const& func) +{ + return m_PluginEnabled.connect(func); +} + +boost::signals2::connection +OrganizerCore::onPluginDisabled(std::function const& func) +{ + return m_PluginDisabled.connect(func); +} + +boost::signals2::connection +OrganizerCore::onNextRefresh(std::function const& func, + RefreshCallbackGroup group, RefreshCallbackMode mode) +{ + if (m_DirectoryUpdate || mode == RefreshCallbackMode::FORCE_WAIT_FOR_REFRESH) { + return m_OnNextRefreshCallbacks.connect(static_cast(group), func); + } else { + func(); + return {}; + } +} + +void OrganizerCore::refresh(bool saveChanges) +{ + // don't lose changes! + if (saveChanges) { + m_CurrentProfile->writeModlistNow(true); + } + + updateModInfoFromDisc(); + m_CurrentProfile->refreshModStatus(); + + m_ModList.notifyChange(-1); + + refreshDirectoryStructure(); + + emit refreshTriggered(); +} + +void OrganizerCore::refreshESPList(bool force) +{ + onNextRefresh( + [this, force] { + TimeThis tt("OrganizerCore::refreshESPList()"); + + m_CurrentProfile->writeModlist(); + + // clear list + try { + m_PluginList.refresh(m_CurrentProfile->name(), *m_DirectoryStructure, + m_CurrentProfile->getLockedOrderFileName(), force); + } catch (const std::exception& e) { + reportError(tr("Failed to refresh list of esps: %1").arg(e.what())); + } + }, + RefreshCallbackGroup::CORE, RefreshCallbackMode::RUN_NOW_IF_POSSIBLE); +} + +void OrganizerCore::refreshBSAList() +{ + TimeThis tt("OrganizerCore::refreshBSAList()"); + + auto archives = gameFeatures().gameFeature(); + + if (archives != nullptr) { + m_ArchivesInit = false; + + // default archives are the ones enabled outside MO. if the list can't be + // found (which might + // happen if ini files are missing) use hard-coded defaults (preferrably the + // same the game would use) + m_DefaultArchives = archives->archives(m_CurrentProfile.get()); + if (m_DefaultArchives.length() == 0) { + m_DefaultArchives = archives->vanillaArchives(); + } + + m_ActiveArchives.clear(); + + auto iter = enabledArchives(); + m_ActiveArchives = toStringList(iter.begin(), iter.end()); + if (m_ActiveArchives.isEmpty()) { + m_ActiveArchives = m_DefaultArchives; + } + + if (m_UserInterface != nullptr) { + m_UserInterface->updateBSAList(m_DefaultArchives, m_ActiveArchives); + m_UserInterface->archivesWriter().write(); + } + + m_ArchivesInit = true; + } +} + +void OrganizerCore::refreshLists() +{ + if ((m_CurrentProfile != nullptr) && m_DirectoryStructure->isPopulated()) { + refreshESPList(true); + refreshBSAList(); + } // no point in refreshing lists if no files have been added to the directory + // tree +} + +void OrganizerCore::updateModActiveState(int index, bool active) +{ + QList modsToUpdate; + modsToUpdate.append(index); + updateModsActiveState(modsToUpdate, active); +} + +void OrganizerCore::updateModsActiveState(const QList& modIndices, + bool active) +{ + int enabled = 0; + for (auto index : modIndices) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + QDir dir(modInfo->absolutePath()); + for (const QString& esm : dir.entryList(QStringList() << "*.esm", QDir::Files)) { + const FileEntryPtr file = m_DirectoryStructure->findFile(ToWString(esm)); + if (file.get() == nullptr) { + log::warn("failed to activate {}", esm); + continue; + } + + if (active != m_PluginList.isEnabled(esm) && file->getAlternatives().empty()) { + m_PluginList.blockSignals(true); + m_PluginList.enableESP(esm, active); + m_PluginList.blockSignals(false); + } + } + + for (const QString& esl : dir.entryList(QStringList() << "*.esl", QDir::Files)) { + const FileEntryPtr file = m_DirectoryStructure->findFile(ToWString(esl)); + if (file.get() == nullptr) { + log::warn("failed to activate {}", esl); + continue; + } + + if (active != m_PluginList.isEnabled(esl) && file->getAlternatives().empty()) { + m_PluginList.blockSignals(true); + m_PluginList.enableESP(esl, active); + m_PluginList.blockSignals(false); + ++enabled; + } + } + QStringList esps = dir.entryList(QStringList() << "*.esp", QDir::Files); + for (const QString& esp : esps) { + const FileEntryPtr file = m_DirectoryStructure->findFile(ToWString(esp)); + if (file.get() == nullptr) { + log::warn("failed to activate {}", esp); + continue; + } + + if (active != m_PluginList.isEnabled(esp) && file->getAlternatives().empty()) { + m_PluginList.blockSignals(true); + m_PluginList.enableESP(esp, active); + m_PluginList.blockSignals(false); + ++enabled; + } + } + } + if (active && (enabled > 1)) { + MessageDialog::showMessage( + tr("Multiple esps/esls activated, please check that they don't conflict."), + qApp->activeWindow()); + } + m_PluginList.refreshLoadOrder(); + // immediately save affected lists + m_PluginListsWriter.writeImmediately(false); +} + +void OrganizerCore::updateModInDirectoryStructure(unsigned int index, + ModInfo::Ptr modInfo) +{ + QMap allModInfo; + allModInfo[index] = modInfo; + updateModsInDirectoryStructure(allModInfo); +} + +void OrganizerCore::updateModsInDirectoryStructure( + QMap modInfo) +{ + std::vector entries; + + for (auto idx : modInfo.keys()) { + QString path = modInfo[idx]->absolutePath(); + QString modDataDir = managedGame()->modDataDirectory(); + path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; + entries.push_back({modInfo[idx]->name(), + path, + modInfo[idx]->stealFiles(), + {}, + m_CurrentProfile->getModPriority(idx)}); + } + + m_DirectoryRefresher->addMultipleModsFilesToStructure(m_DirectoryStructure, entries); + + DirectoryRefresher::cleanStructure(m_DirectoryStructure); + // need to refresh plugin list now so we can activate esps + refreshESPList(true); + // activate all esps of the specified mod so the bsas get activated along with + // it + m_PluginList.blockSignals(true); + updateModsActiveState(modInfo.keys(), true); + m_PluginList.blockSignals(false); + // now we need to refresh the bsa list and save it so there is no confusion + // about what archives are available and active + refreshBSAList(); + if (m_UserInterface != nullptr) { + m_UserInterface->archivesWriter().writeImmediately(false); + } + + std::vector archives = enabledArchives(); + m_DirectoryRefresher->setMods(m_CurrentProfile->getActiveMods(), + std::set(archives.begin(), archives.end())); + + // finally also add files from bsas to the directory structure + for (auto idx : modInfo.keys()) { + QString path = modInfo[idx]->absolutePath(); + QString modDataDir = managedGame()->modDataDirectory(); + path = modDataDir.isEmpty() ? path : path + "/" + modDataDir; + m_DirectoryRefresher->addModBSAToStructure( + m_DirectoryStructure, modInfo[idx]->name(), + m_CurrentProfile->getModPriority(idx), path, modInfo[idx]->archives()); + } +} + +void OrganizerCore::loggedInAction(QWidget* parent, std::function f) +{ + if (NexusInterface::instance().getAccessManager()->validated()) { + f(); + } else if (!m_Settings.network().offlineMode()) { + QString apiKey; + if (GlobalSettings::nexusApiKey(apiKey)) { + doAfterLogin([f] { + f(); + }); + NexusInterface::instance().getAccessManager()->apiCheck(apiKey); + } else { + MessageDialog::showMessage(tr("You need to be logged in with Nexus"), parent); + } + } +} + +void OrganizerCore::requestDownload(const QUrl& url, QNetworkReply* reply) +{ + if (!m_PluginContainer) { + return; + } + for (IPluginModPage* modPage : m_PluginContainer->plugins()) { + if (m_PluginContainer->isEnabled(modPage)) { + ModRepositoryFileInfo* fileInfo = new ModRepositoryFileInfo(); + if (modPage->handlesDownload(url, reply->url(), *fileInfo)) { + fileInfo->repository = modPage->name(); + m_DownloadManager.addDownload(reply, fileInfo); + return; + } + } + } + + // no mod found that could handle the download. Is it a nexus mod? + if (url.host() == "www.nexusmods.com") { + QString gameName = ""; + int modID = 0; + int fileID = 0; + QRegularExpression nameExp("www\\.nexusmods\\.com/(\\a+)/"); + auto match = nameExp.match(url.toString()); + if (match.hasMatch()) { + gameName = match.captured(1); + } + QRegularExpression modExp("mods/(\\d+)"); + match = modExp.match(url.toString()); + if (match.hasMatch()) { + modID = match.captured(1).toInt(); + } + QRegularExpression fileExp("fid=(\\d+)"); + match = fileExp.match(url.toString()); + if (match.hasMatch()) { + fileID = match.captured(1).toInt(); + } + m_DownloadManager.addDownload(reply, + new ModRepositoryFileInfo(gameName, modID, fileID)); + } else { + if (QMessageBox::question(qApp->activeWindow(), tr("Download?"), + tr("A download has been started but no installed " + "page plugin recognizes it.\n" + "If you download anyway no information (i.e. " + "version) will be associated with the " + "download.\n" + "Continue?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_DownloadManager.addDownload(reply, new ModRepositoryFileInfo()); + } + } +} + +PluginContainer& OrganizerCore::pluginContainer() const +{ + return *m_PluginContainer; +} + +GameFeatures& OrganizerCore::gameFeatures() const +{ + return pluginContainer().gameFeatures(); +} + +IPluginGame const* OrganizerCore::managedGame() const +{ + return m_GamePlugin; +} + +IOrganizer const* OrganizerCore::managedGameOrganizer() const +{ + return m_PluginContainer->requirements(m_GamePlugin).m_Organizer; +} + +std::vector OrganizerCore::enabledArchives() +{ + std::vector result; + if (settings().archiveParsing()) { + QFile archiveFile(m_CurrentProfile->getArchivesFileName()); + if (archiveFile.open(QIODevice::ReadOnly)) { + while (!archiveFile.atEnd()) { + result.push_back(QString::fromUtf8(archiveFile.readLine()).trimmed()); + } + archiveFile.close(); + } + } + return result; +} + +void OrganizerCore::refreshDirectoryStructure() +{ + if (m_DirectoryUpdate) { + log::debug("can't refresh, already in progress"); + return; + } + + log::debug("refreshing structure"); + m_DirectoryUpdate = true; + + m_CurrentProfile->writeModlistNow(true); + const auto activeModList = m_CurrentProfile->getActiveMods(); + const auto archives = enabledArchives(); + + m_DirectoryRefresher->setMods(activeModList, + std::set(archives.begin(), archives.end())); + + // runs refresh() in a thread + QTimer::singleShot(0, m_DirectoryRefresher.get(), &DirectoryRefresher::refresh); +} + +void OrganizerCore::onDirectoryRefreshed() +{ + log::debug("directory refreshed, finishing up"); + TimeThis tt("OrganizerCore::onDirectoryRefreshed()"); + + DirectoryEntry* newStructure = m_DirectoryRefresher->stealDirectoryStructure(); + Q_ASSERT(newStructure != m_DirectoryStructure); + + if (newStructure == nullptr) { + // TODO: don't know why this happens, this slot seems to get called twice + // with only one emit + return; + } + + std::swap(m_DirectoryStructure, newStructure); + m_VirtualFileTree.invalidate(); + + if (m_StructureDeleter.joinable()) { + m_StructureDeleter.join(); + } + + m_StructureDeleter = MOShared::startSafeThread([=] { + log::debug("structure deleter thread start"); + delete newStructure; + log::debug("structure deleter thread done"); + }); + + log::debug("clearing caches"); + for (int i = 0; i < m_ModList.rowCount(); ++i) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(i); + modInfo->clearCaches(); + } + + // needs to be done before post refresh tasks + m_DirectoryUpdate = false; + + log::debug("running post refresh tasks"); + m_OnNextRefreshCallbacks(); + m_OnNextRefreshCallbacks.disconnect_all_slots(); + + if (m_CurrentProfile != nullptr) { + log::debug("refreshing lists"); + refreshLists(); + } + + emit directoryStructureReady(); + + log::debug("refresh done"); +} + +void OrganizerCore::clearCaches(std::vector const& indices) const +{ + const auto insert = [](auto& dest, const auto& from) { + dest.insert(from.begin(), from.end()); + }; + std::set allIndices; + for (const auto index : indices) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + + if (m_CurrentProfile->modEnabled(index)) { + // if the mod is enabled, we need to first clear its cache so that + // getModOverwrite(), ..., returns the newly conflicting mods (in case + // the mod just got enabled) + modInfo->clearCaches(); + insert(allIndices, modInfo->getModOverwrite()); + insert(allIndices, modInfo->getModOverwritten()); + insert(allIndices, modInfo->getModArchiveOverwrite()); + insert(allIndices, modInfo->getModArchiveOverwritten()); + insert(allIndices, modInfo->getModArchiveLooseOverwrite()); + insert(allIndices, modInfo->getModArchiveLooseOverwritten()); + } else { + // if the mod is disabled, we need to first fetch the conflicting + // mods, and then clear the cache + insert(allIndices, modInfo->getModOverwrite()); + insert(allIndices, modInfo->getModOverwritten()); + insert(allIndices, modInfo->getModArchiveOverwrite()); + insert(allIndices, modInfo->getModArchiveOverwritten()); + insert(allIndices, modInfo->getModArchiveLooseOverwrite()); + insert(allIndices, modInfo->getModArchiveLooseOverwritten()); + modInfo->clearCaches(); + } + } + + for (auto& index : allIndices) { + ModInfo::getByIndex(index)->clearCaches(); + } +} + +void OrganizerCore::modPrioritiesChanged(const QModelIndexList& indices) +{ + for (unsigned int i = 0; i < currentProfile()->numMods(); ++i) { + int priority = currentProfile()->getModPriority(i); + if (currentProfile()->modEnabled(i)) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(i); + // priorities in the directory structure are one higher because data is 0 + directoryStructure() + ->getOriginByName(MOBase::ToWString(modInfo->internalName())) + .setPriority(priority + 1); + } + } + refreshBSAList(); + currentProfile()->writeModlist(); + directoryStructure()->getFileRegister()->sortOrigins(); + + std::vector vindices; + + for (auto& idx : indices) { + vindices.push_back(idx.data(ModList::IndexRole).toInt()); + } + + clearCaches(vindices); +} + +void OrganizerCore::modStatusChanged(unsigned int index) +{ + try { + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + if (m_CurrentProfile->modEnabled(index)) { + updateModInDirectoryStructure(index, modInfo); + } else { + updateModActiveState(index, false); + if (m_DirectoryStructure->originExists(ToWString(modInfo->name()))) { + FilesOrigin& origin = + m_DirectoryStructure->getOriginByName(ToWString(modInfo->name())); + origin.enable(false); + } + if (m_UserInterface != nullptr) { + m_UserInterface->archivesWriter().write(); + } + } + + for (unsigned int i = 0; i < m_CurrentProfile->numMods(); ++i) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(i); + int priority = m_CurrentProfile->getModPriority(i); + if (m_DirectoryStructure->originExists(ToWString(modInfo->name()))) { + // priorities in the directory structure are one higher because data is + // 0 + m_DirectoryStructure->getOriginByName(ToWString(modInfo->name())) + .setPriority(priority + 1); + } + } + m_DirectoryStructure->getFileRegister()->sortOrigins(); + + refreshLists(); + clearCaches({index}); + m_ModList.notifyModStateChanged({index}); + + } catch (const std::exception& e) { + reportError(tr("failed to update mod list: %1").arg(e.what())); + } +} + +void OrganizerCore::modStatusChanged(QList index) +{ + try { + QMap modsToEnable; + QMap modsToDisable; + std::vector vindices; + for (auto idx : index) { + if (m_CurrentProfile->modEnabled(idx)) { + modsToEnable[idx] = ModInfo::getByIndex(idx); + } else { + modsToDisable[idx] = ModInfo::getByIndex(idx); + } + vindices.push_back(idx); + } + if (!modsToEnable.isEmpty()) { + updateModsInDirectoryStructure(modsToEnable); + } + if (!modsToDisable.isEmpty()) { + updateModsActiveState(modsToDisable.keys(), false); + for (auto idx : modsToDisable.keys()) { + if (m_DirectoryStructure->originExists(ToWString(modsToDisable[idx]->name()))) { + FilesOrigin& origin = m_DirectoryStructure->getOriginByName( + ToWString(modsToDisable[idx]->name())); + origin.enable(false); + } + } + if (m_UserInterface != nullptr) { + m_UserInterface->archivesWriter().write(); + } + } + + for (unsigned int i = 0; i < m_CurrentProfile->numMods(); ++i) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(i); + int priority = m_CurrentProfile->getModPriority(i); + if (m_DirectoryStructure->originExists(ToWString(modInfo->name()))) { + // priorities in the directory structure are one higher because data is + // 0 + m_DirectoryStructure->getOriginByName(ToWString(modInfo->name())) + .setPriority(priority + 1); + } + } + m_DirectoryStructure->getFileRegister()->sortOrigins(); + + refreshLists(); + clearCaches(vindices); + m_ModList.notifyModStateChanged(index); + + } catch (const std::exception& e) { + reportError(tr("failed to update mod list: %1").arg(e.what())); + } +} + +void OrganizerCore::loginSuccessful(bool necessary) +{ + if (necessary) { + MessageDialog::showMessage(tr("login successful"), qApp->activeWindow()); + } + for (QString url : m_PendingDownloads) { + downloadRequestedNXM(url); + } + m_PendingDownloads.clear(); + for (auto task : m_PostLoginTasks) { + task(); + } + + m_PostLoginTasks.clear(); + NexusInterface::instance().loginCompleted(); +} + +void OrganizerCore::loginSuccessfulUpdate(bool necessary) +{ + if (necessary) { + MessageDialog::showMessage(tr("login successful"), qApp->activeWindow()); + } + m_Updater.startUpdate(); +} + +void OrganizerCore::loginFailed(const QString& message) +{ + qCritical().nospace().noquote() << "Nexus API validation failed: " << message; + + if (QMessageBox::question(qApp->activeWindow(), tr("Login failed"), + tr("Login failed, try again?")) == QMessageBox::Yes) { + if (nexusApi(true)) { + return; + } + } + + if (!m_PendingDownloads.isEmpty()) { + MessageDialog::showMessage( + tr("login failed: %1. Download will not be associated with an account") + .arg(message), + qApp->activeWindow()); + for (QString url : m_PendingDownloads) { + downloadRequestedNXM(url); + } + m_PendingDownloads.clear(); + } else { + MessageDialog::showMessage(tr("login failed: %1").arg(message), + qApp->activeWindow()); + m_PostLoginTasks.clear(); + } + NexusInterface::instance().loginCompleted(); +} + +void OrganizerCore::loginFailedUpdate(const QString& message) +{ + MessageDialog::showMessage( + tr("login failed: %1. You need to log-in with Nexus to update MO.").arg(message), + qApp->activeWindow()); +} + +void OrganizerCore::syncOverwrite() +{ + ModInfo::Ptr modInfo = ModInfo::getOverwrite(); + SyncOverwriteDialog syncDialog(modInfo->absolutePath(), m_DirectoryStructure, + qApp->activeWindow()); + if (syncDialog.exec() == QDialog::Accepted) { + syncDialog.apply(QDir::fromNativeSeparators(m_Settings.paths().mods())); + modInfo->diskContentModified(); + refreshDirectoryStructure(); + } +} + +QString OrganizerCore::oldMO1HookDll() const +{ + if (auto extender = gameFeatures().gameFeature()) { + QString hookdll = + QDir::toNativeSeparators(managedGame()->dataDirectory().absoluteFilePath( + extender->PluginPath() + "/hook.dll")); + if (QFile(hookdll).exists()) + return hookdll; + } + return QString(); +} + +std::vector OrganizerCore::activeProblems() const +{ + std::vector problems; + const auto& hookdll = oldMO1HookDll(); + if (!hookdll.isEmpty()) { + // This warning will now be shown every time the problems are checked, which is a + // bit of a "log spam". But since this is a sever error which will most likely make + // the game crash/freeze/etc. and is very hard to diagnose, this "log spam" will + // make it easier for the user to notice the warning. + log::warn("hook.dll found in game folder: {}", hookdll); + problems.push_back(PROBLEM_MO1SCRIPTEXTENDERWORKAROUND); + } + return problems; +} + +QString OrganizerCore::shortDescription(unsigned int key) const +{ + switch (key) { + case PROBLEM_MO1SCRIPTEXTENDERWORKAROUND: { + return tr( + "MO1 \"Script Extender\" load mechanism has left hook.dll in your game folder"); + } break; + default: { + return tr("Description missing"); + } break; + } +} + +QString OrganizerCore::fullDescription(unsigned int key) const +{ + switch (key) { + case PROBLEM_MO1SCRIPTEXTENDERWORKAROUND: { + return tr("hook.dll has been found in your game folder (right " + "click to copy the full path). " + "This is most likely a leftover of setting the ModOrganizer 1 load " + "mechanism to \"Script Extender\", " + "in which case you must remove this file either by changing the load " + "mechanism in ModOrganizer 1 or " + "manually removing the file, otherwise the game is likely to crash and " + "burn.") + .arg(oldMO1HookDll()); + break; + } + default: { + return tr("Description missing"); + } break; + } +} + +bool OrganizerCore::hasGuidedFix(unsigned int) const +{ + return false; +} + +void OrganizerCore::startGuidedFix(unsigned int) const {} + +bool OrganizerCore::saveCurrentLists() +{ + if (m_DirectoryUpdate) { + log::warn("not saving lists during directory update"); + return false; + } + + try { + savePluginList(); + if (m_UserInterface != nullptr) { + m_UserInterface->archivesWriter().write(); + } + } catch (const std::exception& e) { + reportError(tr("failed to save load order: %1").arg(e.what())); + } + + return true; +} + +void OrganizerCore::savePluginList() +{ + onNextRefresh( + [this]() { + m_PluginList.saveTo(m_CurrentProfile->getLockedOrderFileName()); + m_PluginList.saveLoadOrder(*m_DirectoryStructure); + }, + RefreshCallbackGroup::CORE, RefreshCallbackMode::RUN_NOW_IF_POSSIBLE); +} + +void OrganizerCore::saveCurrentProfile() +{ + if (m_CurrentProfile == nullptr) { + return; + } + + m_CurrentProfile->writeModlist(); + m_CurrentProfile->createTweakedIniFile(); + saveCurrentLists(); + storeSettings(); +} + +ProcessRunner OrganizerCore::processRunner() +{ + return ProcessRunner(*this, m_UserInterface); +} + +bool OrganizerCore::beforeRun( + const QFileInfo& binary, const QDir& cwd, const QString& arguments, + const QString& profileName, const QString& customOverwrite, + const QList& forcedLibraries) +{ + saveCurrentProfile(); + + // need to wait until directory structure is ready + if (m_DirectoryUpdate) { + QEventLoop loop; + connect(this, &OrganizerCore::directoryStructureReady, &loop, &QEventLoop::quit, + Qt::ConnectionType::QueuedConnection); + loop.exec(); + } + + // need to make sure all data is saved before we start the application + if (m_CurrentProfile != nullptr) { + m_CurrentProfile->writeModlistNow(true); + } + + if (!m_AboutToRun(binary.absoluteFilePath(), cwd, arguments)) { + log::debug("start of \"{}\" cancelled by plugin", binary.absoluteFilePath()); + return false; + } + + try { + m_USVFS.updateMapping(fileMapping(profileName, customOverwrite)); + m_USVFS.updateForcedLibraries(forcedLibraries); +#ifdef _WIN32 + } catch (const UsvfsConnectorException& e) { + log::debug("{}", e.what()); + return false; +#else + } catch (const FuseConnectorException& e) { + log::error("VFS mount failed: {}", e.what()); + return false; +#endif + } catch (const std::exception& e) { + QWidget* w = nullptr; + if (m_UserInterface) { + w = m_UserInterface->mainWindow(); + } + QMessageBox::warning(w, tr("Error"), e.what()); + return false; + } + +#ifndef _WIN32 + // Deploy plugins.txt and loadorder.txt to Wine prefix before launch + if (m_CurrentProfile != nullptr) { + const QString prefixPathStr = resolveWinePrefixPath(m_Settings, managedGame()); + + if (!prefixPathStr.isEmpty()) { + WinePrefix prefix(prefixPathStr); + if (prefix.isValid()) { + const QString dataDirName = resolveWineDataDirName(managedGame()); + const auto localSavesFeature = gameFeatures().gameFeature(); + const QString saveRelativePath = + resolveSaveRelativePath(m_CurrentProfile, managedGame(), + localSavesFeature.get()); + + // Read plugin lines from profile's plugins.txt + QFile pluginsFile(m_CurrentProfile->getPluginsFileName()); + if (pluginsFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + QStringList plugins; + QTextStream stream(&pluginsFile); + while (!stream.atEnd()) { + const QString line = stream.readLine().trimmed(); + if (!line.isEmpty() && !line.startsWith('#')) { + plugins.append(line); + } + } + pluginsFile.close(); + + if (!plugins.isEmpty()) { + prefix.deployPlugins(plugins, dataDirName); + log::debug("Deployed {} plugins to prefix '{}'", plugins.size(), + prefixPathStr); + } + } + + if (m_CurrentProfile->localSettingsEnabled()) { + int deployedIniCount = 0; + for (const QString& iniFile : managedGame()->iniFiles()) { + const QString sourceIni = + m_CurrentProfile->absoluteIniFilePath(iniFile); + const QString targetIni = QFileInfo(managedGame()->documentsDirectory(), + iniFile) + .absoluteFilePath(); + log::debug("INI deploy check: source='{}' exists={}, target='{}'", + sourceIni, QFileInfo::exists(sourceIni), targetIni); + if (QFileInfo::exists(sourceIni) && + prefix.deployProfileIni(sourceIni, targetIni)) { + ++deployedIniCount; + log::debug("Deployed profile INI '{}' -> '{}'", sourceIni, targetIni); + } + } + if (deployedIniCount > 0) { + log::debug("Deployed {} profile INI files to prefix '{}'", deployedIniCount, + prefixPathStr); + } + } else { + log::debug("Profile local settings not enabled, skipping INI deployment. " + "documentsDirectory='{}'", + managedGame()->documentsDirectory().absolutePath()); + } + + if (m_CurrentProfile->localSavesEnabled()) { + const QString profileSavesDir = + QDir(m_CurrentProfile->absolutePath()).filePath("saves"); + log::debug("Resolved local save mapping: profile='{}', target='{}'", + profileSavesDir, saveRelativePath); + if (!prefix.deployProfileSaves(profileSavesDir, dataDirName, saveRelativePath, + true)) { + log::warn("Failed to deploy profile saves from '{}' to prefix '{}'", + profileSavesDir, prefixPathStr); + } else { + log::debug("Deployed profile saves '{}' -> '{}/{}' in prefix '{}'", + profileSavesDir, dataDirName, saveRelativePath, prefixPathStr); + } + } + } else { + log::warn("Wine prefix at '{}' is not valid (no drive_c)", prefixPathStr); + } + } else { + log::debug("No Wine prefix configured, skipping plugin deployment"); + } + } +#endif + + return true; +} + +void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode) +{ + // need to remove our stored load order because it may be outdated if a + // foreign tool changed the file time. After removing that file, + // refreshESPList will use the file time as the order + if (managedGame()->loadOrderMechanism() == + IPluginGame::LoadOrderMechanism::FileTime) { + log::debug("removing loadorder.txt"); + QFile::remove(m_CurrentProfile->getLoadOrderFileName()); + } + + refreshDirectoryStructure(); + +#ifndef _WIN32 + // Flush staged VFS writes to overwrite and rebuild tree + m_USVFS.flushStagingLive(); + + if (m_CurrentProfile != nullptr) { + const QString prefixPathStr = resolveWinePrefixPath(m_Settings, managedGame()); + if (!prefixPathStr.isEmpty()) { + WinePrefix prefix(prefixPathStr); + if (prefix.isValid()) { + const QString dataDirName = resolveWineDataDirName(managedGame()); + const auto localSavesFeature = gameFeatures().gameFeature(); + const QString saveRelativePath = + resolveSaveRelativePath(m_CurrentProfile, managedGame(), + localSavesFeature.get()); + + if (m_CurrentProfile->localSavesEnabled()) { + const QString profileSavesDir = + QDir(m_CurrentProfile->absolutePath()).filePath("saves"); + log::debug("Syncing local save mapping: profile='{}', target='{}'", + profileSavesDir, saveRelativePath); + if (!prefix.syncSavesBack(profileSavesDir, dataDirName, saveRelativePath)) { + log::warn("Failed to sync saves back from prefix '{}' to '{}'", + prefixPathStr, profileSavesDir); + } + } + + if (m_CurrentProfile->localSettingsEnabled()) { + QList> iniMappings; + for (const QString& iniFile : managedGame()->iniFiles()) { + const QString profileIni = + m_CurrentProfile->absoluteIniFilePath(iniFile); + const QString targetIni = QFileInfo(managedGame()->documentsDirectory(), + iniFile) + .absoluteFilePath(); + iniMappings.append({profileIni, targetIni}); + log::debug("Sync profile INI '{}' <- '{}'", profileIni, targetIni); + } + + if (!iniMappings.isEmpty() && + !prefix.syncProfileInisBack(iniMappings)) { + log::warn("Failed to sync profile INIs back from prefix '{}'", + prefixPathStr); + } + } + } + } + } +#endif + + refreshESPList(true); + savePluginList(); + cycleDiagnostics(); + + // These callbacks should not fiddle with directory structure and ESPs. + m_FinishedRun(binary.absoluteFilePath(), exitCode); +} + +ProcessRunner::Results OrganizerCore::waitForAllUSVFSProcesses(UILocker::Reasons reason) +{ +#ifdef _WIN32 + return processRunner().waitForAllUSVFSProcessesWithLock(reason); +#else + Q_UNUSED(reason); + return ProcessRunner::Completed; +#endif +} + +std::vector OrganizerCore::fileMapping(const QString& profileName, + const QString& customOverwrite) +{ + // need to wait until directory structure is ready + if (m_DirectoryUpdate) { + QEventLoop loop; + connect(this, &OrganizerCore::directoryStructureReady, &loop, &QEventLoop::quit, + Qt::ConnectionType::QueuedConnection); + loop.exec(); + } + + IPluginGame* game = qApp->property("managed_game").value(); + Profile profile(QDir(m_Settings.paths().profiles() + "/" + profileName), game, + gameFeatures()); + + MappingType result; + + auto dataMaps = game->getModMappings(); + + bool overwriteActive = false; + + for (const auto& mod : profile.getActiveMods()) { + if (std::get<0>(mod).compare("overwrite", Qt::CaseInsensitive) == 0) { + continue; + } + + unsigned int modIndex = ModInfo::getIndex(std::get<0>(mod)); + ModInfo::Ptr modPtr = ModInfo::getByIndex(modIndex); + + bool createTarget = customOverwrite == std::get<0>(mod); + QDir modDir = QDir(std::get<1>(mod)); + + overwriteActive |= createTarget; + + if (modPtr->isRegular()) { + for (auto dataMap : dataMaps.asKeyValueRange()) { + auto mapDir = QDir(modDir.absoluteFilePath(dataMap.first)); + if (mapDir.exists()) { + for (auto dir : dataMap.second) { + result.insert(result.end(), + {mapDir.absolutePath(), dir, true, createTarget}); + } + } + } + } + } + + if (!overwriteActive && !customOverwrite.isEmpty()) { + throw MyException( + tr("The designated write target \"%1\" is not enabled.").arg(customOverwrite)); + } + + if (m_CurrentProfile->localSavesEnabled()) { + auto localSaves = gameFeatures().gameFeature(); + if (localSaves != nullptr) { + MappingType saveMap = + localSaves->mappings(currentProfile()->absolutePath() + "/saves"); + result.reserve(result.size() + saveMap.size()); + result.insert(result.end(), saveMap.begin(), saveMap.end()); + } else { + log::warn("local save games not supported by this game plugin"); + } + } + + QDir overwriteDir(m_Settings.paths().overwrite()); + for (auto dataMap : dataMaps.asKeyValueRange()) { + auto overwriteSubpath = overwriteDir.absoluteFilePath(dataMap.first); + if (QDir(overwriteSubpath).exists()) { + for (auto dir : dataMap.second) { + result.insert(result.end(), + {overwriteSubpath, dir, true, customOverwrite.isEmpty()}); + } + } + } + + for (MOBase::IPluginFileMapper* mapper : + m_PluginContainer->plugins()) { + IPlugin* plugin = dynamic_cast(mapper); + if (m_PluginContainer->isEnabled(plugin)) { + MappingType pluginMap = mapper->mappings(); + result.reserve(result.size() + pluginMap.size()); + result.insert(result.end(), pluginMap.begin(), pluginMap.end()); + } + } + + return result; +} + +std::vector OrganizerCore::fileMapping(const QString& dataPath, + const QString& relPath, + const DirectoryEntry* base, + const DirectoryEntry* directoryEntry, + int createDestination) +{ + std::vector result; + + for (FileEntryPtr current : directoryEntry->getFiles()) { + bool isArchive = false; + int origin = current->getOrigin(isArchive); + if (isArchive || (origin == 0)) { + continue; + } + + QString originPath = QString::fromStdWString(base->getOriginByID(origin).getPath()); + QString fileName = QString::fromStdWString(current->getName()); + // QString fileName = ToQString(current->getName()); + QString source = originPath + relPath + fileName; + QString target = dataPath + relPath + fileName; + if (source != target) { + result.push_back({source, target, false, false}); + } + } + + // recurse into subdirectories + for (const auto& d : directoryEntry->getSubDirectories()) { + int origin = d->anyOrigin(); + + QString originPath = QString::fromStdWString(base->getOriginByID(origin).getPath()); + QString dirName = QString::fromStdWString(d->getName()); + QString source = originPath + relPath + dirName; + QString target = dataPath + relPath + dirName; + + bool writeDestination = (base == directoryEntry) && (origin == createDestination); + + result.push_back({source, target, true, writeDestination}); + std::vector subRes = + fileMapping(dataPath, relPath + dirName + "/", base, d, createDestination); + result.insert(result.end(), subRes.begin(), subRes.end()); + } + return result; +} diff --git a/src/src/organizercore.h b/src/src/organizercore.h new file mode 100644 index 0000000..4b976b6 --- /dev/null +++ b/src/src/organizercore.h @@ -0,0 +1,601 @@ +#ifndef ORGANIZERCORE_H +#define ORGANIZERCORE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "downloadmanager.h" +#include "envdump.h" +#include "executableslist.h" +#include "installationmanager.h" +#include "modinfo.h" +#include "modlist.h" +#include "moshortcut.h" +#include "pluginlist.h" +#include "processrunner.h" +#include "selfupdater.h" +#include "settings.h" +#include "uilocker.h" +#ifdef _WIN32 +#include "usvfsconnector.h" +#else +#include "fuseconnector.h" +#endif + +class ModListSortProxy; +class PluginListSortProxy; +class Profile; +class IUserInterface; +class GameFeatures; +class PluginContainer; +class DirectoryRefresher; + +#include +#include + +namespace MOBase +{ +template +class GuessedValue; +class IModInterface; +class IPluginGame; +} // namespace MOBase + +namespace MOShared +{ +class DirectoryEntry; +} + +class OrganizerCore : public QObject, public MOBase::IPluginDiagnose +{ + + Q_OBJECT + Q_INTERFACES(MOBase::IPluginDiagnose) + +private: + friend class OrganizerProxy; + + struct SignalCombinerAnd + { + using result_type = bool; + + template + bool operator()(InputIterator first, InputIterator last) const + { + while (first != last) { + if (!(*first)) { + return false; + } + ++first; + } + return true; + } + }; + +private: + using SignalAboutToRunApplication = + boost::signals2::signal; + using SignalFinishedRunApplication = + boost::signals2::signal; + using SignalUserInterfaceInitialized = boost::signals2::signal; + using SignalProfileCreated = boost::signals2::signal; + using SignalProfileRenamed = + boost::signals2::signal; + using SignalProfileRemoved = boost::signals2::signal; + using SignalProfileChanged = + boost::signals2::signal; + using SignalPluginSettingChanged = boost::signals2::signal; + using SignalPluginEnabled = boost::signals2::signal; + +public: + /** + * Small holder for the game content returned by the ModDataContent feature (the + * list of all possible contents, not the per-mod content). + */ + struct ModDataContentHolder + { + + using Content = MOBase::ModDataContent::Content; + + /** + * @return true if the hold list of contents is empty, false otherwise. + */ + bool empty() const { return m_Contents.empty(); } + + /** + * @param id ID of the content to retrieve. + * + * @return the content with the given ID, or a null pointer if it is not found. + */ + const Content* findById(int id) const + { + auto it = std::find_if(std::begin(m_Contents), std::end(m_Contents), + [&id](auto const& content) { + return content.id() == id; + }); + return it == std::end(m_Contents) ? nullptr : &(*it); + } + + /** + * Apply the given function to each content whose ID is in the given set. + * + * @param ids The set of content IDs. + * @param fn The function to apply. + * @param includeFilter true to also apply the function to filter-only contents, + * false otherwise. + */ + template + void forEachContentIn(std::set const& ids, Fn const& fn, + bool includeFilter = false) const + { + for (const auto& content : m_Contents) { + if ((includeFilter || !content.isOnlyForFilter()) && + ids.find(content.id()) != ids.end()) { + fn(content); + } + } + } + + /** + * @brief Apply fnIn to each content whose ID is in the given set, and fnOut to each + * content not in the given set, excluding filter-only content (from both cases) + * unless includeFilter is true. + * + * @param ids The set of content IDs. + * @param fnIn Function to apply to content whose IDs are in ids. + * @param fnOut Function to apply to content whose IDs are not in ids. + * @param includeFilter true to also apply the function to filter-only contents, + * false otherwise. + */ + template + void forEachContentInOrOut(std::set const& ids, FnIn const& fnIn, + FnOut const& fnOut, bool includeFilter = false) const + { + for (const auto& content : m_Contents) { + if ((includeFilter || !content.isOnlyForFilter())) { + if (ids.find(content.id()) != ids.end()) { + fnIn(content); + } else { + fnOut(content); + } + } + } + } + + /** + * Apply the given function to each content. + * + * @param fn The function to apply. + * @param includeFilter true to also apply the function to filter-only contents, + * false otherwise. + */ + template + void forEachContent(Fn const& fn, bool includeFilter = false) const + { + for (const auto& content : m_Contents) { + if (includeFilter || !content.isOnlyForFilter()) { + fn(content); + } + } + } + + ModDataContentHolder& operator=(ModDataContentHolder const&) = delete; + ModDataContentHolder& operator=(ModDataContentHolder&&) = default; + + private: + std::vector m_Contents; + + /** + * @brief Construct a ModDataContentHolder without any contents (e.g., if the + * feature is missing). + */ + ModDataContentHolder() {} + + /** + * @brief Construct a ModDataContentHold holding the given list of contents. + */ + ModDataContentHolder(std::vector contents) + : m_Contents(std::move(contents)) + {} + + friend class OrganizerCore; + }; + + // enumeration for the mode when adding refresh callbacks + // + enum class RefreshCallbackMode : int + { + // run the callbacks immediately if no refresh is running + RUN_NOW_IF_POSSIBLE = 0, + + // wait for the next refresh if none is running + FORCE_WAIT_FOR_REFRESH = 1 + }; + + // enumeration for the groups where refresh callbacks can be put + // + enum class RefreshCallbackGroup : int + { + // for callbacks by the core itself, highest priority + CORE = 0, + + // internal MO2 callbacks + INTERNAL = 1, + + // external callbacks, typically MO2 plugins + EXTERNAL = 2 + }; + +public: + OrganizerCore(Settings& settings); + + ~OrganizerCore(); + + void setUserInterface(IUserInterface* ui); + void connectPlugins(PluginContainer* container); + + void setManagedGame(MOBase::IPluginGame* game); + + void updateExecutablesList(); + void updateModInfoFromDisc(); + + void checkForUpdates(); + void startMOUpdate(); + + Settings& settings(); + SelfUpdater* updater() { return &m_Updater; } + InstallationManager* installationManager(); + MOShared::DirectoryEntry* directoryStructure() { return m_DirectoryStructure; } + DirectoryRefresher* directoryRefresher() { return m_DirectoryRefresher.get(); } + ExecutablesList* executablesList() { return &m_ExecutablesList; } + void setExecutablesList(const ExecutablesList& executablesList) + { + m_ExecutablesList = executablesList; + } + + std::shared_ptr currentProfile() const { return m_CurrentProfile; } + + void setCurrentProfile(const QString& profileName); + + QStringList profileNames() const; + std::shared_ptr getProfile(const QString& profileName) const; + + std::vector enabledArchives(); + + MOBase::Version getVersion() const { return m_Updater.getVersion(); } + + // return the plugin container + // + PluginContainer& pluginContainer() const; + + // return the game features + GameFeatures& gameFeatures() const; + + MOBase::IPluginGame const* managedGame() const; + + /** + * @brief Retrieve the organizer proxy of the currently managed game. + * + */ + MOBase::IOrganizer const* managedGameOrganizer() const; + + /** + * @return the list of contents for the currently managed game, or an empty vector + * if the game plugin does not implement the ModDataContent feature. + */ + const ModDataContentHolder& modDataContents() const { return m_Contents; } + + bool isArchivesInit() const { return m_ArchivesInit; } + + bool saveCurrentLists(); + + ProcessRunner processRunner(); + + bool beforeRun(const QFileInfo& binary, const QDir& cwd, const QString& arguments, + const QString& profileName, const QString& customOverwrite, + const QList& forcedLibraries); + + void afterRun(const QFileInfo& binary, DWORD exitCode); + + ProcessRunner::Results + waitForAllUSVFSProcesses(UILocker::Reasons reason = UILocker::PreventExit); + + void refreshESPList(bool force = false); + void refreshBSAList(); + + void refreshDirectoryStructure(); + void updateModInDirectoryStructure(unsigned int index, ModInfo::Ptr modInfo); + void updateModsInDirectoryStructure(QMap modInfos); + + void doAfterLogin(const std::function& function) + { + m_PostLoginTasks.append(function); + } + void loggedInAction(QWidget* parent, std::function f); + + bool previewFileWithAlternatives(QWidget* parent, QString filename, + int selectedOrigin = -1); + bool previewFile(QWidget* parent, const QString& originName, const QString& path); + + void loginSuccessfulUpdate(bool necessary); + void loginFailedUpdate(const QString& message); + + static bool createAndMakeWritable(const QString& path); + bool checkPathSymlinks(); + bool bootstrap(); + void createDefaultProfile(); + void createOverwriteDirectories(); + + MOBase::DelayedFileWriter& pluginsWriter() { return m_PluginListsWriter; } + + void prepareVFS(); + + void updateVFSParams(MOBase::log::Levels logLevel, env::CoreDumpTypes coreDumpType, + const QString& coreDumpsPath, std::chrono::seconds spawnDelay, + QString executableBlacklist, const QStringList& skipFileSuffixes, + const QStringList& skipDirectories); + + void setLogLevel(MOBase::log::Levels level); + + bool cycleDiagnostics(); + + static env::CoreDumpTypes getGlobalCoreDumpType(); + static void setGlobalCoreDumpType(env::CoreDumpTypes type); + static std::wstring getGlobalCoreDumpPath(); + +public: + MOBase::IModRepositoryBridge* createNexusBridge() const; + QString profileName() const; + QString profilePath() const; + QString downloadsPath() const; + QString overwritePath() const; + QString basePath() const; + QString modsPath() const; + MOBase::Version version() const; + MOBase::IPluginGame* getGame(const QString& gameName) const; + MOBase::IModInterface* createMod(MOBase::GuessedValue& name); + void modDataChanged(MOBase::IModInterface* mod); + QVariant pluginSetting(const QString& pluginName, const QString& key) const; + void setPluginSetting(const QString& pluginName, const QString& key, + const QVariant& value); + QVariant persistent(const QString& pluginName, const QString& key, + const QVariant& def) const; + void setPersistent(const QString& pluginName, const QString& key, + const QVariant& value, bool sync); + static QString pluginDataPath(); + virtual MOBase::IModInterface* installMod(const QString& fileName, int priority, + bool reinstallation, + ModInfo::Ptr currentMod, + const QString& initModName); + QString resolvePath(const QString& fileName) const; + QStringList listDirectories(const QString& directoryName) const; + QStringList findFiles(const QString& path, + const std::function& filter) const; + QStringList getFileOrigins(const QString& fileName) const; + QList findFileInfos( + const QString& path, + const std::function& filter) const; + DownloadManager* downloadManager(); + PluginList* pluginList(); + ModList* modList(); + void refresh(bool saveChanges = true); + + boost::signals2::connection onAboutToRun( + const std::function& func); + boost::signals2::connection + onFinishedRun(const std::function& func); + boost::signals2::connection + onUserInterfaceInitialized(std::function const& func); + boost::signals2::connection + onProfileCreated(std::function const& func); + boost::signals2::connection onProfileRenamed( + std::function const& + func); + boost::signals2::connection + onProfileRemoved(std::function const& func); + boost::signals2::connection onProfileChanged( + std::function const& func); + boost::signals2::connection onPluginSettingChanged( + std::function const& func); + boost::signals2::connection + onPluginEnabled(std::function const& func); + boost::signals2::connection + onPluginDisabled(std::function const& func); + + // add a function to be called after the next refresh is done + // + // - group to add the function to + // - if immediateIfReady is true, the function will be called immediately if no + // directory update is running + boost::signals2::connection onNextRefresh(std::function const& func, + RefreshCallbackGroup group, + RefreshCallbackMode mode); + +public: // IPluginDiagnose interface + virtual std::vector activeProblems() const; + virtual QString shortDescription(unsigned int key) const; + virtual QString fullDescription(unsigned int key) const; + virtual bool hasGuidedFix(unsigned int key) const; + virtual void startGuidedFix(unsigned int key) const; + +public slots: + + void syncOverwrite(); + + void savePluginList(); + + void refreshLists(); + + ModInfo::Ptr installDownload(int downloadIndex, int priority = -1); + ModInfo::Ptr installArchive(const QString& archivePath, int priority = -1, + bool reinstallation = false, + ModInfo::Ptr currentMod = nullptr, + const QString& modName = QString()); + + void modPrioritiesChanged(QModelIndexList const& indexes); + void modStatusChanged(unsigned int index); + void modStatusChanged(QList index); + void requestDownload(const QUrl& url, QNetworkReply* reply); + void downloadRequestedNXM(const QString& url); + + void userInterfaceInitialized(); + + void profileCreated(MOBase::IProfile* profile); + void profileRenamed(MOBase::IProfile* profile, QString const& oldName, + QString const& newName); + void profileRemoved(QString const& profileName); + + bool nexusApi(bool retry = false); + +signals: + + // emitted after a mod has been installed + // + void modInstalled(const QString& modName); + + // emitted when the managed game changes + // + void managedGameChanged(MOBase::IPluginGame const* gamePlugin); + + // emitted when the profile is changed, before notifying plugins + // + // the new profile can be stored but the old one is temporary and + // should not be + // + void profileChanged(Profile* oldProfile, Profile* newProfile); + + // Notify that the directory structure is ready to be used on the main thread + // Use queued connections + void directoryStructureReady(); + + // Notify of a general UI refresh + void refreshTriggered(); + +private: + std::pair doInstall(const QString& archivePath, + MOBase::GuessedValue modName, + ModInfo::Ptr currentMod, int priority, + bool reinstallation); + + void saveCurrentProfile(); + void storeSettings(); + + void updateModActiveState(int index, bool active); + void updateModsActiveState(const QList& modIndices, bool active); + + // clear the conflict caches of all the given mods, and the mods in conflict + // with the given mods + // + void clearCaches(std::vector const& indices) const; + + bool createDirectory(const QString& path); + + QString oldMO1HookDll() const; + + /** + * @brief return a descriptor of the mappings real file->virtual file + */ + std::vector fileMapping(const QString& profile, + const QString& customOverwrite); + + std::vector fileMapping(const QString& dataPath, const QString& relPath, + const MOShared::DirectoryEntry* base, + const MOShared::DirectoryEntry* directoryEntry, + int createDestination); + +private slots: + + void onDirectoryRefreshed(); + void downloadRequested(QNetworkReply* reply, QString gameName, int modID, + const QString& fileName); + void removeOrigin(const QString& name); + void downloadSpeed(const QString& serverName, int bytesPerSecond); + void loginSuccessful(bool necessary); + void loginFailed(const QString& message); + +private: + static constexpr unsigned int PROBLEM_MO1SCRIPTEXTENDERWORKAROUND = 1; + +private: + IUserInterface* m_UserInterface; + PluginContainer* m_PluginContainer; + QString m_GameName; + MOBase::IPluginGame* m_GamePlugin; + ModDataContentHolder m_Contents; + + std::shared_ptr m_CurrentProfile; + + Settings& m_Settings; + + SelfUpdater m_Updater; + + SignalAboutToRunApplication m_AboutToRun; + SignalFinishedRunApplication m_FinishedRun; + SignalUserInterfaceInitialized m_UserInterfaceInitialized; + SignalProfileCreated m_ProfileCreated; + SignalProfileRenamed m_ProfileRenamed; + SignalProfileRemoved m_ProfileRemoved; + SignalProfileChanged m_ProfileChanged; + SignalPluginSettingChanged m_PluginSettingChanged; + SignalPluginEnabled m_PluginEnabled; + SignalPluginEnabled m_PluginDisabled; + + boost::signals2::signal m_OnNextRefreshCallbacks; + + ModList m_ModList; + PluginList m_PluginList; + + QList> m_PostLoginTasks; + + ExecutablesList m_ExecutablesList; + QStringList m_PendingDownloads; + QStringList m_DefaultArchives; + QStringList m_ActiveArchives; + + std::unique_ptr m_DirectoryRefresher; + MOShared::DirectoryEntry* m_DirectoryStructure; + MOBase::MemoizedLocked> m_VirtualFileTree; + + DownloadManager m_DownloadManager; + InstallationManager m_InstallationManager; + + QThread m_RefresherThread; + + std::thread m_StructureDeleter; + + std::atomic m_DirectoryUpdate; + bool m_ArchivesInit; + + MOBase::DelayedFileWriter m_PluginListsWriter; +#ifdef _WIN32 + UsvfsConnector m_USVFS; +#else + FuseConnector m_USVFS; +#endif + + UILocker m_UILocker; +}; + +#endif // ORGANIZERCORE_H diff --git a/src/src/organizerproxy.cpp b/src/src/organizerproxy.cpp new file mode 100644 index 0000000..80603f0 --- /dev/null +++ b/src/src/organizerproxy.cpp @@ -0,0 +1,515 @@ +#include "organizerproxy.h" + +#include "downloadmanagerproxy.h" +#include "executableslistproxy.h" +#include "gamefeaturesproxy.h" +#include "glob_matching.h" +#include "instancemanager.h" +#include "modlistproxy.h" +#include "organizercore.h" +#include "plugincontainer.h" +#include "pluginlistproxy.h" +#include "proxyutils.h" +#include "settings.h" +#include "shared/appconfig.h" +#include "shared/util.h" + +#include +#include + +#include + +using namespace MOBase; +using namespace MOShared; + +OrganizerProxy::OrganizerProxy(OrganizerCore* organizer, + PluginContainer* pluginContainer, + MOBase::IPlugin* plugin) + : m_Proxied(organizer), m_PluginContainer(pluginContainer), m_Plugin(plugin), + m_DownloadManagerProxy( + std::make_unique(this, organizer->downloadManager())), + m_ModListProxy(std::make_unique(this, organizer->modList())), + m_ExecutablesListProxy( + std::make_unique(organizer->executablesList())), + m_PluginListProxy( + std::make_unique(this, organizer->pluginList())), + m_GameFeaturesProxy( + std::make_unique(this, pluginContainer->gameFeatures())) +{} + +OrganizerProxy::~OrganizerProxy() +{ + disconnectSignals(); +} + +void OrganizerProxy::connectSignals() +{ + m_Connections.push_back( + m_Proxied->onAboutToRun(callSignalIfPluginActive(this, m_AboutToRun, true))); + m_Connections.push_back( + m_Proxied->onFinishedRun(callSignalIfPluginActive(this, m_FinishedRun))); + m_Connections.push_back( + m_Proxied->onProfileCreated(callSignalIfPluginActive(this, m_ProfileCreated))); + m_Connections.push_back( + m_Proxied->onProfileRenamed(callSignalIfPluginActive(this, m_ProfileRenamed))); + m_Connections.push_back( + m_Proxied->onProfileRemoved(callSignalIfPluginActive(this, m_ProfileRemoved))); + m_Connections.push_back( + m_Proxied->onProfileChanged(callSignalIfPluginActive(this, m_ProfileChanged))); + + m_Connections.push_back(m_Proxied->onUserInterfaceInitialized( + callSignalAlways(m_UserInterfaceInitialized))); + m_Connections.push_back( + m_Proxied->onPluginSettingChanged(callSignalAlways(m_PluginSettingChanged))); + m_Connections.push_back( + m_Proxied->onPluginEnabled(callSignalAlways(m_PluginEnabled))); + m_Connections.push_back( + m_Proxied->onPluginDisabled(callSignalAlways(m_PluginDisabled))); + + // Connect the child proxies. + m_DownloadManagerProxy->connectSignals(); + m_ModListProxy->connectSignals(); + m_PluginListProxy->connectSignals(); +} + +void OrganizerProxy::disconnectSignals() +{ + // Disconnect the child proxies. + m_DownloadManagerProxy->disconnectSignals(); + m_ModListProxy->disconnectSignals(); + m_PluginListProxy->disconnectSignals(); + + for (auto& conn : m_Connections) { + conn.disconnect(); + } + m_Connections.clear(); +} + +IModRepositoryBridge* OrganizerProxy::createNexusBridge() const +{ + return new NexusBridge(m_PluginContainer, m_Plugin->name()); +} + +QString OrganizerProxy::instanceName() const +{ + return InstanceManager::singleton().currentInstance()->displayName(); +} + +QString OrganizerProxy::profileName() const +{ + return m_Proxied->profileName(); +} + +QString OrganizerProxy::profilePath() const +{ + return m_Proxied->profilePath(); +} + +QString OrganizerProxy::downloadsPath() const +{ + return m_Proxied->downloadsPath(); +} + +QString OrganizerProxy::overwritePath() const +{ + return m_Proxied->overwritePath(); +} + +QString OrganizerProxy::basePath() const +{ + return m_Proxied->basePath(); +} + +QString OrganizerProxy::modsPath() const +{ + return m_Proxied->modsPath(); +} + +Version OrganizerProxy::version() const +{ + return m_Proxied->version(); +} + +VersionInfo OrganizerProxy::appVersion() const +{ + const auto version = m_Proxied->version(); + const int major = version.major(), minor = version.minor(), + subminor = version.patch(); + int subsubminor = 0; + VersionInfo::ReleaseType infoReleaseType = VersionInfo::RELEASE_FINAL; + + // make a copy + auto prereleases = version.preReleases(); + + if (!prereleases.empty()) { + // check if the first pre-release entry is a number + if (prereleases.front().index() == 0) { + subsubminor = std::get(prereleases.front()); + prereleases.erase(prereleases.begin()); + } + + if (!prereleases.empty()) { + const auto releaseType = std::get(prereleases.front()); + switch (releaseType) { + case Version::Development: + infoReleaseType = VersionInfo::RELEASE_PREALPHA; + break; + case Version::Alpha: + infoReleaseType = VersionInfo::RELEASE_ALPHA; + break; + case Version::Beta: + infoReleaseType = VersionInfo::RELEASE_BETA; + break; + case Version::ReleaseCandidate: + infoReleaseType = VersionInfo::RELEASE_CANDIDATE; + break; + default: + infoReleaseType = VersionInfo::RELEASE_PREALPHA; + } + } + + // there is no way to differentiate two pre-releases? + } + + return VersionInfo(major, minor, subminor, subsubminor, infoReleaseType); +} + +IPluginGame* OrganizerProxy::getGame(const QString& gameName) const +{ + return m_Proxied->getGame(gameName); +} + +IModInterface* OrganizerProxy::createMod(MOBase::GuessedValue& name) +{ + return m_Proxied->createMod(name); +} + +void OrganizerProxy::modDataChanged(IModInterface* mod) +{ + m_Proxied->modDataChanged(mod); +} + +bool OrganizerProxy::isPluginEnabled(QString const& pluginName) const +{ + return m_PluginContainer->isEnabled(pluginName); +} + +bool OrganizerProxy::isPluginEnabled(IPlugin* plugin) const +{ + return m_PluginContainer->isEnabled(plugin); +} + +QVariant OrganizerProxy::pluginSetting(const QString& pluginName, + const QString& key) const +{ + return m_Proxied->pluginSetting(pluginName, key); +} + +void OrganizerProxy::setPluginSetting(const QString& pluginName, const QString& key, + const QVariant& value) +{ + m_Proxied->setPluginSetting(pluginName, key, value); +} + +QVariant OrganizerProxy::persistent(const QString& pluginName, const QString& key, + const QVariant& def) const +{ + return m_Proxied->persistent(pluginName, key, def); +} + +void OrganizerProxy::setPersistent(const QString& pluginName, const QString& key, + const QVariant& value, bool sync) +{ + m_Proxied->setPersistent(pluginName, key, value, sync); +} + +QString OrganizerProxy::pluginDataPath() const +{ + return OrganizerCore::pluginDataPath(); +} + +HANDLE OrganizerProxy::startApplication(const QString& exe, const QStringList& args, + const QString& cwd, const QString& profile, + const QString& overwrite, bool ignoreOverwrite) +{ + log::debug("a plugin has requested to start an application:\n" + " . executable: '{}'\n" + " . args: '{}'\n" + " . cwd: '{}'\n" + " . profile: '{}'\n" + " . overwrite: '{}'\n" + " . ignore overwrite: {}", + exe, args.join(" "), cwd, profile, overwrite, ignoreOverwrite); + + auto runner = m_Proxied->processRunner(); + + // don't wait for completion + runner.setFromFileOrExecutable(exe, args, cwd, profile, overwrite, ignoreOverwrite) + .run(); + + // the plugin is in charge of closing the handle, unless waitForApplication() + // is called on it + return runner.stealProcessHandle().release(); +} + +bool OrganizerProxy::waitForApplication(HANDLE handle, bool refresh, + LPDWORD exitCode) const +{ +#ifdef _WIN32 + const auto pid = ::GetProcessId(handle); +#else + const DWORD pid = 0; // No GetProcessId on Linux +#endif + + log::debug("a plugin wants to wait for an application to complete, pid {}{}", pid, + (pid == 0 ? "unknown (probably already completed)" : "")); + + auto runner = m_Proxied->processRunner(); + + ProcessRunner::WaitFlags waitFlags = ProcessRunner::ForceWait; + + if (refresh) { + waitFlags |= ProcessRunner::TriggerRefresh | ProcessRunner::WaitForRefresh; + } + +#ifdef _WIN32 + const auto r = runner.setWaitForCompletion(waitFlags, UILocker::OutputRequired) + .attachToProcess(handle); +#else + const auto r = runner.setWaitForCompletion(waitFlags, UILocker::OutputRequired) + .attachToProcess(static_cast(reinterpret_cast(handle))); +#endif + + if (exitCode) { + *exitCode = runner.exitCode(); + } + + switch (r) { + case ProcessRunner::Completed: + return true; + + case ProcessRunner::Cancelled: // fall-through + case ProcessRunner::ForceUnlocked: + // this is always an error because the application should have run to + // completion + return false; + + case ProcessRunner::Error: // fall-through + default: + return false; + } +} + +void OrganizerProxy::refresh(bool saveChanges) +{ + m_Proxied->refresh(saveChanges); +} + +IModInterface* OrganizerProxy::installMod(const QString& fileName, + const QString& nameSuggestion) +{ + return m_Proxied->installMod(fileName, -1, false, nullptr, nameSuggestion); +} + +QString OrganizerProxy::resolvePath(const QString& fileName) const +{ + return m_Proxied->resolvePath(fileName); +} + +QStringList OrganizerProxy::listDirectories(const QString& directoryName) const +{ + return m_Proxied->listDirectories(directoryName); +} + +QStringList +OrganizerProxy::findFiles(const QString& path, + const std::function& filter) const +{ + return m_Proxied->findFiles(path, filter); +} + +QStringList OrganizerProxy::findFiles(const QString& path, + const QStringList& globFilters) const +{ + QList> patterns; + for (auto& gfilter : globFilters) { + patterns.append(GlobPattern(gfilter)); + } + return findFiles(path, [&patterns](const QString& filename) { + for (auto& p : patterns) { + if (p.match(filename)) { + return true; + } + } + return false; + }); +} + +QStringList OrganizerProxy::getFileOrigins(const QString& fileName) const +{ + return m_Proxied->getFileOrigins(fileName); +} + +QList OrganizerProxy::findFileInfos( + const QString& path, + const std::function& filter) const +{ + return m_Proxied->findFileInfos(path, filter); +} + +std::shared_ptr OrganizerProxy::virtualFileTree() const +{ + return m_Proxied->m_VirtualFileTree.value(); +} + +MOBase::IDownloadManager* OrganizerProxy::downloadManager() const +{ + return m_DownloadManagerProxy.get(); +} + +MOBase::IPluginList* OrganizerProxy::pluginList() const +{ + return m_PluginListProxy.get(); +} + +MOBase::IModList* OrganizerProxy::modList() const +{ + return m_ModListProxy.get(); +} + +MOBase::IExecutablesList* OrganizerProxy::executablesList() const +{ + return m_ExecutablesListProxy.get(); +} + +MOBase::IGameFeatures* OrganizerProxy::gameFeatures() const +{ + return m_GameFeaturesProxy.get(); +} + +std::shared_ptr OrganizerProxy::profile() const +{ + return m_Proxied->currentProfile(); +} + +QStringList OrganizerProxy::profileNames() const +{ + return m_Proxied->profileNames(); +} + +std::shared_ptr +OrganizerProxy::getProfile(const QString& name) const +{ + return m_Proxied->getProfile(name); +} + +MOBase::IPluginGame const* OrganizerProxy::managedGame() const +{ + return m_Proxied->managedGame(); +} + +// CALLBACKS + +bool OrganizerProxy::onAboutToRun(const std::function& func) +{ + return m_Proxied + ->onAboutToRun(MOShared::callIfPluginActive( + this, + [func](const QString& binary, const QDir&, const QString&) { + return func(binary); + }, + true)) + .connected(); +} + +bool OrganizerProxy::onAboutToRun( + const std::function& func) +{ + return m_Proxied->onAboutToRun(MOShared::callIfPluginActive(this, func, true)) + .connected(); +} + +bool OrganizerProxy::onFinishedRun( + const std::function& func) +{ + return m_Proxied->onFinishedRun(MOShared::callIfPluginActive(this, func)).connected(); +} + +bool OrganizerProxy::onUserInterfaceInitialized( + std::function const& func) +{ + // Always call this one to allow plugin to initialize themselves even when not active: + return m_UserInterfaceInitialized.connect(func).connected(); +} + +bool OrganizerProxy::onNextRefresh(const std::function& func, + bool immediateIfPossible) +{ + using enum OrganizerCore::RefreshCallbackMode; + return m_Proxied + ->onNextRefresh(MOShared::callIfPluginActive(this, func), + OrganizerCore::RefreshCallbackGroup::EXTERNAL, + immediateIfPossible ? RUN_NOW_IF_POSSIBLE + : FORCE_WAIT_FOR_REFRESH) + .connected(); +} + +bool OrganizerProxy::onProfileCreated(std::function const& func) +{ + return m_ProfileCreated.connect(func).connected(); +} + +bool OrganizerProxy::onProfileRenamed( + std::function const& func) +{ + return m_ProfileRenamed.connect(func).connected(); +} + +bool OrganizerProxy::onProfileRemoved(std::function const& func) +{ + return m_ProfileRemoved.connect(func).connected(); +} + +bool OrganizerProxy::onProfileChanged( + std::function const& func) +{ + return m_ProfileChanged.connect(func).connected(); +} +// Always call these one, otherwise plugin cannot detect they are being enabled / +// disabled: +bool OrganizerProxy::onPluginSettingChanged( + std::function const& func) +{ + return m_PluginSettingChanged.connect(func).connected(); +} + +bool OrganizerProxy::onPluginEnabled(std::function const& func) +{ + return m_PluginEnabled.connect(func).connected(); +} + +bool OrganizerProxy::onPluginEnabled(const QString& pluginName, + std::function const& func) +{ + return onPluginEnabled([=](const IPlugin* plugin) { + if (plugin->name().compare(pluginName, Qt::CaseInsensitive) == 0) { + func(); + } + }); +} + +bool OrganizerProxy::onPluginDisabled(std::function const& func) +{ + return m_PluginDisabled.connect(func).connected(); +} + +bool OrganizerProxy::onPluginDisabled(const QString& pluginName, + std::function const& func) +{ + return onPluginDisabled([=](const IPlugin* plugin) { + if (plugin->name().compare(pluginName, Qt::CaseInsensitive) == 0) { + func(); + } + }); +} diff --git a/src/src/organizerproxy.h b/src/src/organizerproxy.h new file mode 100644 index 0000000..f1ede58 --- /dev/null +++ b/src/src/organizerproxy.h @@ -0,0 +1,167 @@ +#ifndef ORGANIZERPROXY_H +#define ORGANIZERPROXY_H + +#include + +#include +#include + +#include "organizercore.h" + +class GameFeaturesProxy; +class PluginContainer; +class DownloadManagerProxy; +class ExecutablesListProxy; +class ModListProxy; +class PluginListProxy; + +class OrganizerProxy : public MOBase::IOrganizer +{ + +public: + OrganizerProxy(OrganizerCore* organizer, PluginContainer* pluginContainer, + MOBase::IPlugin* plugin); + ~OrganizerProxy(); + +public: + /** + * @return the plugin corresponding to this proxy. + */ + MOBase::IPlugin* plugin() const { return m_Plugin; } + +public: // IOrganizer interface + MOBase::IModRepositoryBridge* createNexusBridge() const override; + QString instanceName() const override; + QString profileName() const override; + QString profilePath() const override; + QString downloadsPath() const override; + QString overwritePath() const override; + QString basePath() const override; + QString modsPath() const override; + MOBase::Version version() const override; + MOBase::VersionInfo appVersion() const override; + MOBase::IPluginGame* getGame(const QString& gameName) const override; + MOBase::IModInterface* createMod(MOBase::GuessedValue& name) override; + void modDataChanged(MOBase::IModInterface* mod) override; + QVariant persistent(const QString& pluginName, const QString& key, + const QVariant& def = QVariant()) const override; + void setPersistent(const QString& pluginName, const QString& key, + const QVariant& value, bool sync = true) override; + QString pluginDataPath() const override; + MOBase::IModInterface* installMod(const QString& fileName, + const QString& nameSuggestion = QString()); + QString resolvePath(const QString& fileName) const override; + QStringList listDirectories(const QString& directoryName) const override; + QStringList + findFiles(const QString& path, + const std::function& filter) const override; + QStringList findFiles(const QString& path, + const QStringList& globFilters) const override; + QStringList getFileOrigins(const QString& fileName) const override; + QList + findFileInfos(const QString& path, + const std::function& filter) const override; + std::shared_ptr virtualFileTree() const override; + + MOBase::IDownloadManager* downloadManager() const override; + MOBase::IPluginList* pluginList() const override; + MOBase::IModList* modList() const override; + MOBase::IExecutablesList* executablesList() const override; + std::shared_ptr profile() const override; + QStringList profileNames() const override; + std::shared_ptr + getProfile(const QString& name) const override; + + MOBase::IGameFeatures* gameFeatures() const override; + MOBase::IInstanceManager* instanceManager() const override { return nullptr; } + + HANDLE startApplication(const QString& executable, + const QStringList& args = QStringList(), + const QString& cwd = "", const QString& profile = "", + const QString& forcedCustomOverwrite = "", + bool ignoreCustomOverwrite = false) override; + bool waitForApplication(HANDLE handle, bool refresh = true, + LPDWORD exitCode = nullptr) const override; + void refresh(bool saveChanges) override; + + bool onAboutToRun(const std::function& func) override; + bool onAboutToRun(const std::function& func) override; + bool + onFinishedRun(const std::function& func) override; + bool + onUserInterfaceInitialized(std::function const& func) override; + bool onNextRefresh(const std::function& func, + bool immediateIfPossible) override; + bool onProfileCreated(std::function const& func) override; + bool onProfileRenamed(std::function const& func) override; + bool onProfileRemoved(std::function const& func) override; + bool onProfileChanged( + std::function const& func) override; + + // Plugin related: + virtual bool isPluginEnabled(QString const& pluginName) const override; + virtual bool isPluginEnabled(MOBase::IPlugin* plugin) const override; + virtual QVariant pluginSetting(const QString& pluginName, + const QString& key) const override; + virtual void setPluginSetting(const QString& pluginName, const QString& key, + const QVariant& value) override; + virtual bool onPluginSettingChanged( + std::function const& func) override; + virtual bool + onPluginEnabled(std::function const& func) override; + virtual bool onPluginEnabled(const QString& pluginName, + std::function const& func) override; + virtual bool + onPluginDisabled(std::function const& func) override; + virtual bool onPluginDisabled(const QString& pluginName, + std::function const& func) override; + + virtual MOBase::IPluginGame const* managedGame() const; + +protected: + // The container needs access to some callbacks to simulate startup. + friend class PluginContainer; + + /** + * @brief Connect the signals from this proxy and all the child proxies (plugin list, + * mod list, etc.) to the actual implementation. Before this call, plugins can + * register signals but they won't be triggered. + */ + void connectSignals(); + + /** + * @brief Disconnect the signals from this proxy and all the child proxies (plugin + * list, mod list, etc.) from the actual implementation. + */ + void disconnectSignals(); + +private: + OrganizerCore* m_Proxied; + PluginContainer* m_PluginContainer; + + MOBase::IPlugin* m_Plugin; + + OrganizerCore::SignalAboutToRunApplication m_AboutToRun; + OrganizerCore::SignalFinishedRunApplication m_FinishedRun; + OrganizerCore::SignalUserInterfaceInitialized m_UserInterfaceInitialized; + OrganizerCore::SignalProfileCreated m_ProfileCreated; + OrganizerCore::SignalProfileRenamed m_ProfileRenamed; + OrganizerCore::SignalProfileRemoved m_ProfileRemoved; + OrganizerCore::SignalProfileChanged m_ProfileChanged; + OrganizerCore::SignalPluginSettingChanged m_PluginSettingChanged; + OrganizerCore::SignalPluginEnabled m_PluginEnabled; + OrganizerCore::SignalPluginEnabled m_PluginDisabled; + + std::vector m_Connections; + + std::unique_ptr m_DownloadManagerProxy; + std::unique_ptr m_ModListProxy; + std::unique_ptr m_PluginListProxy; + std::unique_ptr m_ExecutablesListProxy; + std::unique_ptr m_GameFeaturesProxy; +}; + +#endif // ORGANIZERPROXY_H diff --git a/src/src/overwriteinfodialog.cpp b/src/src/overwriteinfodialog.cpp new file mode 100644 index 0000000..0c86ccd --- /dev/null +++ b/src/src/overwriteinfodialog.cpp @@ -0,0 +1,316 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "overwriteinfodialog.h" +#include "report.h" +#include "ui_overwriteinfodialog.h" +#include "utility.h" +#include +#include +#include +#ifdef _WIN32 +#include +#endif + +using namespace MOBase; + +OverwriteInfoDialog::OverwriteInfoDialog(ModInfo::Ptr modInfo, OrganizerCore& organizer, + QWidget* parent) + : QDialog(parent), m_Organizer(organizer), ui(new Ui::OverwriteInfoDialog), + m_FileSystemModel(nullptr), m_DeleteAction(nullptr), m_RenameAction(nullptr), + m_OpenAction(nullptr) +{ + ui->setupUi(this); + + this->setWindowModality(Qt::NonModal); + + m_FileSystemModel = new OverwriteFileSystemModel(this, organizer); + m_FileSystemModel->setReadOnly(false); + setModInfo(modInfo); + ui->filesView->setModel(m_FileSystemModel); + ui->filesView->setRootIndex(m_FileSystemModel->index(modInfo->absolutePath())); + ui->filesView->setColumnWidth(0, 250); + + m_DeleteAction = new QAction(tr("&Delete"), ui->filesView); + m_RenameAction = new QAction(tr("&Rename"), ui->filesView); + m_OpenAction = new QAction(tr("&Open"), ui->filesView); + m_NewFolderAction = new QAction(tr("&New Folder"), ui->filesView); + + new QShortcut(QKeySequence::Delete, this, SLOT(delete_activated())); + + QObject::connect(m_DeleteAction, SIGNAL(triggered()), this, SLOT(deleteTriggered())); + QObject::connect(m_RenameAction, SIGNAL(triggered()), this, SLOT(renameTriggered())); + QObject::connect(m_OpenAction, SIGNAL(triggered()), this, SLOT(openTriggered())); + QObject::connect(m_NewFolderAction, SIGNAL(triggered()), this, + SLOT(createDirectoryTriggered())); +} + +OverwriteInfoDialog::~OverwriteInfoDialog() +{ + delete ui; +} + +void OverwriteInfoDialog::showEvent(QShowEvent* e) +{ + const auto& s = Settings::instance(); + + s.geometry().restoreGeometry(this); + + if (!s.geometry().restoreState(ui->filesView->header())) { + ui->filesView->sortByColumn(0, Qt::AscendingOrder); + } + + QDialog::showEvent(e); +} + +void OverwriteInfoDialog::done(int r) +{ + auto& s = Settings::instance(); + + s.geometry().saveGeometry(this); + s.geometry().saveState(ui->filesView->header()); + + QDialog::done(r); +} + +void OverwriteInfoDialog::setModInfo(ModInfo::Ptr modInfo) +{ + m_ModInfo = modInfo; + if (QDir(modInfo->absolutePath()).exists()) { + m_FileSystemModel->setRootPath(modInfo->absolutePath()); + } else { + throw MyException( + tr("mod not found: %1").arg(qUtf8Printable(modInfo->absolutePath()))); + } +} + +bool OverwriteInfoDialog::recursiveDelete(const QModelIndex& index) +{ + for (int childRow = 0; childRow < m_FileSystemModel->rowCount(index); ++childRow) { + QModelIndex childIndex = m_FileSystemModel->index(childRow, 0, index); + if (m_FileSystemModel->isDir(childIndex)) { + if (!recursiveDelete(childIndex)) { + log::error("failed to delete {}", m_FileSystemModel->fileName(childIndex)); + return false; + } + } else { + if (!m_FileSystemModel->remove(childIndex)) { + log::error("failed to delete {}", m_FileSystemModel->fileName(childIndex)); + return false; + } + } + } + if (!m_FileSystemModel->remove(index)) { + log::error("failed to delete {}", m_FileSystemModel->fileName(index)); + return false; + } + return true; +} + +void OverwriteInfoDialog::deleteFile(const QModelIndex& index) +{ + + bool res = m_FileSystemModel->isDir(index) ? recursiveDelete(index) + : m_FileSystemModel->remove(index); + if (!res) { + QString fileName = m_FileSystemModel->fileName(index); + reportError(tr("Failed to delete \"%1\"").arg(fileName)); + } +} + +void OverwriteInfoDialog::delete_activated() +{ + if (ui->filesView->hasFocus()) { + QItemSelectionModel* selection = ui->filesView->selectionModel(); + + if (selection->hasSelection() && selection->selectedRows().count() >= 1) { + auto root = m_FileSystemModel->rootDirectory(); + + if (selection->selectedRows().count() == 0) { + return; + } else if (selection->selectedRows().count() == 1) { + for (auto modDir : m_Organizer.managedGame()->getModMappings().keys()) { + if (root.absoluteFilePath(modDir).compare( + m_FileSystemModel->filePath(selection->selectedRows().at(0)), + Qt::CaseInsensitive) == 0) { + return; + } + } + + QString fileName = m_FileSystemModel->fileName(selection->selectedRows().at(0)); + if (QMessageBox::question( + this, tr("Confirm"), + tr("Are you sure you want to delete \"%1\"?").arg(fileName), + QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { + return; + } + } else { + if (QMessageBox::question( + this, tr("Confirm"), + tr("Are you sure you want to delete the selected files?"), + QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { + return; + } + } + + foreach (QModelIndex index, selection->selectedRows()) { + for (auto modDir : m_Organizer.managedGame()->getModMappings().keys()) { + if (root.absoluteFilePath(modDir).compare(m_FileSystemModel->filePath(index), + Qt::CaseInsensitive) == 0) { + return; + } + } + deleteFile(index); + } + } + } +} + +void OverwriteInfoDialog::deleteTriggered() +{ + auto root = m_FileSystemModel->rootDirectory(); + if (m_FileSelection.count() == 0) { + return; + } else if (m_FileSelection.count() == 1) { + for (auto modDir : m_Organizer.managedGame()->getModMappings().keys()) { + if (root.absoluteFilePath(modDir).compare( + m_FileSystemModel->filePath(m_FileSelection.at(0)), + Qt::CaseInsensitive) == 0) { + return; + } + } + QString fileName = m_FileSystemModel->fileName(m_FileSelection.at(0)); + if (QMessageBox::question( + this, tr("Confirm"), + tr("Are you sure you want to delete \"%1\"?").arg(fileName), + QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { + return; + } + } else { + if (QMessageBox::question(this, tr("Confirm"), + tr("Are you sure you want to delete the selected files?"), + QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { + return; + } + } + + foreach (QModelIndex index, m_FileSelection) { + for (auto modDir : m_Organizer.managedGame()->getModMappings().keys()) { + if (root.absoluteFilePath(modDir).compare(m_FileSystemModel->filePath(index), + Qt::CaseInsensitive) == 0) { + return; + } + } + deleteFile(index); + } +} + +void OverwriteInfoDialog::renameTriggered() +{ + auto root = m_FileSystemModel->rootDirectory(); + QModelIndex selection = m_FileSelection.at(0); + QModelIndex index = selection.sibling(selection.row(), 0); + if (!index.isValid() || m_FileSystemModel->isReadOnly()) { + return; + } + for (auto modDir : m_Organizer.managedGame()->getModMappings().keys()) { + if (root.absoluteFilePath(modDir).compare(m_FileSystemModel->filePath(selection), + Qt::CaseInsensitive) == 0) { + return; + } + } + ui->filesView->edit(index); +} + +void OverwriteInfoDialog::openFile(const QModelIndex& index) +{ + shell::Open(m_FileSystemModel->filePath(index)); +} + +void OverwriteInfoDialog::openTriggered() +{ + foreach (QModelIndex idx, m_FileSelection) { + openFile(idx); + } +} + +void OverwriteInfoDialog::createDirectoryTriggered() +{ + QModelIndex selection = m_FileSelection.at(0); + + QModelIndex index = + m_FileSystemModel->isDir(selection) ? selection : selection.parent(); + index = index.sibling(index.row(), 0); + + QString name = tr("New Folder"); + QString path = m_FileSystemModel->filePath(index).append("/"); + + QModelIndex existingIndex = m_FileSystemModel->index(path + name); + int suffix = 1; + while (existingIndex.isValid()) { + name = tr("New Folder") + QString::number(suffix++); + existingIndex = m_FileSystemModel->index(path + name); + } + + QModelIndex newIndex = m_FileSystemModel->mkdir(index, name); + if (!newIndex.isValid()) { + reportError(tr("Failed to create \"%1\"").arg(name)); + return; + } + + ui->filesView->setCurrentIndex(newIndex); + ui->filesView->edit(newIndex); +} + +void OverwriteInfoDialog::on_explorerButton_clicked() +{ + shell::Explore(m_ModInfo->absolutePath()); +} + +void OverwriteInfoDialog::on_filesView_customContextMenuRequested(const QPoint& pos) +{ + QItemSelectionModel* selectionModel = ui->filesView->selectionModel(); + m_FileSelection = selectionModel->selectedRows(0); + + QMenu menu(ui->filesView); + + menu.addAction(m_NewFolderAction); + + bool hasFiles = false; + + foreach (QModelIndex idx, m_FileSelection) { + if (m_FileSystemModel->fileInfo(idx).isFile()) { + hasFiles = true; + break; + } + } + + if (selectionModel->hasSelection()) { + if (hasFiles) { + menu.addAction(m_OpenAction); + } + menu.addAction(m_RenameAction); + menu.addAction(m_DeleteAction); + } else { + m_FileSelection.clear(); + m_FileSelection.append(m_FileSystemModel->index(m_FileSystemModel->rootPath(), 0)); + } + + menu.exec(ui->filesView->viewport()->mapToGlobal(pos)); +} diff --git a/src/src/overwriteinfodialog.h b/src/src/overwriteinfodialog.h new file mode 100644 index 0000000..e609802 --- /dev/null +++ b/src/src/overwriteinfodialog.h @@ -0,0 +1,150 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef OVERWRITEINFODIALOG_H +#define OVERWRITEINFODIALOG_H + +#include "modinfo.h" +#include "modlistdropinfo.h" +#include "organizercore.h" +#include +#include + +namespace Ui +{ +class OverwriteInfoDialog; +} + +class OverwriteFileSystemModel : public QFileSystemModel +{ + Q_OBJECT; + +public: + OverwriteFileSystemModel(QObject* parent, OrganizerCore& organizer) + : QFileSystemModel(parent), m_Organizer(organizer), m_RegularColumnCount(0) + {} + + virtual int columnCount(const QModelIndex& parent) const + { + m_RegularColumnCount = QFileSystemModel::columnCount(parent); + return m_RegularColumnCount; + } + + virtual QVariant headerData(int section, Qt::Orientation orientation, int role) const + { + if ((orientation == Qt::Horizontal) && (section >= m_RegularColumnCount)) { + if (role == Qt::DisplayRole) { + return tr("Overwrites"); + } else { + return QVariant(); + } + } else { + return QFileSystemModel::headerData(section, orientation, role); + } + } + + virtual QVariant data(const QModelIndex& index, int role) const + { + if (index.column() == m_RegularColumnCount + 0) { + if (role == Qt::DisplayRole) { + return tr("not implemented"); + } else { + return QVariant(); + } + } else { + return QFileSystemModel::data(index, role); + } + } + + virtual bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, + int column, const QModelIndex& parent) + { + ModListDropInfo dropInfo(data, m_Organizer); + if (dropInfo.isLocalFileDrop()) { + for (auto entry : dropInfo.localUrls()) { + QFileInfo sourceInfo(entry.url.toLocalFile()); + if (sourceInfo.isDir() && + m_Organizer.managedGame()->getModMappings().keys().contains( + entry.relativePath, Qt::CaseInsensitive)) { + return false; + } + } + } + return QFileSystemModel::dropMimeData(data, action, row, column, parent); + } + +private: + mutable int m_RegularColumnCount; + + OrganizerCore& m_Organizer; +}; + +class OverwriteInfoDialog : public QDialog +{ + Q_OBJECT + +public: + explicit OverwriteInfoDialog(ModInfo::Ptr modInfo, OrganizerCore& organizer, + QWidget* parent = 0); + ~OverwriteInfoDialog(); + + ModInfo::Ptr modInfo() const { return m_ModInfo; } + + // saves geometry + // + void done(int r) override; + + void setModInfo(ModInfo::Ptr modInfo); + +protected: + // restores geometry + // + void showEvent(QShowEvent* e) override; + +private: + void openFile(const QModelIndex& index); + bool recursiveDelete(const QModelIndex& index); + void deleteFile(const QModelIndex& index); + +private slots: + + void delete_activated(); + + void deleteTriggered(); + void renameTriggered(); + void openTriggered(); + void createDirectoryTriggered(); + + void on_explorerButton_clicked(); + void on_filesView_customContextMenuRequested(const QPoint& pos); + +private: + Ui::OverwriteInfoDialog* ui; + QFileSystemModel* m_FileSystemModel; + QModelIndexList m_FileSelection; + QAction* m_DeleteAction; + QAction* m_RenameAction; + QAction* m_OpenAction; + QAction* m_NewFolderAction; + + ModInfo::Ptr m_ModInfo; + OrganizerCore& m_Organizer; +}; + +#endif // OVERWRITEINFODIALOG_H diff --git a/src/src/overwriteinfodialog.ui b/src/src/overwriteinfodialog.ui new file mode 100644 index 0000000..8a09ce5 --- /dev/null +++ b/src/src/overwriteinfodialog.ui @@ -0,0 +1,100 @@ + + + OverwriteInfoDialog + + + + 0 + 0 + 710 + 418 + + + + Overwrite + + + + + + Open in Explorer + + + + + + + Qt::CustomContextMenu + + + true + + + QAbstractItemView::DragDrop + + + Qt::MoveAction + + + QAbstractItemView::ExtendedSelection + + + true + + + + + + + You can use drag&drop to move files and directories to regular mods. + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + + + buttonBox + accepted() + OverwriteInfoDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + OverwriteInfoDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/src/pch.cpp b/src/src/pch.cpp new file mode 100644 index 0000000..9e6b2e0 --- /dev/null +++ b/src/src/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/src/pch.h b/src/src/pch.h new file mode 100644 index 0000000..f0a1776 --- /dev/null +++ b/src/src/pch.h @@ -0,0 +1,282 @@ +// std +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +// keep this header separated to avoid ordering issue since this must be +// included before DbgHelp.h +#include + +// windows +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#else +// linux +#include +#include +#include +#include +#include +#include +#include +#include +#include "shared/windows_compat.h" +#endif + +// boost +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// qt +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef MO2_WEBENGINE +#include +#include +#include +#include +#include +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +// QtTest should only be included in test targets, not the main organizer +// #include diff --git a/src/src/persistentcookiejar.cpp b/src/src/persistentcookiejar.cpp new file mode 100644 index 0000000..110b53b --- /dev/null +++ b/src/src/persistentcookiejar.cpp @@ -0,0 +1,78 @@ +#include "persistentcookiejar.h" +#include +#include +#include +#include + +using namespace MOBase; + +PersistentCookieJar::PersistentCookieJar(const QString& fileName, QObject* parent) + : QNetworkCookieJar(parent), m_FileName(fileName) +{ + restore(); +} + +PersistentCookieJar::~PersistentCookieJar() +{ + log::debug("save {}", m_FileName); + save(); +} + +void PersistentCookieJar::clear() +{ + for (const QNetworkCookie& cookie : allCookies()) { + deleteCookie(cookie); + } +} + +void PersistentCookieJar::save() +{ + QTemporaryFile file; + if (!file.open()) { + log::error("failed to save cookies: couldn't create temporary file"); + return; + } + QDataStream data(&file); + + QList cookies = allCookies(); + data << static_cast(cookies.size()); + + for (const QNetworkCookie& cookie : allCookies()) { + data << cookie.toRawForm(); + } + + { + QFile oldCookies(m_FileName); + if (oldCookies.exists()) { + if (!oldCookies.remove()) { + log::error("failed to save cookies: failed to remove {}", m_FileName); + return; + } + } // if it doesn't exists that's fine + } + + if (!file.copy(m_FileName)) { + log::error("failed to save cookies: failed to write {}", m_FileName); + } +} + +void PersistentCookieJar::restore() +{ + QFile file(m_FileName); + if (!file.open(QIODevice::ReadOnly)) { + // not necessarily a problem, the file may just not exist (yet) + return; + } + + QList allCookies; + + QDataStream data(&file); + quint32 count; + data >> count; + for (quint32 i = 0; i < count; ++i) { + QByteArray cookieRaw; + data >> cookieRaw; + allCookies.append(QNetworkCookie::parseCookies(cookieRaw)); + } + setAllCookies(allCookies); +} diff --git a/src/src/persistentcookiejar.h b/src/src/persistentcookiejar.h new file mode 100644 index 0000000..f545211 --- /dev/null +++ b/src/src/persistentcookiejar.h @@ -0,0 +1,26 @@ +#ifndef PERSISTENTCOOKIEJAR_H +#define PERSISTENTCOOKIEJAR_H + +#include + +class PersistentCookieJar : public QNetworkCookieJar +{ + + Q_OBJECT + +public: + PersistentCookieJar(const QString& fileName, QObject* parent = 0); + virtual ~PersistentCookieJar(); + + void clear(); + +private: + void save(); + + void restore(); + +private: + QString m_FileName; +}; + +#endif // PERSISTENTCOOKIEJAR_H diff --git a/src/src/plugincontainer.cpp b/src/src/plugincontainer.cpp new file mode 100644 index 0000000..ed50470 --- /dev/null +++ b/src/src/plugincontainer.cpp @@ -0,0 +1,1264 @@ +#include "plugincontainer.h" +#include "iuserinterface.h" +#include "organizercore.h" +#include "organizerproxy.h" +#include "report.h" +#include "shared/appconfig.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; +using namespace MOShared; + +namespace bf = boost::fusion; + +// Welcome to the wonderful world of MO2 plugin management! +// +// We'll start by the C++ side. +// +// There are 9 types of MO2 plugins, two of which cannot be standalone: IPluginDiagnose +// and IPluginFileMapper. This means that you can have a class implementing IPluginGame, +// IPluginDiagnose and IPluginFileMapper. It is not possible for a class to implement +// two full plugin types (e.g. IPluginPreview and IPluginTool). +// +// Plugins are fetch as QObject initially and must be "qobject-casted" to the right +// type. +// +// Plugins are stored in the PluginContainer class in various C++ containers: there is a +// vector that stores all the plugin as QObject, multiple vectors that stores the plugin +// of each types, a map to find IPlugin object from their names or from IPluginDiagnose +// or IFileMapper (since these do not inherit IPlugin, they cannot be downcasted). +// +// Requirements for plugins are stored in m_Requirements: +// - IPluginGame cannot be enabled by user. A game plugin is considered enable only if +// it is +// the one corresponding to the currently managed games. +// - If a plugin has a master plugin (IPlugin::master()), it cannot be enabled/disabled +// by users, +// and will follow the enabled/disabled state of its parent. +// - Each plugin has an "enabled" setting stored in persistence. If the setting does +// not exist, +// the plugin's enabledByDefault is used instead. +// - A plugin is considered disabled if the setting is false. +// - If the setting is true, a plugin is considered disabled if one of its +// requirements is not met. +// - Users cannot enable a plugin if one of its requirements is not met. +// +// Now let's move to the Proxy side... Or the as of now, the Python side. +// +// Proxied plugins are much more annoying because they can implement all interfaces, and +// are given to MO2 as separate plugins... A Python class implementing IPluginGame and +// IPluginDiagnose will be seen by MO2 as two separate QObject, and they will all have +// the same name. +// +// When a proxied plugin is registered, a few things must be taken care of: +// - There can only be one plugin mapped to a name in the PluginContainer class, so we +// keep the +// plugin corresponding to the most relevant class (see PluginTypeOrder), e.g. if the +// class inherits both IPluginGame and IPluginFileMapper, we map the name to the C++ +// QObject corresponding to the IPluginGame. +// - When a proxied plugin implements multiple interfaces, the IPlugin corresponding to +// the most +// important interface is set as the parent (hidden) of the other IPlugin through +// PluginRequirements. This way, the plugin are managed together (enabled/disabled +// state). The "fake" children plugins will not be returned by +// PluginRequirements::children(). +// - Since each interface corresponds to a different QObject, we need to take care not +// to call +// IPlugin::init() on each QObject, but only on the first one. +// +// All the proxied plugins are linked to the proxy plugin by PluginRequirements. If the +// proxy plugin is disabled, the proxied plugins are not even loaded so not visible in +// the plugin management tab. + +template +struct PluginTypeName; + +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Plugin"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Diagnose"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Game"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Installer"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Mod Page"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Preview"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Tool"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("Proxy"); } +}; +template <> +struct PluginTypeName +{ + static QString value() { return QT_TR_NOOP("File Mapper"); } +}; + +QStringList PluginContainer::pluginInterfaces() +{ + // Find all the names: + QStringList names; + boost::mp11::mp_for_each([&names](const auto* p) { + using plugin_type = std::decay_t; + auto name = PluginTypeName::value(); + if (!name.isEmpty()) { + names.append(name); + } + }); + + return names; +} + +// PluginRequirementProxy + +const std::set PluginRequirements::s_CorePlugins{"INI Bakery"}; + +PluginRequirements::PluginRequirements(PluginContainer* pluginContainer, + MOBase::IPlugin* plugin, OrganizerProxy* proxy, + MOBase::IPluginProxy* pluginProxy) + : m_PluginContainer(pluginContainer), m_Plugin(plugin), m_PluginProxy(pluginProxy), + m_Master(nullptr), m_Organizer(proxy) +{ + // There are a lots of things we cannot set here (e.g. m_Master) because we do not + // know the order plugins are loaded. +} + +void PluginRequirements::fetchRequirements() +{ + m_Requirements = m_Plugin->requirements(); +} + +IPluginProxy* PluginRequirements::proxy() const +{ + return m_PluginProxy; +} + +std::vector PluginRequirements::proxied() const +{ + std::vector children; + if (dynamic_cast(m_Plugin)) { + for (auto* obj : m_PluginContainer->plugins()) { + auto* plugin = qobject_cast(obj); + if (plugin && m_PluginContainer->requirements(plugin).proxy() == m_Plugin) { + children.push_back(plugin); + } + } + } + return children; +} + +IPlugin* PluginRequirements::master() const +{ + // If we have a m_Master, it was forced and thus override the default master(). + if (m_Master) { + return m_Master; + } + + if (m_Plugin->master().isEmpty()) { + return nullptr; + } + + return m_PluginContainer->plugin(m_Plugin->master()); +} + +void PluginRequirements::setMaster(IPlugin* master) +{ + m_Master = master; +} + +std::vector PluginRequirements::children() const +{ + std::vector children; + for (auto* obj : m_PluginContainer->plugins()) { + auto* plugin = qobject_cast(obj); + + // Not checking master() but requirements().master() due to "hidden" + // masters. + // If the master has the same name as the plugin, this is a "hidden" + // master, we do not add it here. + if (plugin && m_PluginContainer->requirements(plugin).master() == m_Plugin && + plugin->name() != m_Plugin->name()) { + children.push_back(plugin); + } + } + return children; +} + +std::vector PluginRequirements::problems() const +{ + std::vector result; + for (auto& requirement : m_Requirements) { + if (auto p = requirement->check(m_Organizer)) { + result.push_back(*p); + } + } + return result; +} + +bool PluginRequirements::canEnable() const +{ + return problems().empty(); +} + +bool PluginRequirements::isCorePlugin() const +{ + // Let's consider game plugins as "core": + if (m_PluginContainer->implementInterface(m_Plugin)) { + return true; + } + + return s_CorePlugins.contains(m_Plugin->name()); +} + +bool PluginRequirements::hasRequirements() const +{ + return !m_Requirements.empty(); +} + +QStringList PluginRequirements::requiredGames() const +{ + // We look for a "GameDependencyRequirement" - There can be only one since otherwise + // it'd mean that the plugin requires two games at once. + for (auto& requirement : m_Requirements) { + if (auto* gdep = + dynamic_cast(requirement.get())) { + return gdep->gameNames(); + } + } + + return {}; +} + +std::vector PluginRequirements::requiredFor() const +{ + std::vector required; + std::set visited; + requiredFor(required, visited); + return required; +} + +void PluginRequirements::requiredFor(std::vector& required, + std::set& visited) const +{ + // Handle cyclic dependencies. + if (visited.contains(m_Plugin)) { + return; + } + visited.insert(m_Plugin); + + for (auto& [plugin, requirements] : m_PluginContainer->m_Requirements) { + + // If the plugin is not enabled, discard: + if (!m_PluginContainer->isEnabled(plugin)) { + continue; + } + + // Check the requirements: + for (auto& requirement : requirements.m_Requirements) { + + // We check for plugin dependency. Game dependency are not checked this way. + if (auto* pdep = + dynamic_cast(requirement.get())) { + + // Check if at least one of the plugin in the requirements is enabled (except + // this one): + bool oneEnabled = false; + for (auto& pluginName : pdep->pluginNames()) { + if (pluginName != m_Plugin->name() && + m_PluginContainer->isEnabled(pluginName)) { + oneEnabled = true; + break; + } + } + + // No plugin enabled found, so the plugin requires this plugin: + if (!oneEnabled) { + required.push_back(plugin); + requirements.requiredFor(required, visited); + break; + } + } + } + } +} + +// PluginContainer + +PluginContainer::PluginContainer(OrganizerCore* organizer) + : m_Organizer(organizer), m_UserInterface(nullptr), + m_GameFeatures(std::make_unique(organizer, this)), + m_PreviewGenerator(*this) +{} + +PluginContainer::~PluginContainer() +{ + m_Organizer = nullptr; + unloadPlugins(); +} + +void PluginContainer::startPlugins(IUserInterface* userInterface) +{ + m_UserInterface = userInterface; + startPluginsImpl(plugins()); +} + +QStringList PluginContainer::implementedInterfaces(IPlugin* plugin) const +{ + // We need a QObject to be able to qobject_cast<> to the plugin types: + QObject* oPlugin = as_qobject(plugin); + + if (!oPlugin) { + return {}; + } + + return implementedInterfaces(oPlugin); +} + +QStringList PluginContainer::implementedInterfaces(QObject* oPlugin) const +{ + // Find all the names: + QStringList names; + boost::mp11::mp_for_each([oPlugin, &names](const auto* p) { + using plugin_type = std::decay_t; + if (qobject_cast(oPlugin)) { + auto name = PluginTypeName::value(); + if (!name.isEmpty()) { + names.append(name); + } + } + }); + + // If the plugin implements at least one interface other than IPlugin, remove IPlugin: + if (names.size() > 1) { + names.removeAll(PluginTypeName::value()); + } + + return names; +} + +QString PluginContainer::topImplementedInterface(IPlugin* plugin) const +{ + auto interfaces = implementedInterfaces(plugin); + return interfaces.isEmpty() ? "" : interfaces[0]; +} + +bool PluginContainer::isBetterInterface(QObject* lhs, QObject* rhs) const +{ + int count = 0, lhsIdx = -1, rhsIdx = -1; + boost::mp11::mp_for_each([&](const auto* p) { + using plugin_type = std::decay_t; + if (lhsIdx < 0 && qobject_cast(lhs)) { + lhsIdx = count; + } + if (rhsIdx < 0 && qobject_cast(rhs)) { + rhsIdx = count; + } + ++count; + }); + return lhsIdx < rhsIdx; +} + +QStringList PluginContainer::pluginFileNames() const +{ + QStringList result; + for (QPluginLoader* loader : m_PluginLoaders) { + result.append(loader->fileName()); + } + std::vector proxyList = bf::at_key(m_Plugins); + for (IPluginProxy* proxy : proxyList) { + QStringList proxiedPlugins = + proxy->pluginList(AppConfig::basePath() + "/" + + ToQString(AppConfig::pluginPath())); + result.append(proxiedPlugins); + } + return result; +} + +QObject* PluginContainer::as_qobject(MOBase::IPlugin* plugin) const +{ + // Find the correspond QObject - Can this be done safely with a cast? + auto& objects = bf::at_key(m_Plugins); + auto it = + std::find_if(std::begin(objects), std::end(objects), [plugin](QObject* obj) { + return qobject_cast(obj) == plugin; + }); + + if (it == std::end(objects)) { + return nullptr; + } + + return *it; +} + +bool PluginContainer::initPlugin(IPlugin* plugin, IPluginProxy* pluginProxy, + bool skipInit) +{ + // when MO has no instance loaded, init() is not called on plugins, except + // for proxy plugins, where init() is called with a null IOrganizer + // + // after proxies are initialized, instantiate() is called for all the plugins + // they've discovered, but as for regular plugins, init() won't be + // called on them if m_OrganizerCore is null + + if (plugin == nullptr) { + return false; + } + + OrganizerProxy* proxy = nullptr; + if (m_Organizer) { + proxy = new OrganizerProxy(m_Organizer, this, plugin); + proxy->setParent(as_qobject(plugin)); + } + + // Check if it is a proxy plugin: + bool isProxy = dynamic_cast(plugin); + + auto [it, bl] = m_Requirements.emplace( + plugin, PluginRequirements(this, plugin, proxy, pluginProxy)); + + if (!m_Organizer && !isProxy) { + return true; + } + + if (skipInit) { + return true; + } + + if (!plugin->init(proxy)) { + log::warn("plugin failed to initialize"); + return false; + } + + // Update requirements: + it->second.fetchRequirements(); + + return true; +} + +void PluginContainer::registerGame(IPluginGame* game) +{ + m_SupportedGames.insert({game->gameName(), game}); +} + +void PluginContainer::unregisterGame(MOBase::IPluginGame* game) +{ + m_SupportedGames.erase(game->gameName()); +} + +IPlugin* PluginContainer::registerPlugin(QObject* plugin, const QString& filepath, + MOBase::IPluginProxy* pluginProxy) +{ + + // generic treatment for all plugins + IPlugin* pluginObj = qobject_cast(plugin); + if (pluginObj == nullptr) { + log::debug("PluginContainer::registerPlugin() called with a non IPlugin QObject."); + return nullptr; + } + + // If we already a plugin with this name: + bool skipInit = false; + auto& mapNames = bf::at_key(m_AccessPlugins); + if (mapNames.contains(pluginObj->name())) { + + IPlugin* other = mapNames[pluginObj->name()]; + + // If both plugins are from the same proxy and the same file, this is usually + // ok (in theory some one could write two different classes from the same Python + // file/module): + if (pluginProxy && m_Requirements.at(other).proxy() == pluginProxy && + this->filepath(other) == QDir::cleanPath(filepath)) { + + // Plugin has already been initialized: + skipInit = true; + + if (isBetterInterface(plugin, as_qobject(other))) { + log::debug( + "replacing plugin '{}' with interfaces [{}] by one with interfaces [{}]", + pluginObj->name(), implementedInterfaces(other).join(", "), + implementedInterfaces(plugin).join(", ")); + bf::at_key(m_AccessPlugins)[pluginObj->name()] = pluginObj; + } + } else { + log::warn("Trying to register two plugins with the name '{}' (from {} and {}), " + "the second one will not be registered.", + pluginObj->name(), this->filepath(other), QDir::cleanPath(filepath)); + return nullptr; + } + } else { + bf::at_key(m_AccessPlugins)[pluginObj->name()] = pluginObj; + } + + // Storing the original QObject* is a bit of a hack as I couldn't figure out any + // way to cast directly between IPlugin* and IPluginDiagnose* + bf::at_key(m_Plugins).push_back(plugin); + + plugin->setProperty("filepath", QDir::cleanPath(filepath)); + plugin->setParent(this); + + if (m_Organizer) { + m_Organizer->settings().plugins().registerPlugin(pluginObj); + } + + { // diagnosis plugin + IPluginDiagnose* diagnose = qobject_cast(plugin); + if (diagnose != nullptr) { + bf::at_key(m_Plugins).push_back(diagnose); + bf::at_key(m_AccessPlugins)[diagnose] = pluginObj; + diagnose->onInvalidated([&]() { + emit diagnosisUpdate(); + }); + } + } + { // file mapper plugin + IPluginFileMapper* mapper = qobject_cast(plugin); + if (mapper != nullptr) { + bf::at_key(m_Plugins).push_back(mapper); + bf::at_key(m_AccessPlugins)[mapper] = pluginObj; + } + } + { // mod page plugin + IPluginModPage* modPage = qobject_cast(plugin); + if (initPlugin(modPage, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(modPage); + emit pluginRegistered(modPage); + return modPage; + } + } + { // game plugin + IPluginGame* game = qobject_cast(plugin); + if (game) { + game->detectGame(); + if (initPlugin(game, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(game); + registerGame(game); + emit pluginRegistered(game); + return game; + } + } + } + { // tool plugins + IPluginTool* tool = qobject_cast(plugin); + if (initPlugin(tool, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(tool); + emit pluginRegistered(tool); + return tool; + } + } + { // installer plugins + IPluginInstaller* installer = qobject_cast(plugin); + if (initPlugin(installer, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(installer); + if (m_Organizer) { + installer->setInstallationManager(m_Organizer->installationManager()); + } + emit pluginRegistered(installer); + return installer; + } + } + { // preview plugins + IPluginPreview* preview = qobject_cast(plugin); + if (initPlugin(preview, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(preview); + return preview; + } + } + { // proxy plugins + IPluginProxy* proxy = qobject_cast(plugin); + if (initPlugin(proxy, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(proxy); + emit pluginRegistered(proxy); + + QStringList filepaths = + proxy->pluginList(QCoreApplication::applicationDirPath() + "/" + + ToQString(AppConfig::pluginPath())); + for (const QString& filepath : filepaths) { + loadProxied(filepath, proxy); + } + return proxy; + } + } + + { // dummy plugins + // only initialize these, no processing otherwise + IPlugin* dummy = qobject_cast(plugin); + if (initPlugin(dummy, pluginProxy, skipInit)) { + bf::at_key(m_Plugins).push_back(dummy); + emit pluginRegistered(dummy); + return dummy; + } + } + + return nullptr; +} + +IPluginGame* PluginContainer::managedGame() const +{ + // TODO: This const_cast is safe but ugly. Most methods require a IPlugin*, so + // returning a const-version if painful. This should be fixed by making methods accept + // a const IPlugin* instead, but there are a few tricks with qobject_cast and const. + return m_Organizer ? const_cast(m_Organizer->managedGame()) : nullptr; +} + +bool PluginContainer::isEnabled(IPlugin* plugin) const +{ + // Check if it's a game plugin: + if (implementInterface(plugin)) { + return plugin == m_Organizer->managedGame(); + } + + // Check the master, if any: + auto& requirements = m_Requirements.at(plugin); + + if (requirements.master()) { + return isEnabled(requirements.master()); + } + + // Check if the plugin is enabled: + if (!m_Organizer->persistent(plugin->name(), "enabled", plugin->enabledByDefault()) + .toBool()) { + return false; + } + + // Check the requirements: + return m_Requirements.at(plugin).canEnable(); +} + +void PluginContainer::setEnabled(MOBase::IPlugin* plugin, bool enable, + bool dependencies) +{ + // If required, disable dependencies: + if (!enable && dependencies) { + for (auto* p : requirements(plugin).requiredFor()) { + // No need to "recurse" here since requiredFor already does it. + setEnabled(p, false, false); + } + } + + // Always disable/enable child plugins: + for (auto* p : requirements(plugin).children()) { + // "Child" plugin should have no dependencies. + setEnabled(p, enable, false); + } + + m_Organizer->setPersistent(plugin->name(), "enabled", enable, true); + + if (enable) { + emit pluginEnabled(plugin); + } else { + emit pluginDisabled(plugin); + } +} + +MOBase::IPlugin* PluginContainer::plugin(QString const& pluginName) const +{ + auto& map = bf::at_key(m_AccessPlugins); + auto it = map.find(pluginName); + if (it == std::end(map)) { + return nullptr; + } + return it->second; +} + +MOBase::IPlugin* PluginContainer::plugin(MOBase::IPluginDiagnose* diagnose) const +{ + auto& map = bf::at_key(m_AccessPlugins); + auto it = map.find(diagnose); + if (it == std::end(map)) { + return nullptr; + } + return it->second; +} + +MOBase::IPlugin* PluginContainer::plugin(MOBase::IPluginFileMapper* mapper) const +{ + auto& map = bf::at_key(m_AccessPlugins); + auto it = map.find(mapper); + if (it == std::end(map)) { + return nullptr; + } + return it->second; +} + +bool PluginContainer::isEnabled(QString const& pluginName) const +{ + IPlugin* p = plugin(pluginName); + return p ? isEnabled(p) : false; +} +bool PluginContainer::isEnabled(MOBase::IPluginDiagnose* diagnose) const +{ + IPlugin* p = plugin(diagnose); + return p ? isEnabled(p) : false; +} +bool PluginContainer::isEnabled(MOBase::IPluginFileMapper* mapper) const +{ + IPlugin* p = plugin(mapper); + return p ? isEnabled(p) : false; +} + +const PluginRequirements& PluginContainer::requirements(IPlugin* plugin) const +{ + return m_Requirements.at(plugin); +} + +OrganizerProxy* PluginContainer::organizerProxy(MOBase::IPlugin* plugin) const +{ + return requirements(plugin).m_Organizer; +} + +MOBase::IPluginProxy* PluginContainer::pluginProxy(MOBase::IPlugin* plugin) const +{ + return requirements(plugin).proxy(); +} + +QString PluginContainer::filepath(MOBase::IPlugin* plugin) const +{ + return as_qobject(plugin)->property("filepath").toString(); +} + +IPluginGame* PluginContainer::game(const QString& name) const +{ + auto iter = m_SupportedGames.find(name); + if (iter != m_SupportedGames.end()) { + return iter->second; + } else { + return nullptr; + } +} + +void PluginContainer::startPluginsImpl(const std::vector& plugins) const +{ + // setUserInterface() + if (m_UserInterface) { + for (auto* plugin : plugins) { + if (auto* proxy = qobject_cast(plugin)) { + proxy->setParentWidget(m_UserInterface->mainWindow()); + } + if (auto* modPage = qobject_cast(plugin)) { + modPage->setParentWidget(m_UserInterface->mainWindow()); + } + if (auto* tool = qobject_cast(plugin)) { + tool->setParentWidget(m_UserInterface->mainWindow()); + } + if (auto* installer = qobject_cast(plugin)) { + installer->setParentWidget(m_UserInterface->mainWindow()); + } + } + } + + // Trigger initial callbacks, e.g. onUserInterfaceInitialized and onProfileChanged. + if (m_Organizer) { + for (auto* object : plugins) { + auto* plugin = qobject_cast(object); + auto* oproxy = organizerProxy(plugin); + oproxy->connectSignals(); + oproxy->m_ProfileChanged(nullptr, m_Organizer->currentProfile().get()); + + if (m_UserInterface) { + oproxy->m_UserInterfaceInitialized(m_UserInterface->mainWindow()); + } + } + } +} + +std::vector PluginContainer::loadProxied(const QString& filepath, + IPluginProxy* proxy) +{ + std::vector proxiedPlugins; + + try { + // We get a list of matching plugins as proxies can return multiple plugins + // per file and do not have a good way of supporting multiple inheritance. + QList matchingPlugins = proxy->load(filepath); + + // We are going to group plugin by names and "fix" them later: + std::map> proxiedByNames; + + for (QObject* proxiedPlugin : matchingPlugins) { + if (proxiedPlugin != nullptr) { + + if (IPlugin* proxied = registerPlugin(proxiedPlugin, filepath, proxy); + proxied) { + log::debug("loaded plugin '{}@{}' from '{}' - [{}]", proxied->name(), + proxied->version().canonicalString(), + QFileInfo(filepath).fileName(), + implementedInterfaces(proxied).join(", ")); + + // Store the plugin for later: + proxiedPlugins.push_back(proxiedPlugin); + proxiedByNames[proxied->name()].push_back(proxied); + } else { + log::warn("plugin \"{}\" failed to load. If this plugin is for an older " + "version of MO " + "you have to update it or delete it if no update exists.", + filepath); + } + } + } + + // Fake masters: + for (auto& [name, proxiedPlugins] : proxiedByNames) { + if (proxiedPlugins.size() > 1) { + auto it = std::min_element(std::begin(proxiedPlugins), std::end(proxiedPlugins), + [&](auto const& lhs, auto const& rhs) { + return isBetterInterface(as_qobject(lhs), + as_qobject(rhs)); + }); + + for (auto& proxiedPlugin : proxiedPlugins) { + if (proxiedPlugin != *it) { + m_Requirements.at(proxiedPlugin).setMaster(*it); + } + } + } + } + } catch (const std::exception& e) { + reportError( + QObject::tr("failed to initialize plugin %1: %2").arg(filepath).arg(e.what())); + } + + return proxiedPlugins; +} + +QObject* PluginContainer::loadQtPlugin(const QString& filepath) +{ + std::unique_ptr pluginLoader(new QPluginLoader(filepath, this)); + if (pluginLoader->instance() == nullptr) { + m_FailedPlugins.push_back(filepath); + log::error("failed to load plugin {}: {}", filepath, pluginLoader->errorString()); + } else { + QObject* object = pluginLoader->instance(); + if (IPlugin* plugin = registerPlugin(object, filepath, nullptr); plugin) { + log::debug("loaded plugin '{}@{}' from '{}' - [{}]", plugin->name(), + plugin->version().canonicalString(), QFileInfo(filepath).fileName(), + implementedInterfaces(plugin).join(", ")); + m_PluginLoaders.push_back(pluginLoader.release()); + return object; + } else { + m_FailedPlugins.push_back(filepath); + log::warn("plugin '{}' failed to load (may be outdated)", filepath); + } + } + return nullptr; +} + +std::optional PluginContainer::isQtPluginFolder(const QString& filepath) const +{ + + if (!QFileInfo(filepath).isDir()) { + return {}; + } + + QDirIterator iter(filepath, QDir::Files | QDir::NoDotAndDotDot); + while (iter.hasNext()) { + iter.next(); + const auto filePath = iter.filePath(); + + // not a library, skip + if (!QLibrary::isLibrary(filePath)) { + continue; + } + + // check if we have proper metadata - this does not load the plugin (metaData() + // should be very lightweight) + const QPluginLoader loader(filePath); + if (!loader.metaData().isEmpty()) { + return filePath; + } + } + + return {}; +} + +void PluginContainer::loadPlugin(QString const& filepath) +{ + std::vector plugins; + if (QFileInfo(filepath).isFile() && QLibrary::isLibrary(filepath)) { + QObject* plugin = loadQtPlugin(filepath); + if (plugin) { + plugins.push_back(plugin); + } + } else if (auto p = isQtPluginFolder(filepath)) { + QObject* plugin = loadQtPlugin(*p); + if (plugin) { + plugins.push_back(plugin); + } + } else { + // We need to check if this can be handled by a proxy. + for (auto* proxy : this->plugins()) { + auto filepaths = proxy->pluginList(QCoreApplication::applicationDirPath() + "/" + + ToQString(AppConfig::pluginPath())); + if (filepaths.contains(filepath)) { + plugins = loadProxied(filepath, proxy); + break; + } + } + } + + for (auto* plugin : plugins) { + emit pluginRegistered(qobject_cast(plugin)); + } + + startPluginsImpl(plugins); +} + +void PluginContainer::unloadPlugin(MOBase::IPlugin* plugin, QObject* object) +{ + if (auto* game = qobject_cast(object)) { + + if (game == managedGame()) { + throw Exception("cannot unload the plugin for the currently managed game"); + } + + unregisterGame(game); + } + + // We need to remove from the m_Plugins maps BEFORE unloading from the proxy + // otherwise the qobject_cast to check the plugin type will not work. + bf::for_each(m_Plugins, [object](auto& t) { + using type = typename std::decay_t::value_type; + + // We do not want to remove from QObject since we are iterating over them. + if constexpr (!std::is_same{}) { + auto itp = + std::find(t.second.begin(), t.second.end(), qobject_cast(object)); + if (itp != t.second.end()) { + t.second.erase(itp); + } + } + }); + + emit pluginUnregistered(plugin); + + // Remove from the members. + if (auto* diagnose = qobject_cast(object)) { + bf::at_key(m_AccessPlugins).erase(diagnose); + } + if (auto* mapper = qobject_cast(object)) { + bf::at_key(m_AccessPlugins).erase(mapper); + } + + auto& mapNames = bf::at_key(m_AccessPlugins); + if (mapNames.contains(plugin->name())) { + mapNames.erase(plugin->name()); + } + + m_Organizer->settings().plugins().unregisterPlugin(plugin); + + // Force disconnection of the signals from the proxies. This is a safety + // operations since those signals should be disconnected when the proxies + // are destroyed anyway. + organizerProxy(plugin)->disconnectSignals(); + + // Is this a proxied plugin? + auto* proxy = pluginProxy(plugin); + + if (proxy) { + proxy->unload(filepath(plugin)); + } else { + // We need to find the loader. + auto it = std::find_if(m_PluginLoaders.begin(), m_PluginLoaders.end(), + [object](auto* loader) { + return loader->instance() == object; + }); + + if (it != m_PluginLoaders.end()) { + if (!(*it)->unload()) { + log::error("failed to unload {}: {}", (*it)->fileName(), (*it)->errorString()); + } + delete *it; + m_PluginLoaders.erase(it); + } else { + log::error("loader for plugin {} does not exist, cannot unload", plugin->name()); + } + } + + object->deleteLater(); + + // Do this at the end. + m_Requirements.erase(plugin); +} + +void PluginContainer::unloadPlugin(QString const& filepath) +{ + // We need to find all the plugins from the given path and + // unload them: + QString cleanPath = QDir::cleanPath(filepath); + auto& objects = bf::at_key(m_Plugins); + for (auto it = objects.begin(); it != objects.end();) { + auto* plugin = qobject_cast(*it); + if (this->filepath(plugin) == filepath) { + unloadPlugin(plugin, *it); + it = objects.erase(it); + } else { + ++it; + } + } +} + +void PluginContainer::reloadPlugin(QString const& filepath) +{ + unloadPlugin(filepath); + loadPlugin(filepath); +} + +void PluginContainer::unloadPlugins() +{ + if (m_Organizer) { + // this will clear several structures that can hold on to pointers to + // plugins, as well as read the plugin blacklist from the ini file, which + // is used in loadPlugins() below to skip plugins + // + // note that the first thing loadPlugins() does is call unloadPlugins(), + // so this makes sure the blacklist is always available + m_Organizer->settings().plugins().clearPlugins(); + } + + bf::for_each(m_Plugins, [](auto& t) { + t.second.clear(); + }); + bf::for_each(m_AccessPlugins, [](auto& t) { + t.second.clear(); + }); + m_Requirements.clear(); + + while (!m_PluginLoaders.empty()) { + QPluginLoader* loader = m_PluginLoaders.back(); + m_PluginLoaders.pop_back(); + if ((loader != nullptr) && !loader->unload()) { + log::debug("failed to unload {}: {}", loader->fileName(), loader->errorString()); + } + delete loader; + } +} + +void PluginContainer::loadPlugins() +{ + TimeThis tt("PluginContainer::loadPlugins()"); + + unloadPlugins(); + + for (QObject* plugin : QPluginLoader::staticInstances()) { + registerPlugin(plugin, "", nullptr); + } + + QFile loadCheck; + QString skipPlugin; + + if (m_Organizer) { + loadCheck.setFileName(qApp->property("dataPath").toString() + + "/plugin_loadcheck.tmp"); + + if (loadCheck.exists() && loadCheck.open(QIODevice::ReadOnly)) { + // oh, there was a failed plugin load last time. Find out which plugin was loaded + // last + QString fileName; + while (!loadCheck.atEnd()) { + fileName = QString::fromUtf8(loadCheck.readLine().constData()).trimmed(); + } + + log::warn("loadcheck file found for plugin '{}'", fileName); + + MOBase::TaskDialog dlg; + + const auto Skip = QMessageBox::Ignore; + const auto Blacklist = QMessageBox::Cancel; + const auto Load = QMessageBox::Ok; + + const auto r = + dlg.title(tr("Plugin error")) + .main(tr("Mod Organizer failed to load the plugin '%1' last time it was " + "started.") + .arg(fileName)) + .content(tr( + "The plugin can be skipped for this session, blacklisted, " + "or loaded normally, in which case it might fail again. Blacklisted " + "plugins can be re-enabled later in the settings.")) + .icon(QMessageBox::Warning) + .button({tr("Skip this plugin"), Skip}) + .button({tr("Blacklist this plugin"), Blacklist}) + .button({tr("Load this plugin"), Load}) + .exec(); + + switch (r) { + case Skip: + log::warn("user wants to skip plugin '{}'", fileName); + skipPlugin = fileName; + break; + + case Blacklist: + log::warn("user wants to blacklist plugin '{}'", fileName); + m_Organizer->settings().plugins().addBlacklist(fileName); + break; + + case Load: + log::warn("user wants to load plugin '{}' anyway", fileName); + break; + } + + loadCheck.close(); + } + + loadCheck.open(QIODevice::WriteOnly); + } + + QString pluginPath = + AppConfig::basePath() + "/" + ToQString(AppConfig::pluginPath()); + log::debug("looking for plugins in {}", QDir::toNativeSeparators(pluginPath)); + + // Linux is case-sensitive; keep only the canonical Fallout NV plugin filename. + // Older builds may leave a stale lowercase artifact that causes duplicate + // registration warnings at startup. + { + const QString nvCanonical = pluginPath + "/libgame_falloutNV.so"; + const QString nvStale = pluginPath + "/libgame_falloutnv.so"; + if (QFile::exists(nvCanonical) && QFile::exists(nvStale)) { + if (QFile::remove(nvStale)) { + log::debug("removed stale plugin artifact '{}'", + QDir::toNativeSeparators(nvStale)); + } else { + log::warn("failed to remove stale plugin artifact '{}'", + QDir::toNativeSeparators(nvStale)); + } + } + } + + QDirIterator iter(pluginPath, QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); + + while (iter.hasNext()) { + iter.next(); + + if (skipPlugin == iter.fileName()) { + log::debug("plugin \"{}\" skipped for this session", iter.fileName()); + continue; + } + + if (m_Organizer) { + if (m_Organizer->settings().plugins().blacklisted(iter.fileName())) { + log::debug("plugin \"{}\" blacklisted", iter.fileName()); + continue; + } + } + + if (loadCheck.isOpen()) { + loadCheck.write(iter.fileName().toUtf8()); + loadCheck.write("\n"); + loadCheck.flush(); + } + + QString filepath = iter.filePath(); + if (QLibrary::isLibrary(filepath)) { + loadQtPlugin(filepath); + } else if (auto p = isQtPluginFolder(filepath)) { + loadQtPlugin(*p); + } + } + + if (skipPlugin.isEmpty()) { + // remove the load check file on success + if (loadCheck.isOpen()) { + loadCheck.remove(); + } + } else { + // remember the plugin for next time + if (loadCheck.isOpen()) { + loadCheck.close(); + } + + log::warn("user skipped plugin '{}', remembering in loadcheck", skipPlugin); + loadCheck.open(QIODevice::WriteOnly); + loadCheck.write(skipPlugin.toUtf8()); + loadCheck.write("\n"); + loadCheck.flush(); + } + + bf::at_key(m_Plugins).push_back(this); + + if (m_Organizer) { + bf::at_key(m_Plugins).push_back(m_Organizer); + m_Organizer->connectPlugins(this); + } +} + +std::vector PluginContainer::activeProblems() const +{ + std::vector problems; + if (m_FailedPlugins.size()) { + problems.push_back(PROBLEM_PLUGINSNOTLOADED); + } + return problems; +} + +QString PluginContainer::shortDescription(unsigned int key) const +{ + switch (key) { + case PROBLEM_PLUGINSNOTLOADED: { + return tr("Some plugins could not be loaded"); + } break; + default: { + return tr("Description missing"); + } break; + } +} + +QString PluginContainer::fullDescription(unsigned int key) const +{ + switch (key) { + case PROBLEM_PLUGINSNOTLOADED: { + QString result = + tr("The following plugins could not be loaded. The reason may be missing " + "dependencies (i.e. python) or an outdated version:") + + "
      "; + for (const QString& plugin : m_FailedPlugins) { + result += "
    • " + plugin + "
    • "; + } + result += "
        "; + return result; + } break; + default: { + return tr("Description missing"); + } break; + } +} + +bool PluginContainer::hasGuidedFix(unsigned int) const +{ + return false; +} + +void PluginContainer::startGuidedFix(unsigned int) const {} diff --git a/src/src/plugincontainer.h b/src/src/plugincontainer.h new file mode 100644 index 0000000..f9824be --- /dev/null +++ b/src/src/plugincontainer.h @@ -0,0 +1,505 @@ +#ifndef PLUGINCONTAINER_H +#define PLUGINCONTAINER_H + +#include "previewgenerator.h" + +class OrganizerCore; +class IUserInterface; + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifndef Q_MOC_RUN +#include +#include +#include +#endif // Q_MOC_RUN +#include +#include + +#include "game_features.h" + +class OrganizerProxy; + +/** + * @brief Class that wrap multiple requirements for a plugin together. THis + * class owns the requirements. + */ +class PluginRequirements +{ +public: + /** + * @return true if the plugin can be enabled (all requirements are met). + */ + bool canEnable() const; + + /** + * @return true if this is a core plugin, i.e. a plugin that should not be + * manually enabled or disabled by the user. + */ + bool isCorePlugin() const; + + /** + * @return true if this plugin has requirements (satisfied or not). + */ + bool hasRequirements() const; + + /** + * @return the proxy that created this plugin, if any. + */ + MOBase::IPluginProxy* proxy() const; + + /** + * @return the list of plugins this plugin proxies (if it's a proxy plugin). + */ + std::vector proxied() const; + + /** + * @return the master of this plugin, if any. + */ + MOBase::IPlugin* master() const; + + /** + * @return the plugins this plugin is master of. + */ + std::vector children() const; + + /** + * @return the list of problems to be resolved before enabling the plugin. + */ + std::vector problems() const; + + /** + * @return the name of the games (gameName()) this plugin can be used with, or an + * empty list if this plugin does not require particular games. + */ + QStringList requiredGames() const; + + /** + * @return the list of plugins currently enabled that would have to be disabled + * if this plugin was disabled. + */ + std::vector requiredFor() const; + +private: + // The list of "Core" plugins. + static const std::set s_CorePlugins; + + // Accumulator version for requiredFor() to avoid infinite recursion. + void requiredFor(std::vector& required, + std::set& visited) const; + + // Retrieve the requirements from the underlying plugin, take ownership on them + // and store them. We cannot do this in the constructor because we want to have a + // constructed object before calling init(). + void fetchRequirements(); + + // Set the master for this plugin. This is required to "fake" masters for proxied + // plugins. + void setMaster(MOBase::IPlugin* master); + + friend class OrganizerCore; + friend class PluginContainer; + + PluginContainer* m_PluginContainer; + MOBase::IPlugin* m_Plugin; + MOBase::IPluginProxy* m_PluginProxy; + MOBase::IPlugin* m_Master; + std::vector> m_Requirements; + OrganizerProxy* m_Organizer; + std::vector m_RequiredFor; + + PluginRequirements(PluginContainer* pluginContainer, MOBase::IPlugin* plugin, + OrganizerProxy* proxy, MOBase::IPluginProxy* pluginProxy); +}; + +/** + * + */ +class PluginContainer : public QObject, public MOBase::IPluginDiagnose +{ + + Q_OBJECT + Q_INTERFACES(MOBase::IPluginDiagnose) + +private: + using PluginMap = boost::fusion::map< + boost::fusion::pair>, + boost::fusion::pair>, + boost::fusion::pair>, + boost::fusion::pair>, + boost::fusion::pair>, + boost::fusion::pair>, + boost::fusion::pair>, + boost::fusion::pair>, + boost::fusion::pair>, + boost::fusion::pair>>; + + using AccessPluginMap = boost::fusion::map< + boost::fusion::pair>, + boost::fusion::pair>, + boost::fusion::pair>>; + + static constexpr unsigned int PROBLEM_PLUGINSNOTLOADED = 1; + + /** + * This typedefs defines the order of plugin interface. This is increasing order of + * importance". + * + * @note IPlugin is the less important interface, followed by IPluginDiagnose and + * IPluginFileMapper as those are usually implemented together with another + * interface. Other interfaces are in a alphabetical order since it is unlikely a + * plugin will implement multiple ones. + */ + using PluginTypeOrder = boost::mp11::mp_transform< + std::add_pointer_t, + boost::mp11::mp_list< + MOBase::IPluginGame, MOBase::IPluginInstaller, MOBase::IPluginModPage, + MOBase::IPluginPreview, MOBase::IPluginProxy, MOBase::IPluginTool, + MOBase::IPluginDiagnose, MOBase::IPluginFileMapper, MOBase::IPlugin>>; + + static_assert(boost::mp11::mp_size::value == + boost::mp11::mp_size::value - 1); + +public: + /** + * @brief Retrieved the (localized) names of the various plugin interfaces. + * + * @return the (localized) names of the various plugin interfaces. + */ + static QStringList pluginInterfaces(); + +public: + PluginContainer(OrganizerCore* organizer); + virtual ~PluginContainer(); + + /** + * @brief Start the plugins. + * + * This function should not be called before MO2 is ready and plugins can be + * started, and will do the following: + * - connect the callbacks of the plugins, + * - set the parent widget for plugins that can have one, + * - notify plugins that MO2 has been started, including: + * - triggering a call to the "profile changed" callback for the initial profile, + * - triggering a call to the "user interface initialized" callback. + * + * @param userInterface The main user interface to use for the plugins. + */ + void startPlugins(IUserInterface* userInterface); + + /** + * @brief Load, unload or reload the plugin at the given path. + * + */ + void loadPlugin(QString const& filepath); + void unloadPlugin(QString const& filepath); + void reloadPlugin(QString const& filepath); + + /** + * @brief Load all plugins. + * + */ + void loadPlugins(); + + /** + * @brief Retrieve the list of plugins of the given type. + * + * @return the list of plugins of the specified type. + * + * @tparam T The type of plugin to retrieve. + */ + template + const std::vector& plugins() const + { + typename boost::fusion::result_of::at_key::type temp = + boost::fusion::at_key(m_Plugins); + return temp; + } + + /** + * @brief Check if a plugin implement a given interface. + * + * @param plugin The plugin to check. + * + * @return true if the plugin implements the interface, false otherwise. + * + * @tparam The interface type. + */ + template + bool implementInterface(MOBase::IPlugin* plugin) const + { + // We need a QObject to be able to qobject_cast<> to the plugin types: + QObject* oPlugin = as_qobject(plugin); + + if (!oPlugin) { + return false; + } + + return qobject_cast(oPlugin); + } + + /** + * @brief Retrieve a plugin from its name or a corresponding non-IPlugin + * interface. + * + * @param t Name of the plugin to retrieve, or non-IPlugin interface. + * + * @return the corresponding plugin, or a null pointer. + * + * @note It is possible to have multiple plugins for the same name when + * dealing with proxied plugins (e.g. Python), in which case the + * most important one will be returned, as specified in PluginTypeOrder. + */ + MOBase::IPlugin* plugin(QString const& pluginName) const; + MOBase::IPlugin* plugin(MOBase::IPluginDiagnose* diagnose) const; + MOBase::IPlugin* plugin(MOBase::IPluginFileMapper* mapper) const; + + /** + * @brief Find the game plugin corresponding to the given name. + * + * @param name The name of the game to find a plugin for (as returned by + * IPluginGame::gameName()). + * + * @return the game plugin for the given name, or a null pointer if no + * plugin exists for this game. + */ + MOBase::IPluginGame* game(const QString& name) const; + + /** + * @return the IPlugin interface to the currently managed game. + */ + MOBase::IPluginGame* managedGame() const; + + /** + * @brief Check if the given plugin is enabled. + * + * @param plugin The plugin to check. + * + * @return true if the plugin is enabled, false otherwise. + */ + bool isEnabled(MOBase::IPlugin* plugin) const; + + // These are friendly methods that called isEnabled(plugin(arg)). + bool isEnabled(QString const& pluginName) const; + bool isEnabled(MOBase::IPluginDiagnose* diagnose) const; + bool isEnabled(MOBase::IPluginFileMapper* mapper) const; + + /** + * @brief Enable or disable a plugin. + * + * @param plugin The plugin to enable or disable. + * @param enable true to enable, false to disable. + * @param dependencies If true and enable is false, dependencies will also + * be disabled (see PluginRequirements::requiredFor). + */ + void setEnabled(MOBase::IPlugin* plugin, bool enable, bool dependencies = true); + + /** + * @brief Retrieve the requirements for the given plugin. + * + * @param plugin The plugin to retrieve the requirements for. + * + * @return the requirements (as proxy) for the given plugin. + */ + const PluginRequirements& requirements(MOBase::IPlugin* plugin) const; + + /** + * @brief Retrieved the (localized) names of interfaces implemented by the given + * plugin. + * + * @param plugin The plugin to retrieve interface for. + * + * @return the (localized) names of interfaces implemented by this plugin. + */ + QStringList implementedInterfaces(MOBase::IPlugin* plugin) const; + + /** + * @brief Return the (localized) name of the most important interface implemented by + * the given plugin. + * + * The order of interfaces is defined in X. + * + * @param plugin The plugin to retrieve the interface for. + * + * @return the (localized) name of the most important interface implemented by this + * plugin. + */ + QString topImplementedInterface(MOBase::IPlugin* plugin) const; + + /** + * @return the game features. + */ + GameFeatures& gameFeatures() const { return *m_GameFeatures; } + + /** + * @return the preview generator. + */ + const PreviewGenerator& previewGenerator() const { return m_PreviewGenerator; } + + /** + * @return the list of plugin file names, including proxied plugins. + */ + QStringList pluginFileNames() const; + +public: // IPluginDiagnose interface + virtual std::vector activeProblems() const; + virtual QString shortDescription(unsigned int key) const; + virtual QString fullDescription(unsigned int key) const; + virtual bool hasGuidedFix(unsigned int key) const; + virtual void startGuidedFix(unsigned int key) const; + +signals: + + /** + * @brief Emitted when plugins are enabled or disabled. + */ + void pluginEnabled(MOBase::IPlugin*); + void pluginDisabled(MOBase::IPlugin*); + + /** + * @brief Emitted when plugins are registered or unregistered. + */ + void pluginRegistered(MOBase::IPlugin*); + void pluginUnregistered(MOBase::IPlugin*); + + void diagnosisUpdate(); + +private: + friend class PluginRequirements; + + // Unload all the plugins. + void unloadPlugins(); + + // Retrieve the organizer proxy for the given plugin. + OrganizerProxy* organizerProxy(MOBase::IPlugin* plugin) const; + + // Retrieve the proxy plugin that instantiated the given plugin, or a null pointer + // if the plugin was not instantiated by a proxy. + MOBase::IPluginProxy* pluginProxy(MOBase::IPlugin* plugin) const; + + // Retrieve the path to the file or folder corresponding to the plugin. + QString filepath(MOBase::IPlugin* plugin) const; + + // Load plugins from the given filepath using the given proxy. + std::vector loadProxied(const QString& filepath, + MOBase::IPluginProxy* proxy); + + // Load the Qt plugin from the given file. + QObject* loadQtPlugin(const QString& filepath); + + // check if a plugin is folder containing a Qt plugin, it is, return the path to the + // DLL containing the plugin in the folder, otherwise return an empty optional + // + // a Qt plugin folder is a folder with a DLL containing a library (not in a + // subdirectory), if multiple plugins are present, only the first one is returned + // + // extra DLLs are ignored by Qt so can be present in the folder + // + std::optional isQtPluginFolder(const QString& filepath) const; + + // See startPlugins for more details. This is simply an intermediate function + // that can be used when loading plugins after initialization. This uses the + // user interface in m_UserInterface. + void startPluginsImpl(const std::vector& plugins) const; + + /** + * @brief Unload the given plugin. + * + * This function is not public because it's kind of dangerous trying to unload + * plugin directly since some plugins are linked together. + * + * @param plugin The plugin to unload/unregister. + * @param object The QObject corresponding to the plugin. + */ + void unloadPlugin(MOBase::IPlugin* plugin, QObject* object); + + /** + * @brief Retrieved the (localized) names of interfaces implemented by the given + * plugin. + * + * @param plugin The plugin to retrieve interface for. + * + * @return the (localized) names of interfaces implemented by this plugin. + * + * @note This function can be used to get implemented interfaces before registering + * a plugin. + */ + QStringList implementedInterfaces(QObject* plugin) const; + + /** + * @brief Check if a plugin implements a "better" interface than another + * one, as specified by PluginTypeOrder. + * + * @param lhs, rhs The plugin to compare. + * + * @return true if the left plugin implements a better interface than the right + * one, false otherwise (or if both implements the same interface). + */ + bool isBetterInterface(QObject* lhs, QObject* rhs) const; + + /** + * @brief Find the QObject* corresponding to the given plugin. + * + * @param plugin The plugin to find the QObject* for. + * + * @return a QObject* for the given plugin. + */ + QObject* as_qobject(MOBase::IPlugin* plugin) const; + + /** + * @brief Initialize a plugin. + * + * @param plugin The plugin to initialize. + * @param proxy The proxy that created this plugin (can be null). + * @param skipInit If true, IPlugin::init() will not be called, regardless + * of the state of the container. + * + * @return true if the plugin was initialized correctly, false otherwise. + */ + bool initPlugin(MOBase::IPlugin* plugin, MOBase::IPluginProxy* proxy, bool skipInit); + + void registerGame(MOBase::IPluginGame* game); + void unregisterGame(MOBase::IPluginGame* game); + + MOBase::IPlugin* registerPlugin(QObject* pluginObj, const QString& fileName, + MOBase::IPluginProxy* proxy); + + // Core organizer, can be null (e.g. on first MO2 startup). + OrganizerCore* m_Organizer; + + // Main user interface, can be null until MW has been initialized. + IUserInterface* m_UserInterface; + + // Game features + std::unique_ptr m_GameFeatures; + + PluginMap m_Plugins; + + // This maps allow access to IPlugin* from name or diagnose/mapper object. + AccessPluginMap m_AccessPlugins; + + std::map m_Requirements; + + std::map m_SupportedGames; + QStringList m_FailedPlugins; + std::vector m_PluginLoaders; + + PreviewGenerator m_PreviewGenerator; + + QFile m_PluginsCheck; +}; + +#endif // PLUGINCONTAINER_H diff --git a/src/src/pluginlist.cpp b/src/src/pluginlist.cpp new file mode 100644 index 0000000..aa44e0a --- /dev/null +++ b/src/src/pluginlist.cpp @@ -0,0 +1,2104 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ +#include "pluginlist.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "gameplugins.h" +#include "modinfo.h" +#include "modlist.h" +#include "organizercore.h" +#include "settings.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "shared/windows_error.h" +#include "viewmarkingscrollbar.h" + +#ifndef _WIN32 +#include +#include +#endif + +using namespace MOBase; +using namespace MOShared; + +static QString TruncateString(const QString& text) +{ + QString new_text = text; + + if (new_text.length() > 1024) { + new_text.truncate(1024); + new_text += "..."; + } + + return new_text; +} + +PluginList::PluginList(OrganizerCore& organizer) + : QAbstractItemModel(&organizer), m_Organizer(organizer), m_FontMetrics(QFont()) +{ + connect(this, SIGNAL(writePluginsList()), this, SLOT(generatePluginIndexes())); + m_LastCheck.start(); +} + +PluginList::~PluginList() +{ + m_Refreshed.disconnect_all_slots(); + m_PluginMoved.disconnect_all_slots(); + m_PluginStateChanged.disconnect_all_slots(); +} + +QString PluginList::getColumnName(int column) +{ + switch (column) { + case COL_NAME: + return tr("Name"); + case COL_PRIORITY: + return tr("Priority"); + case COL_MODINDEX: + return tr("Mod Index"); + case COL_FLAGS: + return tr("Flags"); + case COL_FORMVERSION: + return tr("Form Version"); + case COL_HEADERVERSION: + return tr("Header Version"); + case COL_AUTHOR: + return tr("Author"); + case COL_DESCRIPTION: + return tr("Description"); + default: + return tr("unknown"); + } +} + +QString PluginList::getColumnToolTip(int column) +{ + switch (column) { + case COL_NAME: + return tr("Name of the plugin"); + case COL_FLAGS: + return tr("Emblems to highlight things that might require attention."); + case COL_PRIORITY: + return tr( + "Load priority of plugins. The higher, the more \"important\" it is and thus " + "overwrites data from plugins with lower priority."); + case COL_MODINDEX: + return tr("Determines the formids of objects originating from this mods."); + case COL_FORMVERSION: + return tr("Form version of the plugin."); + case COL_HEADERVERSION: + return tr("Header version of the plugin."); + case COL_AUTHOR: + return tr("Author of the plugin."); + case COL_DESCRIPTION: + return tr("Description of the plugin."); + default: + return tr("unknown"); + } +} + +void PluginList::highlightPlugins(const std::vector& modIndices, + const MOShared::DirectoryEntry& directoryEntry) +{ + auto profile = m_Organizer.currentProfile(); + + for (auto& esp : m_ESPs) { + esp.modSelected = false; + } + + for (auto& modIndex : modIndices) { + ModInfo::Ptr selectedMod = ModInfo::getByIndex(modIndex); + if (!selectedMod.isNull() && profile->modEnabled(modIndex)) { + QString modDataPath = selectedMod->absolutePath(); + modDataPath = + m_Organizer.managedGame()->modDataDirectory().isEmpty() + ? modDataPath + : modDataPath + "/" + m_Organizer.managedGame()->modDataDirectory(); + QDir dir(modDataPath); + QStringList plugins; + if (dir.exists()) { + plugins = dir.entryList(QStringList() << "*.esp" + << "*.esm" + << "*.esl"); + } + const MOShared::FilesOrigin& origin = + directoryEntry.getOriginByName(selectedMod->internalName().toStdWString()); + if (plugins.size() > 0) { + for (auto plugin : plugins) { + MOShared::FileEntryPtr file = directoryEntry.findFile(plugin.toStdWString()); + if (file && file->getOrigin() != origin.getID()) { + const auto alternatives = file->getAlternatives(); + if (std::find_if(alternatives.begin(), alternatives.end(), + [&](const FileAlternative& element) { + return element.originID() == origin.getID(); + }) == alternatives.end()) + continue; + } + std::map::iterator iter = m_ESPsByName.find(plugin); + if (iter != m_ESPsByName.end()) { + m_ESPs[iter->second].modSelected = true; + } + } + } + } + } + + emit dataChanged(this->index(0, 0), this->index(static_cast(m_ESPs.size()) - 1, + this->columnCount() - 1)); +} + +void PluginList::highlightMasters(const QModelIndexList& selectedPluginIndices) +{ + for (auto& esp : m_ESPs) { + esp.isMasterOfSelectedPlugin = false; + } + + for (const auto& pluginIndex : selectedPluginIndices) { + const ESPInfo& plugin = m_ESPs[pluginIndex.row()]; + for (const auto& master : plugin.masters) { + const auto iter = m_ESPsByName.find(master); + if (iter != m_ESPsByName.end()) { + m_ESPs[iter->second].isMasterOfSelectedPlugin = true; + } + } + } +} + +void PluginList::refresh(const QString& profileName, + const DirectoryEntry& baseDirectory, + const QString& lockedOrderFile, bool force) +{ + TimeThis tt("PluginList::refresh()"); + + if (force) { + m_ESPs.clear(); + m_ESPsByName.clear(); + m_ESPsByPriority.clear(); + } + + ChangeBracket layoutChange(this); + + QStringList primaryPlugins = m_GamePlugin->primaryPlugins(); + QStringList enabledPlugins = m_GamePlugin->enabledPlugins(); + auto gamePlugins = m_Organizer.gameFeatures().gameFeature(); + const bool lightPluginsAreSupported = + gamePlugins ? gamePlugins->lightPluginsAreSupported() : false; + const bool mediumPluginsAreSupported = + gamePlugins ? gamePlugins->mediumPluginsAreSupported() : false; + const bool blueprintPluginsAreSupported = + gamePlugins ? gamePlugins->blueprintPluginsAreSupported() : false; + const bool loadOrderMechanismNone = + m_GamePlugin->loadOrderMechanism() == IPluginGame::LoadOrderMechanism::None; + + m_CurrentProfile = profileName; + + std::unordered_map availablePlugins; + QStringList archiveCandidates; + + for (FileEntryPtr current : baseDirectory.getFiles()) { + if (current.get() == nullptr) { + continue; + } + const QString& filename = ToQString(current->getName()); + + if (filename.endsWith(".esp", Qt::CaseInsensitive) || + filename.endsWith(".esm", Qt::CaseInsensitive) || + filename.endsWith(".esl", Qt::CaseInsensitive)) { + availablePlugins.insert(std::make_pair(filename, current)); + } else if (filename.endsWith(".bsa", Qt::CaseInsensitive) || + filename.endsWith("ba2", Qt::CaseInsensitive)) { + archiveCandidates.append(filename); + } + } + + for (const auto& [filename, current] : availablePlugins) { + if (m_ESPsByName.contains(filename)) { + continue; + } + + bool forceLoaded = Settings::instance().game().forceEnableCoreFiles() && + primaryPlugins.contains(filename, Qt::CaseInsensitive); + bool forceEnabled = enabledPlugins.contains(filename, Qt::CaseInsensitive); + bool forceDisabled = loadOrderMechanismNone && !forceLoaded && !forceEnabled; + if (!lightPluginsAreSupported && filename.endsWith(".esl")) { + forceDisabled = true; + } + + bool archive = false; + try { + FilesOrigin& origin = baseDirectory.getOriginByID(current->getOrigin(archive)); + + // name without extension + QString baseName = QFileInfo(filename).completeBaseName(); + + QString iniPath = baseName + ".ini"; + bool hasIni = baseDirectory.findFile(ToWString(iniPath)).get() != nullptr; + std::set loadedArchives; + for (const auto& archiveName : archiveCandidates) { + if (archiveName.startsWith(baseName, Qt::CaseInsensitive)) { + loadedArchives.insert(archiveName); + } + } + + QString originName = ToQString(origin.getName()); + unsigned int modIndex = ModInfo::getIndex(originName); + if (modIndex != UINT_MAX) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); + originName = modInfo->name(); + } + + m_ESPs.emplace_back(filename, forceLoaded, forceEnabled, forceDisabled, + originName, ToQString(current->getFullPath()), hasIni, + loadedArchives, lightPluginsAreSupported, + mediumPluginsAreSupported, blueprintPluginsAreSupported); + m_ESPs.rbegin()->priority = -1; + } catch (const std::exception& e) { + reportError(tr("failed to update esp info for file %1 (source id: %2), error: %3") + .arg(filename) + .arg(current->getOrigin(archive)) + .arg(e.what())); + } + } + + for (const auto& espName : m_ESPsByName) { + if (!availablePlugins.contains(espName.first)) { + m_ESPs[espName.second].name = ""; + } + } + + m_ESPs.erase(std::remove_if(m_ESPs.begin(), m_ESPs.end(), + [](const ESPInfo& info) -> bool { + return info.name.isEmpty(); + }), + m_ESPs.end()); + + fixPriorities(); + + // functions in GamePlugins will use the IPluginList interface of this, so + // indices need to work. priority will be off however + updateIndices(); + + if (gamePlugins) { + gamePlugins->readPluginLists(m_Organizer.managedGameOrganizer()->pluginList()); + } + + fixPrimaryPlugins(); + fixPluginRelationships(); + + testMasters(); + + updateIndices(); + + readLockedOrderFrom(lockedOrderFile); + + layoutChange.finish(); + + refreshLoadOrder(); + emit dataChanged(this->index(0, 0), + this->index(static_cast(m_ESPs.size()), columnCount())); + + m_Refreshed(); +} + +void PluginList::fixPrimaryPlugins() +{ + if (!m_Organizer.settings().game().forceEnableCoreFiles()) { + return; + } + + // This function ensures that the primary plugins are first and in the correct order + QStringList primaryPlugins = m_Organizer.managedGame()->primaryPlugins(); + int prio = 0; + int prioBlueprint = 0; + bool somethingChanged = false; + for (auto esp : m_ESPs) { + if (!esp.isBlueprintFlagged) + prioBlueprint++; + } + for (QString plugin : primaryPlugins) { + std::map::iterator iter = m_ESPsByName.find(plugin); + // Plugin is present? + if (iter != m_ESPsByName.end()) { + if (!m_ESPs[iter->second].isBlueprintFlagged) { + if (prio != m_ESPs[iter->second].priority) { + // Priority is wrong! Fix it! + int newPrio = prio; + setPluginPriority(iter->second, newPrio, true /* isForced */); + somethingChanged = true; + } + prio++; + } else { + if (prioBlueprint != m_ESPs[iter->second].priority) { + // Priority is wrong! Fix it! + int newPrio = prioBlueprint; + setPluginPriority(iter->second, newPrio, true /* isForced */); + somethingChanged = true; + } + prioBlueprint++; + } + } + } + + if (somethingChanged) { + writePluginsList(); + } +} + +void PluginList::fixPluginRelationships() +{ + TimeThis timer("PluginList::fixPluginRelationships"); + + // Count the types of plugins + int standardCount = 0; + int masterCount = 0; + int blueprintMasterCount = 0; + for (auto plugin : m_ESPs) { + if (plugin.hasLightExtension || plugin.hasMasterExtension || + plugin.isMasterFlagged) { + if (plugin.isBlueprintFlagged) { + blueprintMasterCount++; + } else { + masterCount++; + } + } + if (!plugin.isBlueprintFlagged) { + standardCount++; + } + } + + // Ensure masters are up top and normal plugins are down below + for (int i = 0; i < m_ESPs.size(); i++) { + ESPInfo& plugin = m_ESPs[i]; + if (plugin.hasLightExtension || plugin.hasMasterExtension || + plugin.isMasterFlagged) { + if (plugin.isBlueprintFlagged) { + if (plugin.priority > standardCount + blueprintMasterCount) { + int newPriority = standardCount + blueprintMasterCount; + setPluginPriority(i, newPriority); + } + } else { + if (plugin.priority > masterCount) { + int newPriority = masterCount; + setPluginPriority(i, newPriority); + } + } + } else { + if (plugin.isBlueprintFlagged) { + if (plugin.priority < standardCount + blueprintMasterCount) { + int newPriority = standardCount + blueprintMasterCount + 1; + setPluginPriority(i, newPriority); + } + } else { + if (plugin.priority < masterCount) { + int newPriority = masterCount + 1; + setPluginPriority(i, newPriority); + } + } + } + } + + // Ensure master/child relationships are observed + for (int i = 0; i < m_ESPs.size(); i++) { + ESPInfo& plugin = m_ESPs[i]; + int newPriority = plugin.priority; + for (auto master : plugin.masters) { + auto iter = m_ESPsByName.find(master); + if (iter != m_ESPsByName.end()) { + if (m_ESPs.at(iter->second).isBlueprintFlagged == plugin.isBlueprintFlagged) { + newPriority = std::max(newPriority, m_ESPs[iter->second].priority); + } + } + } + if (newPriority != plugin.priority) { + setPluginPriority(i, newPriority); + } + } +} + +void PluginList::fixPriorities() +{ + std::vector> espPrios; + + for (int i = 0; i < m_ESPs.size(); ++i) { + int prio = m_ESPs[i].priority; + if (prio == -1) { + prio = INT_MAX; + } + espPrios.push_back(std::make_pair(prio, i)); + } + + std::sort(espPrios.begin(), espPrios.end(), + [](const std::pair& lhs, const std::pair& rhs) { + return lhs.first < rhs.first; + }); + + for (int i = 0; i < espPrios.size(); ++i) { + m_ESPs[espPrios[i].second].priority = i; + } +} + +void PluginList::enableESP(const QString& name, bool enable) +{ + std::map::iterator iter = m_ESPsByName.find(name); + + if (iter != m_ESPsByName.end()) { + auto enabled = m_ESPs[iter->second].enabled; + m_ESPs[iter->second].enabled = (enable && !m_ESPs[iter->second].forceDisabled) || + m_ESPs[iter->second].forceLoaded || + m_ESPs[iter->second].forceEnabled; + + emit writePluginsList(); + if (enabled != m_ESPs[iter->second].enabled) { + pluginStatesChanged({name}, state(name)); + } + } else { + reportError(tr("Plugin not found: %1").arg(qUtf8Printable(name))); + } +} + +int PluginList::findPluginByPriority(int priority) +{ + for (int i = 0; i < m_ESPs.size(); i++) { + if (m_ESPs[i].priority == priority) { + return i; + } + } + log::error("No plugin with priority {}", priority); + return -1; +} + +void PluginList::setEnabled(const QModelIndexList& indices, bool enabled) +{ + QStringList dirty; + for (auto& idx : indices) { + if (m_ESPs[idx.row()].forceLoaded || m_ESPs[idx.row()].forceEnabled || + m_ESPs[idx.row()].forceDisabled) + continue; + if (m_ESPs[idx.row()].enabled != enabled) { + m_ESPs[idx.row()].enabled = enabled; + dirty.append(m_ESPs[idx.row()].name); + } + } + if (!dirty.isEmpty()) { + emit writePluginsList(); + pluginStatesChanged(dirty, enabled ? IPluginList::PluginState::STATE_ACTIVE + : IPluginList::PluginState::STATE_INACTIVE); + } +} + +void PluginList::setEnabledAll(bool enabled) +{ + QStringList dirty; + for (ESPInfo& info : m_ESPs) { + if (info.forceLoaded || info.forceEnabled || info.forceDisabled) + continue; + if (info.enabled != enabled) { + info.enabled = enabled; + dirty.append(info.name); + } + } + if (!dirty.isEmpty()) { + emit writePluginsList(); + pluginStatesChanged(dirty, enabled ? IPluginList::PluginState::STATE_ACTIVE + : IPluginList::PluginState::STATE_INACTIVE); + } +} + +void PluginList::sendToPriority(const QModelIndexList& indices, int newPriority) +{ + std::vector pluginsToMove; + for (auto& idx : indices) { + if (!m_ESPs[idx.row()].forceLoaded) { + pluginsToMove.push_back(idx.row()); + } + } + if (pluginsToMove.size()) { + changePluginPriority(pluginsToMove, newPriority); + } +} + +void PluginList::shiftPluginsPriority(const QModelIndexList& indices, int offset) +{ + // retrieve the plugin index and sort them by priority to avoid issue + // when moving them + std::vector allIndex; + for (auto& idx : indices) { + allIndex.push_back(idx.row()); + } + std::sort(allIndex.begin(), allIndex.end(), [=](int lhs, int rhs) { + bool cmp = m_ESPs[lhs].priority < m_ESPs[rhs].priority; + return offset > 0 ? !cmp : cmp; + }); + + for (auto index : allIndex) { + int newPriority = m_ESPs[index].priority + offset; + if (newPriority >= 0 && newPriority < rowCount()) { + setPluginPriority(index, newPriority); + } + } + + refreshLoadOrder(); +} + +void PluginList::toggleState(const QModelIndexList& indices) +{ + QModelIndex minRow, maxRow; + for (auto& idx : indices) { + if (!minRow.isValid() || (idx.row() < minRow.row())) { + minRow = idx; + } + if (!maxRow.isValid() || (idx.row() > maxRow.row())) { + maxRow = idx; + } + int oldState = idx.data(Qt::CheckStateRole).toInt(); + setData(idx, oldState == Qt::Unchecked ? Qt::Checked : Qt::Unchecked, + Qt::CheckStateRole); + } + + emit dataChanged(minRow, maxRow); +} + +bool PluginList::isEnabled(const QString& name) +{ + std::map::iterator iter = m_ESPsByName.find(name); + + if (iter != m_ESPsByName.end()) { + return m_ESPs[iter->second].enabled; + } else { + return false; + } +} + +void PluginList::clearInformation(const QString& name) +{ + std::map::iterator iter = m_ESPsByName.find(name); + + if (iter != m_ESPsByName.end()) { + m_AdditionalInfo[name].messages.clear(); + } +} + +void PluginList::clearAdditionalInformation() +{ + m_AdditionalInfo.clear(); +} + +void PluginList::addInformation(const QString& name, const QString& message) +{ + std::map::iterator iter = m_ESPsByName.find(name); + + if (iter != m_ESPsByName.end()) { + m_AdditionalInfo[name].messages.append(message); + } else { + log::warn("failed to associate message for \"{}\"", name); + } +} + +void PluginList::addLootReport(const QString& name, Loot::Plugin plugin) +{ + auto iter = m_ESPsByName.find(name); + + if (iter != m_ESPsByName.end()) { + m_AdditionalInfo[name].loot = std::move(plugin); + } else { + log::warn("failed to associate loot report for \"{}\"", name); + } +} + +bool PluginList::isEnabled(int index) +{ + return m_ESPs.at(index).enabled; +} + +void PluginList::readLockedOrderFrom(const QString& fileName) +{ + m_LockedOrder.clear(); + + QFile file(fileName); + if (!file.exists()) { + // no locked load order, that's ok + return; + } + + file.open(QIODevice::ReadOnly); + int lineNumber = 0; + while (!file.atEnd()) { + QByteArray line = file.readLine(); + ++lineNumber; + + // Skip empty lines or commented out lines (#) + if ((line.size() <= 0) || (line.at(0) == '#')) { + continue; + } + + QList fields = line.split('|'); + if (fields.count() != 2) { + // Don't know how to parse this so run away + log::error("locked order file: invalid line #{}: {}", lineNumber, + QString::fromUtf8(line).trimmed()); + continue; + } + + // Read the plugin name and priority + QString pluginName = QString::fromUtf8(fields.at(0)); + int priority = fields.at(1).trimmed().toInt(); + if (priority < 0) { + // WTF do you mean a negative priority? + log::error("locked order file: invalid line #{}: {}", lineNumber, + QString::fromUtf8(line).trimmed()); + continue; + } + + // Determine the index of the plugin + auto it = m_ESPsByName.find(pluginName); + if (it == m_ESPsByName.end()) { + // Plugin does not exist in the current set of plugins + m_LockedOrder[pluginName] = priority; + continue; + } + int pluginIndex = it->second; + + // Do not allow locking forced plugins + if (m_ESPs[pluginIndex].forceLoaded) { + continue; + } + + // If the priority is larger than the number of plugins, just keep it locked + if (priority >= m_ESPsByPriority.size()) { + m_LockedOrder[pluginName] = priority; + continue; + } + + // These are some helper functions for figuring out what is already locked + auto findLocked = [&](const std::pair& a) { + return a.second == priority; + }; + auto alreadyLocked = [&]() { + return std::find_if(m_LockedOrder.begin(), m_LockedOrder.end(), findLocked) != + m_LockedOrder.end(); + }; + + // See if we can just set the given priority + if (!m_ESPs[m_ESPsByPriority.at(priority)].forceLoaded && !alreadyLocked()) { + m_LockedOrder[pluginName] = priority; + continue; + } + + // Find the next higher priority we can set the plugin to + while (++priority < m_ESPs.size()) { + if (!m_ESPs[m_ESPsByPriority.at(priority)].forceLoaded && !alreadyLocked()) { + m_LockedOrder[pluginName] = priority; + break; + } + } + + // See if we walked off the end of the plugin list + if (priority >= m_ESPs.size()) { + // I guess go ahead and lock it here at the end of the list? + m_LockedOrder[pluginName] = priority; + continue; + } + } /* while (!file.atEnd()) */ + file.close(); +} + +void PluginList::writeLockedOrder(const QString& fileName) const +{ + SafeWriteFile file(fileName); + + file->resize(0); + file->write(QString("# This file was automatically generated by Mod Organizer.\r\n") + .toUtf8()); + for (auto iter = m_LockedOrder.begin(); iter != m_LockedOrder.end(); ++iter) { + file->write(QString("%1|%2\r\n").arg(iter->first).arg(iter->second).toUtf8()); + } + file->commit(); +} + +void PluginList::saveTo(const QString& lockedOrderFileName) const +{ + auto gamePlugins = m_Organizer.gameFeatures().gameFeature(); + if (gamePlugins) { + gamePlugins->writePluginLists(m_Organizer.managedGameOrganizer()->pluginList()); + } + + writeLockedOrder(lockedOrderFileName); +} + +bool PluginList::saveLoadOrder(DirectoryEntry& directoryStructure) +{ + if (m_GamePlugin->loadOrderMechanism() != IPluginGame::LoadOrderMechanism::FileTime) { + // nothing to do + return true; + } + + log::debug("setting file times on esps"); + + for (ESPInfo& esp : m_ESPs) { + std::wstring espName = ToWString(esp.name); + const FileEntryPtr fileEntry = directoryStructure.findFile(espName); + if (fileEntry.get() != nullptr) { + QString fileName; + bool archive = false; + int originid = fileEntry->getOrigin(archive); + + fileName = QDir(ToQString( + directoryStructure.getOriginByID(originid).getPath())) + .filePath(esp.name); + +#ifdef _WIN32 + HANDLE file = + ::CreateFile(ToWString(fileName).c_str(), GENERIC_READ | GENERIC_WRITE, 0, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) { + if (::GetLastError() == ERROR_SHARING_VIOLATION) { + // file is locked, probably the game is running + return false; + } else { + throw windows_error( + QObject::tr("failed to access %1").arg(fileName).toUtf8().constData()); + } + } + + ULONGLONG temp = 0; + temp = (145731ULL + esp.priority) * 24 * 60 * 60 * 10000000ULL; + + FILETIME newWriteTime; + + newWriteTime.dwLowDateTime = (DWORD)(temp & 0xFFFFFFFF); + newWriteTime.dwHighDateTime = (DWORD)(temp >> 32); + esp.time = newWriteTime; + fileEntry->setFileTime(newWriteTime); + if (!::SetFileTime(file, nullptr, nullptr, &newWriteTime)) { + throw windows_error(QObject::tr("failed to set file time %1") + .arg(fileName) + .toUtf8() + .constData()); + } + + CloseHandle(file); +#else + // On Linux, use utimensat to set file modification time + ULONGLONG temp = 0; + temp = (145731ULL + esp.priority) * 24 * 60 * 60 * 10000000ULL; + + FILETIME newWriteTime; + newWriteTime.dwLowDateTime = (DWORD)(temp & 0xFFFFFFFF); + newWriteTime.dwHighDateTime = (DWORD)(temp >> 32); + esp.time = newWriteTime; + fileEntry->setFileTime(newWriteTime); + + // Convert FILETIME to timespec and set the file modification time + { + uint64_t ticks = (static_cast(newWriteTime.dwHighDateTime) << 32) | + newWriteTime.dwLowDateTime; + // Convert from Windows epoch (1601) to Unix epoch (1970) + ticks -= 116444736000000000ULL; + time_t secs = static_cast(ticks / 10000000ULL); + struct timespec times[2]; + times[0].tv_sec = 0; + times[0].tv_nsec = UTIME_OMIT; + times[1].tv_sec = secs; + times[1].tv_nsec = 0; + std::string path = fileName.toStdString(); + if (utimensat(AT_FDCWD, path.c_str(), times, 0) != 0) { + log::warn("failed to set file time for {}", fileName); + } + } +#endif + } + } + return true; +} + +int PluginList::enabledCount() const +{ + int enabled = 0; + for (const auto& info : m_ESPs) { + if (info.enabled) { + ++enabled; + } + } + return enabled; +} + +QString PluginList::getIndexPriority(int index) const +{ + return m_ESPs[index].index; +} + +bool PluginList::isESPLocked(int index) const +{ + return m_LockedOrder.find(m_ESPs.at(index).name) != m_LockedOrder.end(); +} + +void PluginList::lockESPIndex(int index, bool lock) +{ + if (lock) { + if (!m_ESPs.at(index).forceLoaded) + m_LockedOrder[getName(index)] = m_ESPs.at(index).loadOrder; + else + return; + } else { + auto iter = m_LockedOrder.find(getName(index)); + if (iter != m_LockedOrder.end()) { + m_LockedOrder.erase(iter); + } + } + emit writePluginsList(); +} + +void PluginList::syncLoadOrder() +{ + int loadOrder = 0; + for (unsigned int i = 0; i < m_ESPs.size(); ++i) { + int index = m_ESPsByPriority[i]; + + if (m_ESPs[index].enabled) { + m_ESPs[index].loadOrder = loadOrder++; + } else { + m_ESPs[index].loadOrder = -1; + } + } +} + +void PluginList::refreshLoadOrder() +{ + ChangeBracket layoutChange(this); + syncLoadOrder(); + // set priorities according to locked load order + std::map lockedLoadOrder; + std::for_each(m_LockedOrder.begin(), m_LockedOrder.end(), + [&lockedLoadOrder](const std::pair& ele) { + lockedLoadOrder[ele.second] = ele.first; + }); + + int targetPrio = 0; + bool savePluginsList = false; + // this is guaranteed to iterate from lowest key (load order) to highest + for (auto iter = lockedLoadOrder.begin(); iter != lockedLoadOrder.end(); ++iter) { + auto nameIter = m_ESPsByName.find(iter->second); + if (nameIter != m_ESPsByName.end()) { + // locked esp exists + + // find the location to insert at + while ((targetPrio < static_cast(m_ESPs.size() - 1)) && + (m_ESPs[m_ESPsByPriority[targetPrio]].loadOrder < iter->first)) { + ++targetPrio; + } + + if (static_cast(targetPrio) >= m_ESPs.size()) { + continue; + } + + int temp = targetPrio; + int index = nameIter->second; + if (m_ESPs[index].priority != temp) { + setPluginPriority(index, temp); + m_ESPs[index].loadOrder = iter->first; + syncLoadOrder(); + savePluginsList = true; + } + } + } + if (savePluginsList) { + emit writePluginsList(); + } +} + +void PluginList::disconnectSlots() +{ + m_PluginMoved.disconnect_all_slots(); + m_Refreshed.disconnect_all_slots(); + m_PluginStateChanged.disconnect_all_slots(); +} + +int PluginList::timeElapsedSinceLastChecked() const +{ + return m_LastCheck.elapsed(); +} + +QStringList PluginList::pluginNames() const +{ + QStringList result; + + for (const ESPInfo& info : m_ESPs) { + result.append(info.name); + } + + return result; +} + +IPluginList::PluginStates PluginList::state(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return IPluginList::STATE_MISSING; + } else { + return m_ESPs[iter->second].enabled ? IPluginList::STATE_ACTIVE + : IPluginList::STATE_INACTIVE; + } +} + +void PluginList::setState(const QString& name, PluginStates state) +{ + auto iter = m_ESPsByName.find(name); + if (iter != m_ESPsByName.end()) { + m_ESPs[iter->second].enabled = + (state == IPluginList::STATE_ACTIVE && !m_ESPs[iter->second].forceDisabled) || + m_ESPs[iter->second].forceLoaded || m_ESPs[iter->second].forceEnabled; + } else { + log::warn("Plugin not found: {}", name); + } +} + +void PluginList::setLoadOrder(const QStringList& pluginList) +{ + for (ESPInfo& info : m_ESPs) { + info.priority = -1; + } + int maxPriority = 0; + for (const QString& plugin : pluginList) { + auto iter = m_ESPsByName.find(plugin); + if (iter != m_ESPsByName.end()) { + m_ESPs[iter->second].priority = maxPriority++; + } + } + + // use old priorities + for (ESPInfo& info : m_ESPs) { + if (info.priority == -1) { + info.priority = maxPriority++; + } + } + updateIndices(); +} + +int PluginList::priority(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return -1; + } else { + return m_ESPs[iter->second].priority; + } +} + +bool PluginList::setPriority(const QString& name, int newPriority) +{ + + if (newPriority < 0 || newPriority >= static_cast(m_ESPsByPriority.size())) { + return false; + } + + auto oldPriority = priority(name); + if (oldPriority == -1) { + return false; + } + + int rowIndex = findPluginByPriority(oldPriority); + + // We need to increment newPriority if its above the old one, otherwise the + // plugin is place right below the new priority. + if (oldPriority < newPriority) { + newPriority += 1; + } + changePluginPriority({rowIndex}, newPriority); + + return true; +} + +int PluginList::loadOrder(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return -1; + } else { + return m_ESPs[iter->second].loadOrder; + } +} + +QStringList PluginList::masters(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return QStringList(); + } else { + QStringList result; + for (const QString& master : m_ESPs[iter->second].masters) { + result.append(master); + } + return result; + } +} + +QString PluginList::origin(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return QString(); + } else { + return m_ESPs[iter->second].originName; + } +} + +bool PluginList::hasMasterExtension(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return false; + } else { + return m_ESPs[iter->second].hasMasterExtension; + } +} + +bool PluginList::hasLightExtension(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return false; + } else { + return m_ESPs[iter->second].hasLightExtension; + } +} + +bool PluginList::isMasterFlagged(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return false; + } else { + return m_ESPs[iter->second].isMasterFlagged; + } +} + +bool PluginList::isMediumFlagged(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return false; + } else { + return m_ESPs[iter->second].isMediumFlagged; + } +} + +bool PluginList::isLightFlagged(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return false; + } else { + return m_ESPs[iter->second].isLightFlagged; + } +} + +bool PluginList::isBlueprintFlagged(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return false; + } else { + return m_ESPs[iter->second].isBlueprintFlagged; + } +} + +bool PluginList::hasNoRecords(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return false; + } else { + return m_ESPs[iter->second].hasNoRecords; + } +} + +int PluginList::formVersion(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return -1; + } else { + return m_ESPs[iter->second].formVersion; + } +} + +float PluginList::headerVersion(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return -1; + } else { + return m_ESPs[iter->second].headerVersion; + } +} + +QString PluginList::author(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return QString(); + } else { + return m_ESPs[iter->second].author; + } +} + +QString PluginList::description(const QString& name) const +{ + auto iter = m_ESPsByName.find(name); + if (iter == m_ESPsByName.end()) { + return QString(); + } else { + return m_ESPs[iter->second].description; + } +} + +boost::signals2::connection PluginList::onPluginStateChanged( + const std::function&)>& func) +{ + return m_PluginStateChanged.connect(func); +} + +void PluginList::pluginStatesChanged(QStringList const& pluginNames, + PluginStates state) const +{ + if (pluginNames.isEmpty()) { + return; + } + std::map infos; + for (auto& name : pluginNames) { + infos[name] = state; + } + m_PluginStateChanged(infos); +} + +boost::signals2::connection +PluginList::onRefreshed(const std::function& callback) +{ + return m_Refreshed.connect(callback); +} + +boost::signals2::connection +PluginList::onPluginMoved(const std::function& func) +{ + return m_PluginMoved.connect(func); +} + +void PluginList::updateIndices() +{ + m_ESPsByName.clear(); + m_ESPsByPriority.clear(); + m_ESPsByPriority.resize(m_ESPs.size()); + for (unsigned int i = 0; i < m_ESPs.size(); ++i) { + if (m_ESPs[i].priority < 0) { + continue; + } + if (m_ESPs[i].priority >= static_cast(m_ESPs.size())) { + log::error("invalid plugin priority: {}", m_ESPs[i].priority); + continue; + } + m_ESPsByName[m_ESPs[i].name] = i; + m_ESPsByPriority.at(static_cast(m_ESPs[i].priority)) = i; + } + + generatePluginIndexes(); +} + +void PluginList::generatePluginIndexes() +{ + int numESLs = 0; + int numESHs = 0; + int numSkipped = 0; + + auto gamePlugins = m_Organizer.gameFeatures().gameFeature(); + const bool lightPluginsSupported = + gamePlugins ? gamePlugins->lightPluginsAreSupported() : false; + const bool mediumPluginsSupported = + gamePlugins ? gamePlugins->mediumPluginsAreSupported() : false; + + std::vector coreLightPlugins; + std::vector coreMediumPlugins; + + for (int l = 0; l < m_ESPs.size(); ++l) { + int i = m_ESPsByPriority.at(l); + if (!m_ESPs[i].enabled) { + m_ESPs[i].index = QString(); + ++numSkipped; + continue; + } + if (mediumPluginsSupported && m_ESPs[i].isMediumFlagged) { + int ESHpos = 253 + (numESHs / 256); + m_ESPs[i].index = QString("%1:%2") + .arg(ESHpos, 2, 16, QChar('0')) + .arg(numESHs % 256, 2, 16, QChar('0')) + .toUpper(); + ++numESHs; + + } else if (lightPluginsSupported && + (m_ESPs[i].hasLightExtension || m_ESPs[i].isLightFlagged)) { + int ESLpos = 254 + (numESLs / 4096); + m_ESPs[i].index = QString("%1:%2") + .arg(ESLpos, 2, 16, QChar('0')) + .arg(numESLs % 4096, 3, 16, QChar('0')) + .toUpper(); + ++numESLs; + } else { + m_ESPs[i].index = QString("%1") + .arg(l - numESHs - numESLs - numSkipped, 2, 16, QChar('0')) + .toUpper(); + } + } + emit esplist_changed(); +} + +int PluginList::rowCount(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return static_cast(m_ESPs.size()); + } else { + return 0; + } +} + +int PluginList::columnCount(const QModelIndex&) const +{ + return COL_LASTCOLUMN + 1; +} + +void PluginList::testMasters() +{ + std::set enabledMasters; + for (const auto& iter : m_ESPs) { + if (iter.enabled) { + enabledMasters.insert(iter.name); + } + } + + for (auto& iter : m_ESPs) { + iter.masterUnset.clear(); + if (iter.enabled) { + for (const auto& master : iter.masters) { + if (enabledMasters.find(master) == enabledMasters.end()) { + iter.masterUnset.insert(master); + } + } + } + } +} + +QVariant PluginList::data(const QModelIndex& modelIndex, int role) const +{ + int index = modelIndex.row(); + + if ((role == Qt::DisplayRole) || (role == Qt::EditRole)) { + return displayData(modelIndex); + } else if ((role == Qt::CheckStateRole) && (modelIndex.column() == 0)) { + return checkstateData(modelIndex); + } else if (role == Qt::ForegroundRole) { + return foregroundData(modelIndex); + } else if (role == Qt::BackgroundRole) { + return backgroundData(modelIndex); + } else if (role == Qt::FontRole) { + return fontData(modelIndex); + } else if (role == Qt::TextAlignmentRole) { + return alignmentData(modelIndex); + } else if (role == Qt::ToolTipRole) { + return tooltipData(modelIndex); + } else if (role == Qt::UserRole + 1) { + return iconData(modelIndex); + } + return QVariant(); +} + +QVariant PluginList::displayData(const QModelIndex& modelIndex) const +{ + const int index = modelIndex.row(); + const auto& plugin = m_ESPs[index]; + + switch (modelIndex.column()) { + case COL_NAME: + return plugin.name; + + case COL_PRIORITY: + return QString::number(plugin.priority); + + case COL_MODINDEX: + return plugin.index; + + case COL_FORMVERSION: + return plugin.formVersion != 0 ? QString::number(plugin.formVersion) : QString(); + + case COL_HEADERVERSION: + return QString::number(plugin.headerVersion); + + case COL_AUTHOR: + return plugin.author; + + case COL_DESCRIPTION: + return plugin.description; + + default: + return {}; + } +} + +QVariant PluginList::checkstateData(const QModelIndex& modelIndex) const +{ + const int index = modelIndex.row(); + + if (m_ESPs[index].forceLoaded || m_ESPs[index].forceEnabled) { + return Qt::Checked; + } else if (m_ESPs[index].forceDisabled) { + return Qt::Unchecked; + } + + return m_ESPs[index].enabled ? Qt::Checked : Qt::Unchecked; +} + +QVariant PluginList::foregroundData(const QModelIndex& modelIndex) const +{ + const int index = modelIndex.row(); + + if ((modelIndex.column() == COL_NAME) && m_ESPs[index].forceLoaded) { + return QBrush(Qt::gray); + } + + if ((modelIndex.column() == COL_NAME) && m_ESPs[index].forceDisabled) { + return QBrush(Qt::darkRed); + } + + return {}; +} + +QVariant PluginList::backgroundData(const QModelIndex& modelIndex) const +{ + const int index = modelIndex.row(); + const ESPInfo& plugin = m_ESPs[index]; + + if (plugin.modSelected) { + return Settings::instance().colors().pluginListContained(); + } else if (plugin.isMasterOfSelectedPlugin) { + return Settings::instance().colors().pluginListMaster(); + } + + return {}; +} + +QVariant PluginList::fontData(const QModelIndex& modelIndex) const +{ + const int index = modelIndex.row(); + + QFont result; + + if (m_ESPs[index].hasMasterExtension || m_ESPs[index].isMasterFlagged || + m_ESPs[index].hasLightExtension) + result.setWeight(QFont::Bold); + if (m_ESPs[index].isLightFlagged || m_ESPs[index].hasLightExtension) + result.setItalic(true); + else if (m_ESPs[index].isMediumFlagged) + result.setUnderline(true); + if (m_ESPs[index].isBlueprintFlagged) + result.setLetterSpacing(QFont::SpacingType::AbsoluteSpacing, 2); + + return result; +} + +QVariant PluginList::alignmentData(const QModelIndex& modelIndex) const +{ + const int index = modelIndex.row(); + + if (modelIndex.column() == 0) { + return QVariant(Qt::AlignLeft | Qt::AlignVCenter); + } else { + return QVariant(Qt::AlignHCenter | Qt::AlignVCenter); + } +} + +QVariant PluginList::tooltipData(const QModelIndex& modelIndex) const +{ + const int index = modelIndex.row(); + const auto& esp = m_ESPs[index]; + + QString toolTip; + + toolTip += "" + tr("Origin") + ": " + esp.originName; + + if (esp.forceLoaded) { + toolTip += "
        " + + tr("This plugin can't be disabled or moved (enforced by the game).") + + ""; + } + + if (esp.forceEnabled) { + toolTip += "
        " + + tr("This plugin can't be disabled (enforced by the game).") + ""; + } + + if (esp.formVersion != 0) { + // Oblivion-style plugin headers don't have a form version + toolTip += + "
        " + tr("Form Version") + ": " + QString::number(esp.formVersion); + } + + toolTip += + "
        " + tr("Header Version") + ": " + QString::number(esp.headerVersion); + + if (!esp.author.isEmpty()) { + toolTip += "
        " + tr("Author") + ": " + TruncateString(esp.author); + } + + if (esp.description.size() > 0) { + toolTip += + "
        " + tr("Description") + ": " + TruncateString(esp.description); + } + + if (esp.masterUnset.size() > 0) { + toolTip += + "
        " + tr("Missing Masters") + ": " + "" + + TruncateString( + QStringList(esp.masterUnset.begin(), esp.masterUnset.end()).join(", ")) + + ""; + } + + std::set enabledMasters; + std::set_difference(esp.masters.begin(), esp.masters.end(), esp.masterUnset.begin(), + esp.masterUnset.end(), + std::inserter(enabledMasters, enabledMasters.end())); + + if (!enabledMasters.empty()) { + toolTip += "
        " + tr("Enabled Masters") + + ": " + TruncateString(SetJoin(enabledMasters, ", ")); + } + + if (!esp.archives.empty()) { + QString archiveString = + esp.archives.size() < 6 + ? TruncateString( + QStringList(esp.archives.begin(), esp.archives.end()).join(", ")) + + "
        " + : ""; + toolTip += "
        " + tr("Loads Archives") + ": " + archiveString + + tr("There are Archives connected to this plugin. Their assets will be " + "added to your game, overwriting in case of conflicts following the " + "plugin order. Loose files will always overwrite assets from " + "Archives. (This flag only checks for Archives from the same mod as " + "the plugin)"); + } + + if (esp.hasIni) { + toolTip += "
        " + tr("Loads INI settings") + + ": " + "
        " + + tr("There is an ini file connected to this plugin. Its settings will " + "be added to your game settings, overwriting in case of conflicts."); + } + + if (esp.isLightFlagged && !esp.hasLightExtension) { + QString type = esp.hasMasterExtension ? "ESM" : "ESP"; + toolTip += + "

        " + + tr("This %1 is flagged as a light plugin (ESL). It will adhere to the %1 load " + "order but the records will be loaded in ESL space (FE/FF). You can have up " + "to 4096 light plugins in addition to other plugin types.") + .arg(type); + } else if (esp.isMediumFlagged && esp.hasMasterExtension) { + toolTip += "

        " + + tr("This ESM is flagged as a medium plugin (ESH). It adheres to the ESM " + "load order but loads records in ESH space (FD). You can have 256 " + "medium plugins in addition to other plugin types."); + } + + if (esp.isBlueprintFlagged) { + toolTip += "

        " + + tr("This plugin has the blueprint flag. This forces it to load after " + "every other non-blueprint plugin. Blueprint plugins will adhere to " + "standard load order rules with other blueprint plugins."); + } + + if (esp.isLightFlagged && esp.isMediumFlagged) { + toolTip += + "

        " + + tr("WARNING: This plugin is both light and medium flagged. " + "This could indicate that the file was saved improperly " + "and may have mismatched record references. Use it at your own risk."); + } + + if (esp.hasNoRecords) { + toolTip += "

        " + tr("This is a dummy plugin. It contains no records and is " + "typically used to load a paired archive file."); + } + + if (esp.forceDisabled) { + auto feature = m_Organizer.gameFeatures().gameFeature(); + if (feature && esp.hasLightExtension && feature->lightPluginsAreSupported()) { + toolTip += "

        " + tr("Light plugins (ESL) are not supported by this game."); + } else { + toolTip += "

        " + tr("This game does not currently permit custom plugin " + "loading. There may be manual workarounds."); + } + } + + // additional info + auto itor = m_AdditionalInfo.find(esp.name); + + if (itor != m_AdditionalInfo.end()) { + if (!itor->second.messages.isEmpty()) { + toolTip += "
          "; + + for (auto&& message : itor->second.messages) { + toolTip += "
        • " + message + "
        • "; + } + + toolTip += "
        "; + } + + // loot + toolTip += makeLootTooltip(itor->second.loot); + } + + return toolTip; +} + +QString PluginList::makeLootTooltip(const Loot::Plugin& loot) const +{ + QString s; + + for (auto&& f : loot.incompatibilities) { + s += "
      • " + + tr("Incompatible with %1") + .arg(f.displayName.isEmpty() ? f.name : f.displayName) + + "
      • "; + } + + for (auto&& m : loot.missingMasters) { + s += "
      • " + tr("Depends on missing %1").arg(m) + "
      • "; + } + + for (auto&& m : loot.messages) { + s += "
      • "; + + switch (m.type) { + case log::Warning: + s += tr("Warning") + ": "; + break; + + case log::Error: + s += tr("Error") + ": "; + break; + + case log::Info: // fall-through + case log::Debug: + default: + // nothing + break; + } + + s += m.text + "
      • "; + } + + for (auto&& d : loot.dirty) { + s += "
      • " + d.toString(false) + "
      • "; + } + + for (auto&& c : loot.clean) { + s += "
      • " + c.toString(true) + "
      • "; + } + + if (!s.isEmpty()) { + s = "
        " + "
          " + + s + "
        "; + } + + return s; +} + +QVariant PluginList::iconData(const QModelIndex& modelIndex) const +{ + int index = modelIndex.row(); + + QVariantList result; + + const auto& esp = m_ESPs[index]; + + auto infoItor = m_AdditionalInfo.find(esp.name); + + const AdditionalInfo* info = nullptr; + if (infoItor != m_AdditionalInfo.end()) { + info = &infoItor->second; + } + + if (isProblematic(esp, info)) { + result.append(":/MO/gui/warning"); + } + + if (m_LockedOrder.find(esp.name) != m_LockedOrder.end()) { + result.append(":/MO/gui/locked"); + } + + if (hasInfo(esp, info)) { + result.append(":/MO/gui/information"); + } + + if (esp.hasIni) { + result.append(":/MO/gui/attachment"); + } + + if (!esp.archives.empty()) { + result.append(":/MO/gui/archive_conflict_neutral"); + } + + if (esp.isLightFlagged && !esp.hasLightExtension) { + result.append(":/MO/gui/awaiting"); + } + + if (esp.isMediumFlagged) { + result.append(":/MO/gui/run"); + if (esp.isLightFlagged) { + result.append(":/MO/gui/warning"); + } + } + + if (esp.isBlueprintFlagged) { + result.append(":/MO/gui/resources/go-down.png"); + } + + if (esp.hasNoRecords) { + result.append(":/MO/gui/unchecked-checkbox"); + } + + if (info && !info->loot.dirty.empty()) { + result.append(":/MO/gui/edit_clear"); + } + + return result; +} + +bool PluginList::isProblematic(const ESPInfo& esp, const AdditionalInfo* info) const +{ + if (esp.masterUnset.size() > 0) { + return true; + } + + if (info) { + if (!info->loot.incompatibilities.empty()) { + return true; + } + + if (!info->loot.missingMasters.empty()) { + return true; + } + } + + return false; +} + +bool PluginList::hasInfo(const ESPInfo& esp, const AdditionalInfo* info) const +{ + if (info) { + if (!info->messages.empty()) { + return true; + } + + if (!info->loot.messages.empty()) { + return true; + } + } + + return false; +} + +bool PluginList::setData(const QModelIndex& modIndex, const QVariant& value, int role) +{ + QString modName = modIndex.data().toString(); + IPluginList::PluginStates oldState = state(modName); + + bool result = false; + + if (role == Qt::CheckStateRole) { + m_ESPs[modIndex.row()].enabled = value.toInt() == Qt::Checked || + m_ESPs[modIndex.row()].forceLoaded || + m_ESPs[modIndex.row()].forceEnabled; + m_LastCheck.restart(); + emit dataChanged(modIndex, modIndex); + + refreshLoadOrder(); + emit writePluginsList(); + + result = true; + } else if (role == Qt::EditRole) { + if (modIndex.column() == COL_PRIORITY) { + bool ok = false; + int newPriority = value.toInt(&ok); + if (ok) { + setPluginPriority(modIndex.row(), newPriority); + result = true; + } + refreshLoadOrder(); + emit writePluginsList(); + } + } + + IPluginList::PluginStates newState = state(modName); + if (oldState != newState) { + try { + pluginStatesChanged({modName}, newState); + testMasters(); + emit dataChanged(this->index(0, 0), + this->index(static_cast(m_ESPs.size()), columnCount())); + } catch (const std::exception& e) { + log::error("failed to invoke state changed notification: {}", e.what()); + } catch (...) { + log::error("failed to invoke state changed notification: unknown exception"); + } + } + + return result; +} + +QVariant PluginList::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation == Qt::Horizontal) { + if (role == Qt::DisplayRole) { + return getColumnName(section); + } else if (role == Qt::ToolTipRole) { + return getColumnToolTip(section); + } + } + return QAbstractItemModel::headerData(section, orientation, role); +} + +Qt::ItemFlags PluginList::flags(const QModelIndex& modelIndex) const +{ + int index = modelIndex.row(); + Qt::ItemFlags result = QAbstractItemModel::flags(modelIndex); + + if (modelIndex.isValid()) { + if (!m_ESPs[index].forceLoaded && !m_ESPs[index].forceDisabled) + result |= Qt::ItemIsDragEnabled; + if (!m_ESPs[index].forceLoaded && !m_ESPs[index].forceEnabled && + !m_ESPs[index].forceDisabled) + result |= Qt::ItemIsUserCheckable; + if (modelIndex.column() == COL_PRIORITY) + result |= Qt::ItemIsEditable; + result &= ~Qt::ItemIsDropEnabled; + } else { + result |= Qt::ItemIsDropEnabled; + } + + return result; +} + +void PluginList::setPluginPriority(int row, int& newPriority, bool isForced) +{ + int newPriorityTemp = newPriority; + + // enforce valid range + if (newPriorityTemp < 0) + newPriorityTemp = 0; + else if (newPriorityTemp >= static_cast(m_ESPsByPriority.size())) + newPriorityTemp = static_cast(m_ESPsByPriority.size()) - 1; + + int blueprintStartPos = 0; + for (auto esp : m_ESPs) { + if (!esp.isBlueprintFlagged) { + blueprintStartPos++; + } + } + + bool isBlueprint = m_ESPs[row].isBlueprintFlagged; + int lowerLimit = 0; + int upperLimit = 0; + if (isBlueprint) { + if (newPriorityTemp < blueprintStartPos) { + newPriorityTemp = blueprintStartPos; + } + lowerLimit = blueprintStartPos; + upperLimit = static_cast(m_ESPsByPriority.size() - 1); + } + if (!isBlueprint) { + if (newPriorityTemp >= blueprintStartPos) { + newPriorityTemp = blueprintStartPos - 1; + } + upperLimit = blueprintStartPos - 1; + } + if (!m_ESPs[row].isMasterFlagged && !m_ESPs[row].hasLightExtension && + !m_ESPs[row].hasMasterExtension) { + // don't allow esps to be moved above esms + while ((newPriorityTemp < upperLimit) && + (m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).isMasterFlagged || + m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).hasLightExtension || + m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).hasMasterExtension)) { + ++newPriorityTemp; + } + } else { + // don't allow esms to be moved below esps + while ((newPriorityTemp > lowerLimit) && + !m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).isMasterFlagged && + !m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).hasLightExtension && + !m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).hasMasterExtension) { + --newPriorityTemp; + } + // also don't allow "regular" esms to be moved above primary plugins + while ((newPriorityTemp < upperLimit) && + (m_ESPs.at(m_ESPsByPriority.at(newPriorityTemp)).forceLoaded)) { + ++newPriorityTemp; + } + } + + int oldPriority = m_ESPs.at(row).priority; + if (newPriorityTemp < oldPriority) { // moving up + // don't allow plugins to be moved above their masters + for (auto master : m_ESPs[row].masters) { + auto iter = m_ESPsByName.find(master); + if (iter != m_ESPsByName.end()) { + if (m_ESPs[iter->second].isBlueprintFlagged == + m_ESPs.at(row).isBlueprintFlagged) { + int masterPriority = m_ESPs[iter->second].priority; + if (masterPriority >= newPriorityTemp) { + newPriorityTemp = masterPriority + 1; + } + } + } + } + } else if (newPriorityTemp > oldPriority) { // moving down + // don't allow masters to be moved below their children + for (int i = oldPriority + 1; i <= newPriorityTemp; i++) { + PluginList::ESPInfo* otherInfo = &m_ESPs.at(m_ESPsByPriority[i]); + for (auto master : otherInfo->masters) { + auto iter = m_ESPsByName.find(master); + if (iter != m_ESPsByName.end()) { + if (m_ESPs[iter->second].isBlueprintFlagged == + otherInfo->isBlueprintFlagged) { + if (master.compare(m_ESPs[row].name, Qt::CaseInsensitive) == 0) { + newPriorityTemp = otherInfo->priority - 1; + break; + } + } + } + } + } + } + + try { + if (newPriorityTemp != oldPriority) { + if (newPriorityTemp > oldPriority) { + // priority is higher than the old, so the gap we left is in lower priorities + for (int i = oldPriority + 1; i <= newPriorityTemp; ++i) { + --m_ESPs.at(m_ESPsByPriority.at(i)).priority; + } + emit dataChanged(index(oldPriority + 1, 0), + index(newPriorityTemp, columnCount())); + } else { + for (int i = newPriorityTemp; i < oldPriority; ++i) { + ++m_ESPs.at(m_ESPsByPriority.at(i)).priority; + } + emit dataChanged(index(newPriorityTemp, 0), + index(oldPriority - 1, columnCount())); + ++newPriority; + } + + m_ESPs.at(row).priority = newPriorityTemp; + emit dataChanged(index(row, 0), index(row, columnCount())); + m_PluginMoved(m_ESPs[row].name, oldPriority, newPriorityTemp); + } + } catch (const std::out_of_range&) { + reportError(tr("failed to restore load order for %1").arg(m_ESPs[row].name)); + } + + updateIndices(); +} + +void PluginList::changePluginPriority(std::vector rows, int newPriority) +{ + ChangeBracket layoutChange(this); + const std::vector& esp = m_ESPs; + + // sort the moving plugins by ascending priorities + std::sort(rows.begin(), rows.end(), [&esp](const int& LHS, const int& RHS) { + return esp[LHS].priority < esp[RHS].priority; + }); + + // if at least on plugin is increasing in priority, the target index is + // that of the row BELOW the dropped location, otherwise it's the one above + for (std::vector::const_iterator iter = rows.begin(); iter != rows.end(); + ++iter) { + if (m_ESPs[*iter].priority < newPriority) { + --newPriority; + break; + } + } + + for (std::vector::const_iterator iter = rows.begin(); iter != rows.end(); + ++iter) { + setPluginPriority(*iter, newPriority); + } + + layoutChange.finish(); + refreshLoadOrder(); + emit writePluginsList(); +} + +bool PluginList::dropMimeData(const QMimeData* mimeData, Qt::DropAction action, int row, + int, const QModelIndex& parent) +{ + if (action == Qt::IgnoreAction) { + return true; + } + + QByteArray encoded = mimeData->data("application/x-qabstractitemmodeldatalist"); + QDataStream stream(&encoded, QIODevice::ReadOnly); + + std::vector sourceRows; + + while (!stream.atEnd()) { + int sourceRow, col; + QMap roleDataMap; + stream >> sourceRow >> col >> roleDataMap; + if (col == 0) { // only add each row once + sourceRows.push_back(sourceRow); + } + } + + if (row == -1) { + row = parent.row(); + } + + int newPriority; + + if ((row < 0) || (row >= static_cast(m_ESPs.size()))) { + newPriority = static_cast(m_ESPs.size()); + } else { + newPriority = m_ESPs[row].priority; + } + changePluginPriority(sourceRows, newPriority); + + return false; +} + +QModelIndex PluginList::index(int row, int column, const QModelIndex&) const +{ + if ((row < 0) || (row >= rowCount()) || (column < 0) || (column >= columnCount())) { + return QModelIndex(); + } + return createIndex(row, column, row); +} + +QModelIndex PluginList::parent(const QModelIndex&) const +{ + return QModelIndex(); +} + +PluginList::ESPInfo::ESPInfo(const QString& name, bool forceLoaded, bool forceEnabled, + bool forceDisabled, const QString& originName, + const QString& fullPath, bool hasIni, + std::set archives, bool lightSupported, + bool mediumSupported, bool blueprintSupported) + : name(name), fullPath(fullPath), enabled(forceLoaded), forceLoaded(forceLoaded), + forceEnabled(forceEnabled), forceDisabled(forceDisabled), priority(0), + loadOrder(-1), originName(originName), hasIni(hasIni), + archives(archives.begin(), archives.end()), modSelected(false), + isMasterOfSelectedPlugin(false) +{ + QString parsePath = fullPath; +#ifndef _WIN32 + if (!QFileInfo::exists(parsePath)) { + const QFileInfo fi(parsePath); + const QDir dir(fi.path()); + if (dir.exists()) { + const auto candidates = dir.entryList(QDir::Files | QDir::Readable); + const auto targetName = fi.fileName(); + const auto it = std::find_if(candidates.begin(), candidates.end(), + [&](const QString& candidate) { + return candidate.compare(targetName, + Qt::CaseInsensitive) == 0; + }); + if (it != candidates.end()) { + parsePath = dir.filePath(*it); + this->fullPath = parsePath; + log::warn("plugin path case mismatch, resolved '{}' -> '{}'", fullPath, parsePath); + } + } + } +#endif + + try { + ESP::File file(ToWString(parsePath)); + auto extension = name.right(3).toLower(); + hasMasterExtension = (extension == "esm"); + hasLightExtension = (extension == "esl"); + isMasterFlagged = file.isMaster(); + isLightFlagged = lightSupported && file.isLight(mediumSupported); + isMediumFlagged = mediumSupported && file.isMedium(); + isBlueprintFlagged = blueprintSupported && + (isMasterFlagged || hasMasterExtension || hasLightExtension) && + file.isBlueprint(); + hasNoRecords = file.isDummy(); + + formVersion = file.formVersion(); + headerVersion = file.headerVersion(); + author = QString::fromLatin1(file.author().c_str()); + description = QString::fromLatin1(file.description().c_str()); + + for (auto&& m : file.masters()) { + masters.insert(QString::fromStdString(m)); + } + } catch (const std::exception& e) { + log::error("failed to parse plugin file {}: {}", fullPath, e.what()); + hasMasterExtension = false; + hasLightExtension = false; + isMasterFlagged = false; + isLightFlagged = false; + isMediumFlagged = false; + isBlueprintFlagged = false; + hasNoRecords = false; + } +} + +void PluginList::managedGameChanged(const IPluginGame* gamePlugin) +{ + m_GamePlugin = gamePlugin; +} diff --git a/src/src/pluginlist.h b/src/src/pluginlist.h new file mode 100644 index 0000000..2cdf49a --- /dev/null +++ b/src/src/pluginlist.h @@ -0,0 +1,445 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef PLUGINLIST_H +#define PLUGINLIST_H + +#include "loot.h" +#include "profile.h" +#include +#include + +namespace MOBase +{ +class IPluginGame; +} + +#include +#include +#include +#include +#include +#include + +#pragma warning(push) +#pragma warning(disable : 4100) +#ifndef Q_MOC_RUN +#include +#include +#endif + +#include +#include + +class OrganizerCore; + +template +class ChangeBracket +{ +public: + ChangeBracket(C* model) : m_Model(nullptr) + { + QVariant var = model->property("__aboutToChange"); + bool aboutToChange = var.isValid() && var.toBool(); + if (!aboutToChange) { + model->layoutAboutToBeChanged(); + model->setProperty("__aboutToChange", true); + m_Model = model; + } + } + ~ChangeBracket() { finish(); } + + void finish() + { + if (m_Model != nullptr) { + m_Model->layoutChanged(); + m_Model->setProperty("__aboutToChange", false); + m_Model = nullptr; + } + } + +private: + C* m_Model; +}; + +/** + * @brief model representing the plugins (.esp/.esm) in the current virtual data folder + **/ +class PluginList : public QAbstractItemModel +{ + Q_OBJECT + friend class ChangeBracket; + +public: + enum EColumn + { + COL_NAME, + COL_FLAGS, + COL_PRIORITY, + COL_MODINDEX, + COL_FORMVERSION, + COL_HEADERVERSION, + COL_AUTHOR, + COL_DESCRIPTION, + + COL_LASTCOLUMN = COL_DESCRIPTION, + }; + + using PluginStates = MOBase::IPluginList::PluginStates; + + friend class PluginListProxy; + + using SignalRefreshed = boost::signals2::signal; + using SignalPluginMoved = boost::signals2::signal; + using SignalPluginStateChanged = + boost::signals2::signal&)>; + +public: + /** + * @brief constructor + * + * @param parent parent object + **/ + PluginList(OrganizerCore& organizer); + + ~PluginList(); + + /** + * @brief does a complete refresh of the list + * + * @param profileName name of the current profile + * @param baseDirectory the root directory structure representing the virtual data + *directory + * @param lockedOrderFile list of plugins that shouldn't change load order + * @todo the profile is not used? If it was, we should pass the Profile-object instead + **/ + void refresh(const QString& profileName, + const MOShared::DirectoryEntry& baseDirectory, + const QString& lockedOrderFile, bool refresh); + + /** + * @brief enable a plugin based on its name + * + * @param name name of the plugin to enable + * @param enable set to true to enable the esp, false to disable it + **/ + void enableESP(const QString& name, bool enable = true); + + /** + * @brief test if a plugin is enabled + * + * @param name name of the plugin to look up + * @return true if the plugin is enabled, false otherwise + **/ + bool isEnabled(const QString& name); + + /** + * @brief clear all additional information we stored on plugins + */ + void clearAdditionalInformation(); + + /** + * @brief reset additional information on a mod + * @param name name of the plugin to clear the information of + */ + void clearInformation(const QString& name); + + /** + * @brief add additional information on a mod (i.e. from loot) + * @param name name of the plugin to add information about + * @param message the message to add to the plugin + */ + void addInformation(const QString& name, const QString& message); + + /** + * adds information from a loot report + */ + void addLootReport(const QString& name, Loot::Plugin plugin); + + /** + * @brief test if a plugin is enabled + * + * @param index index of the plugin to look up + * @return true if the plugin is enabled, false otherwise + * @throws std::out_of_range exception is thrown if index is invalid + **/ + bool isEnabled(int index); + + /** + * @brief save the plugin status to the specified file + * + * @param lockedOrderFileName path of the lockedorder.txt to write to + **/ + void saveTo(const QString& lockedOrderFileName) const; + + /** + * @brief save the current load order + * + * the load order used by the game is defined by the last modification time which this + * function sets. An exception is newer version of skyrim where the load order is + *defined by the order of files in plugins.txt + * @param directoryStructure the root directory structure representing the virtual + *data directory + * @return true on success or if there was nothing to save, false if the load order + *can't be saved, i.e. because files are locked + * @todo since this works on actual files the load order can't be configured + *per-profile. Files of the same name in different mods can also have different load + *orders which makes this very intransparent + * @note also stores to disk the list of locked esps + **/ + bool saveLoadOrder(MOShared::DirectoryEntry& directoryStructure); + + /** + * @return number of enabled plugins in the list + */ + int enabledCount() const; + + int timeElapsedSinceLastChecked() const; + + QString getName(int index) const { return m_ESPs.at(index).name; } + int getPriority(int index) const { return m_ESPs.at(index).priority; } + QString getAuthor(int index) const { return m_ESPs.at(index).author; } + QString getDescription(int index) const { return m_ESPs.at(index).description; } + QString getIndexPriority(int index) const; + bool isESPLocked(int index) const; + void lockESPIndex(int index, bool lock); + + static QString getColumnName(int column); + static QString getColumnToolTip(int column); + + // highlight plugins contained in the mods at the given indices + // + void highlightPlugins(const std::vector& modIndices, + const MOShared::DirectoryEntry& directoryEntry); + + void highlightMasters(const QModelIndexList& selectedPluginIndices); + + void refreshLoadOrder(); + + void disconnectSlots(); + +public: + QStringList pluginNames() const; + PluginStates state(const QString& name) const; + void setState(const QString& name, PluginStates state); + int priority(const QString& name) const; + int loadOrder(const QString& name) const; + bool setPriority(const QString& name, int newPriority); + QStringList masters(const QString& name) const; + QString origin(const QString& name) const; + void setLoadOrder(const QStringList& pluginList); + + bool hasMasterExtension(const QString& name) const; + bool hasLightExtension(const QString& name) const; + bool isMasterFlagged(const QString& name) const; + bool isMediumFlagged(const QString& name) const; + bool isLightFlagged(const QString& name) const; + bool isBlueprintFlagged(const QString& name) const; + bool hasNoRecords(const QString& name) const; + + int formVersion(const QString& name) const; + float headerVersion(const QString& name) const; + QString author(const QString& name) const; + QString description(const QString& name) const; + + boost::signals2::connection onRefreshed(const std::function& callback); + boost::signals2::connection + onPluginMoved(const std::function& func); + boost::signals2::connection onPluginStateChanged( + const std::function&)>& func); + +public: // implementation of the QAbstractTableModel interface + virtual int rowCount(const QModelIndex& parent = QModelIndex()) const; + virtual int columnCount(const QModelIndex& parent = QModelIndex()) const; + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const; + virtual bool setData(const QModelIndex& index, const QVariant& value, + int role = Qt::EditRole); + virtual QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const; + virtual Qt::ItemFlags flags(const QModelIndex& index) const; + virtual Qt::DropActions supportedDropActions() const { return Qt::MoveAction; } + virtual bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, + int column, const QModelIndex& parent); + virtual QModelIndex index(int row, int column, + const QModelIndex& parent = QModelIndex()) const; + virtual QModelIndex parent(const QModelIndex& child) const; + +public slots: + + // enable/disable all plugins + // + void setEnabledAll(bool enabled); + + // enable/disable plugins at the given indices. + // + void setEnabled(const QModelIndexList& indices, bool enabled); + + // send plugins to the given priority + // + void sendToPriority(const QModelIndexList& indices, int priority); + + // shift the priority of mods at the given indices by the given offset + // + void shiftPluginsPriority(const QModelIndexList& indices, int offset); + + // toggle the active state of mods at the given indices + // + void toggleState(const QModelIndexList& indices); + + /** + * @brief The currently managed game has changed + * @param gamePlugin + */ + void managedGameChanged(MOBase::IPluginGame const* gamePlugin); + + /** + * @brief Generate the plugin indexes because something was changed + **/ + void generatePluginIndexes(); + +signals: + + /** + * @brief emitted when the plugin list changed, i.e. the load order was modified or a + *plugin was checked/unchecked + * @note this is currently only used to signal that there are changes that can be + *saved, it does not immediately cause anything to be written to disc + **/ + void esplist_changed(); + + void writePluginsList(); + +private: + struct ESPInfo + { + ESPInfo(const QString& name, bool forceLoaded, bool forceEnabled, + bool forceDisabled, const QString& originName, const QString& fullPath, + bool hasIni, std::set archives, bool lightSupported, + bool mediumSupported, bool blueprintSupported); + + QString name; + QString fullPath; + bool enabled; + bool forceLoaded; + bool forceEnabled; + bool forceDisabled; + int priority; + QString index; + int loadOrder; + FILETIME time; + QString originName; + bool hasMasterExtension; + bool hasLightExtension; + bool isMasterFlagged; + bool isMediumFlagged; + bool isLightFlagged; + bool isBlueprintFlagged; + bool hasNoRecords; + bool modSelected; + bool isMasterOfSelectedPlugin; + int formVersion; + float headerVersion; + QString author; + QString description; + bool hasIni; + std::set archives; + std::set masters; + mutable std::set masterUnset; + + bool operator<(const ESPInfo& str) const { return (loadOrder < str.loadOrder); } + }; + + struct AdditionalInfo + { + QStringList messages; + Loot::Plugin loot; + }; + +private: + void syncLoadOrder(); + void updateIndices(); + + void writeLockedOrder(const QString& fileName) const; + + void readLockedOrderFrom(const QString& fileName); + void setPluginPriority(int row, int& newPriority, bool isForced = false); + void changePluginPriority(std::vector rows, int newPriority); + + void testMasters(); + + void fixPrimaryPlugins(); + void fixPriorities(); + void fixPluginRelationships(); + + int findPluginByPriority(int priority); + + /** + * @brief Notify MO2 plugins that the states of the given plugins have changed to the + * given state. + * + * @param pluginNames Names of the plugin. + * @param state New state of the plugin. + * + */ + void pluginStatesChanged(QStringList const& pluginNames, PluginStates state) const; + +private: + OrganizerCore& m_Organizer; + + std::vector m_ESPs; + mutable std::map m_LastSaveHash; + + std::map m_ESPsByName; + std::vector m_ESPsByPriority; + + std::map m_LockedOrder; + + std::map + m_AdditionalInfo; // maps esp names to boss information + + QString m_CurrentProfile; + QFontMetrics m_FontMetrics; + + SignalRefreshed m_Refreshed; + SignalPluginMoved m_PluginMoved; + SignalPluginStateChanged m_PluginStateChanged; + + QTemporaryFile m_TempFile; + + QElapsedTimer m_LastCheck; + + const MOBase::IPluginGame* m_GamePlugin; + + QVariant displayData(const QModelIndex& modelIndex) const; + QVariant checkstateData(const QModelIndex& modelIndex) const; + QVariant foregroundData(const QModelIndex& modelIndex) const; + QVariant backgroundData(const QModelIndex& modelIndex) const; + QVariant fontData(const QModelIndex& modelIndex) const; + QVariant alignmentData(const QModelIndex& modelIndex) const; + QVariant tooltipData(const QModelIndex& modelIndex) const; + QVariant iconData(const QModelIndex& modelIndex) const; + + QString makeLootTooltip(const Loot::Plugin& loot) const; + bool isProblematic(const ESPInfo& esp, const AdditionalInfo* info) const; + bool hasInfo(const ESPInfo& esp, const AdditionalInfo* info) const; +}; + +#pragma warning(pop) + +#endif // PLUGINLIST_H diff --git a/src/src/pluginlistcontextmenu.cpp b/src/src/pluginlistcontextmenu.cpp new file mode 100644 index 0000000..39f2065 --- /dev/null +++ b/src/src/pluginlistcontextmenu.cpp @@ -0,0 +1,165 @@ +#include "pluginlistcontextmenu.h" + +#include +#include + +#include "organizercore.h" +#include "pluginlistview.h" + +using namespace MOBase; + +PluginListContextMenu::PluginListContextMenu(const QModelIndex& index, + OrganizerCore& core, PluginListView* view) + : QMenu(view), m_core(core), + m_index(index.model() == view->model() ? view->indexViewToModel(index) : index), + m_view(view) +{ + if (view->selectionModel()->hasSelection()) { + m_selected = view->indexViewToModel(view->selectionModel()->selectedRows()); + } else if (index.isValid()) { + m_selected = {index}; + } + + if (!m_selected.isEmpty()) { + addAction(tr("Enable selected"), [=]() { + m_core.pluginList()->setEnabled(m_selected, true); + }); + addAction(tr("Disable selected"), [=]() { + m_core.pluginList()->setEnabled(m_selected, false); + }); + + addSeparator(); + } + + addAction(tr("Enable all"), [=]() { + if (QMessageBox::question(m_view->topLevelWidget(), tr("Confirm"), + tr("Really enable all plugins?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_core.pluginList()->setEnabledAll(true); + } + }); + addAction(tr("Disable all"), [=]() { + if (QMessageBox::question(m_view->topLevelWidget(), tr("Confirm"), + tr("Really disable all plugins?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_core.pluginList()->setEnabledAll(false); + } + }); + + if (!m_selected.isEmpty()) { + addSeparator(); + addMenu(createSendToContextMenu()); + + addSeparator(); + + bool hasLocked = false; + bool hasUnlocked = false; + for (auto& idx : m_selected) { + if (m_core.pluginList()->isEnabled(idx.row())) { + if (m_core.pluginList()->isESPLocked(idx.row())) { + hasLocked = true; + } else { + hasUnlocked = true; + } + } + } + + if (hasLocked) { + addAction(tr("Unlock load order"), [=]() { + setESPLock(m_selected, false); + }); + } + if (hasUnlocked) { + addAction(tr("Lock load order"), [=]() { + setESPLock(m_selected, true); + }); + } + } + + if (m_index.isValid()) { + addSeparator(); + + unsigned int modInfoIndex = + ModInfo::getIndex(m_core.pluginList()->origin(m_index.data().toString())); + // this is to avoid showing the option on game files like skyrim.esm + if (modInfoIndex != UINT_MAX) { + addAction(tr("Open Origin in Explorer"), [=]() { + openOriginExplorer(m_selected); + }); + ModInfo::Ptr modInfo = ModInfo::getByIndex(modInfoIndex); + std::vector flags = modInfo->getFlags(); + + if (!modInfo->isForeign() && m_selected.size() == 1) { + QAction* infoAction = addAction(tr("Open Origin Info..."), [=]() { + openOriginInformation(index); + }); + setDefaultAction(infoAction); + } + } + } +} + +QMenu* PluginListContextMenu::createSendToContextMenu() +{ + QMenu* menu = new QMenu(m_view); + menu->setTitle(tr("Send to... ")); + menu->addAction(tr("Top"), [=]() { + m_core.pluginList()->sendToPriority(m_selected, 0); + }); + menu->addAction(tr("Bottom"), [=]() { + m_core.pluginList()->sendToPriority(m_selected, INT_MAX); + }); + menu->addAction(tr("Priority..."), [=]() { + sendPluginsToPriority(m_selected); + }); + return menu; +} + +void PluginListContextMenu::sendPluginsToPriority(const QModelIndexList& indices) +{ + bool ok; + int newPriority = QInputDialog::getInt(m_view->topLevelWidget(), tr("Set Priority"), + tr("Set the priority of the selected plugins"), + 0, 0, INT_MAX, 1, &ok); + if (!ok) + return; + + m_core.pluginList()->sendToPriority(m_selected, newPriority); +} + +void PluginListContextMenu::setESPLock(const QModelIndexList& indices, bool locked) +{ + for (auto& idx : indices) { + if (m_core.pluginList()->isEnabled(idx.row())) { + m_core.pluginList()->lockESPIndex(idx.row(), locked); + } + } +} + +void PluginListContextMenu::openOriginExplorer(const QModelIndexList& indices) +{ + for (auto& idx : indices) { + QString fileName = idx.data().toString(); + unsigned int modIndex = ModInfo::getIndex(m_core.pluginList()->origin(fileName)); + if (modIndex == UINT_MAX) { + continue; + } + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); + shell::Explore(modInfo->absolutePath()); + } +} + +void PluginListContextMenu::openOriginInformation(const QModelIndex& index) +{ + try { + QString fileName = index.data().toString(); + unsigned int modIndex = ModInfo::getIndex(m_core.pluginList()->origin(fileName)); + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); + + if (modInfo->isRegular() || modInfo->isOverwrite()) { + emit openModInformation(modIndex); + } + } catch (const std::exception& e) { + reportError(e.what()); + } +} diff --git a/src/src/pluginlistcontextmenu.h b/src/src/pluginlistcontextmenu.h new file mode 100644 index 0000000..b944577 --- /dev/null +++ b/src/src/pluginlistcontextmenu.h @@ -0,0 +1,51 @@ +#ifndef PLUGINLISTCONTEXTMENU_H +#define PLUGINLISTCONTEXTMENU_H + +#include +#include +#include + +#include "modinfo.h" + +class PluginListView; +class OrganizerCore; + +class PluginListContextMenu : public QMenu +{ + Q_OBJECT + +public: + // creates a new context menu, the given index is the one for the click and should be + // valid + // + PluginListContextMenu(const QModelIndex& index, OrganizerCore& core, + PluginListView* modListView); + +signals: + + // emitted to open a mod information + // + void openModInformation(unsigned int modIndex); + +public: + // create the "Send to... " context menu + // + QMenu* createSendToContextMenu(); + void sendPluginsToPriority(const QModelIndexList& indices); + + // set ESP lock on the given plugins + // + void setESPLock(const QModelIndexList& indices, bool locked); + + // open explorer or mod information for the origin of the plugins + // + void openOriginExplorer(const QModelIndexList& indices); + void openOriginInformation(const QModelIndex& index); + + OrganizerCore& m_core; + QModelIndex m_index; + QModelIndexList m_selected; + PluginListView* m_view; +}; + +#endif diff --git a/src/src/pluginlistproxy.cpp b/src/src/pluginlistproxy.cpp new file mode 100644 index 0000000..cb1cc7c --- /dev/null +++ b/src/src/pluginlistproxy.cpp @@ -0,0 +1,155 @@ +#include "pluginlistproxy.h" +#include "organizerproxy.h" +#include "proxyutils.h" + +using namespace MOBase; +using namespace MOShared; + +PluginListProxy::PluginListProxy(OrganizerProxy* oproxy, PluginList* pluginlist) + : m_OrganizerProxy(oproxy), m_Proxied(pluginlist) +{} + +PluginListProxy::~PluginListProxy() +{ + disconnectSignals(); +} + +void PluginListProxy::connectSignals() +{ + m_Connections.push_back( + m_Proxied->onRefreshed(callSignalIfPluginActive(m_OrganizerProxy, m_Refreshed))); + m_Connections.push_back(m_Proxied->onPluginMoved( + callSignalIfPluginActive(m_OrganizerProxy, m_PluginMoved))); + m_Connections.push_back(m_Proxied->onPluginStateChanged( + callSignalIfPluginActive(m_OrganizerProxy, m_PluginStateChanged))); +} + +void PluginListProxy::disconnectSignals() +{ + for (auto& conn : m_Connections) { + conn.disconnect(); + } + m_Connections.clear(); +} + +QStringList PluginListProxy::pluginNames() const +{ + return m_Proxied->pluginNames(); +} + +IPluginList::PluginStates PluginListProxy::state(const QString& name) const +{ + return m_Proxied->state(name); +} + +void PluginListProxy::setState(const QString& name, PluginStates state) +{ + return m_Proxied->setState(name, state); +} + +int PluginListProxy::priority(const QString& name) const +{ + return m_Proxied->priority(name); +} + +bool PluginListProxy::setPriority(const QString& name, int newPriority) +{ + return m_Proxied->setPriority(name, newPriority); +} + +int PluginListProxy::loadOrder(const QString& name) const +{ + return m_Proxied->loadOrder(name); +} + +void PluginListProxy::setLoadOrder(const QStringList& pluginList) +{ + return m_Proxied->setLoadOrder(pluginList); +} + +bool PluginListProxy::isMaster(const QString& name) const +{ + return m_Proxied->isMasterFlagged(name); +} + +QStringList PluginListProxy::masters(const QString& name) const +{ + return m_Proxied->masters(name); +} + +QString PluginListProxy::origin(const QString& name) const +{ + return m_Proxied->origin(name); +} + +bool PluginListProxy::onRefreshed(const std::function& func) +{ + return m_Refreshed.connect(func).connected(); +} + +bool PluginListProxy::onPluginMoved( + const std::function& func) +{ + return m_PluginMoved.connect(func).connected(); +} + +bool PluginListProxy::onPluginStateChanged( + const std::function&)>& func) +{ + return m_PluginStateChanged.connect(func).connected(); +} + +bool PluginListProxy::hasMasterExtension(const QString& name) const +{ + return m_Proxied->hasMasterExtension(name); +} + +bool PluginListProxy::hasLightExtension(const QString& name) const +{ + return m_Proxied->hasLightExtension(name); +} + +bool PluginListProxy::isMasterFlagged(const QString& name) const +{ + return m_Proxied->isMasterFlagged(name); +} + +bool PluginListProxy::isMediumFlagged(const QString& name) const +{ + return m_Proxied->isMediumFlagged(name); +} + +bool PluginListProxy::isLightFlagged(const QString& name) const +{ + return m_Proxied->isLightFlagged(name); +} + +bool PluginListProxy::isBlueprintFlagged(const QString& name) const +{ + return m_Proxied->isBlueprintFlagged(name); +} + +bool PluginListProxy::hasNoRecords(const QString& name) const +{ + return m_Proxied->hasNoRecords(name); +} + +int PluginListProxy::formVersion(const QString& name) const +{ + return m_Proxied->formVersion(name); +} + +float PluginListProxy::headerVersion(const QString& name) const +{ + return m_Proxied->headerVersion(name); +} + +QString PluginListProxy::author(const QString& name) const +{ + return m_Proxied->author(name); +} + +QString PluginListProxy::description(const QString& name) const +{ + return m_Proxied->description(name); +} diff --git a/src/src/pluginlistproxy.h b/src/src/pluginlistproxy.h new file mode 100644 index 0000000..dbcccea --- /dev/null +++ b/src/src/pluginlistproxy.h @@ -0,0 +1,62 @@ +#ifndef PLUGINLISTPROXY_H +#define PLUGINLISTPROXY_H + +#include "pluginlist.h" +#include + +class OrganizerProxy; + +class PluginListProxy : public MOBase::IPluginList +{ +public: + PluginListProxy(OrganizerProxy* oproxy, PluginList* pluginlist); + virtual ~PluginListProxy(); + + QStringList pluginNames() const override; + PluginStates state(const QString& name) const override; + void setState(const QString& name, PluginStates state) override; + int priority(const QString& name) const override; + bool setPriority(const QString& name, int newPriority) override; + int loadOrder(const QString& name) const override; + void setLoadOrder(const QStringList& pluginList) override; + [[deprecated]] bool isMaster(const QString& name) const override; + QStringList masters(const QString& name) const override; + QString origin(const QString& name) const override; + + bool onRefreshed(const std::function& callback) override; + bool + onPluginMoved(const std::function& func) override; + bool onPluginStateChanged( + const std::function&)>& func) override; + + bool hasMasterExtension(const QString& name) const override; + bool hasLightExtension(const QString& name) const override; + bool isMasterFlagged(const QString& name) const override; + bool isMediumFlagged(const QString& name) const override; + bool isLightFlagged(const QString& name) const override; + bool isBlueprintFlagged(const QString& name) const override; + bool hasNoRecords(const QString& name) const override; + + int formVersion(const QString& name) const override; + float headerVersion(const QString& name) const override; + QString author(const QString& name) const override; + QString description(const QString& name) const override; + +private: + friend class OrganizerProxy; + + // See OrganizerProxy::connectSignals(). + void connectSignals(); + void disconnectSignals(); + + OrganizerProxy* m_OrganizerProxy; + PluginList* m_Proxied; + + PluginList::SignalRefreshed m_Refreshed; + PluginList::SignalPluginMoved m_PluginMoved; + PluginList::SignalPluginStateChanged m_PluginStateChanged; + + std::vector m_Connections; +}; + +#endif // ORGANIZERPROXY_H diff --git a/src/src/pluginlistsortproxy.cpp b/src/src/pluginlistsortproxy.cpp new file mode 100644 index 0000000..0d42ce9 --- /dev/null +++ b/src/src/pluginlistsortproxy.cpp @@ -0,0 +1,164 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "pluginlistsortproxy.h" +#include "messagedialog.h" +#include +#include +#include +#include +#include + +PluginListSortProxy::PluginListSortProxy(QObject* parent) + : QSortFilterProxyModel(parent), m_SortIndex(0), m_SortOrder(Qt::AscendingOrder) +{ + m_EnabledColumns.set(PluginList::COL_NAME); + m_EnabledColumns.set(PluginList::COL_PRIORITY); + m_EnabledColumns.set(PluginList::COL_MODINDEX); + this->setDynamicSortFilter(true); +} + +void PluginListSortProxy::setEnabledColumns(unsigned int columns) +{ + emit layoutAboutToBeChanged(); + for (int i = 0; i <= PluginList::COL_LASTCOLUMN; ++i) { + m_EnabledColumns.set(i, (columns & (1 << i)) != 0); + } + emit layoutChanged(); +} + +void PluginListSortProxy::updateFilter(const QString& filter) +{ + m_CurrentFilter = filter; + invalidateFilter(); +} + +bool PluginListSortProxy::filterAcceptsRow(int row, const QModelIndex&) const +{ + return filterMatchesPlugin( + sourceModel()->data(sourceModel()->index(row, 0)).toString()); +} + +bool PluginListSortProxy::lessThan(const QModelIndex& left, + const QModelIndex& right) const +{ + PluginList* plugins = qobject_cast(sourceModel()); + switch (left.column()) { + case PluginList::COL_NAME: { + return QString::compare(plugins->getName(left.row()), plugins->getName(right.row()), + Qt::CaseInsensitive) < 0; + } break; + case PluginList::COL_FLAGS: { + QVariantList lhsList = left.data(Qt::UserRole + 1).toList(); + QVariantList rhsList = right.data(Qt::UserRole + 1).toList(); + if (lhsList.size() != rhsList.size()) { + return lhsList.size() < rhsList.size(); + } else { + for (int i = 0; i < lhsList.size(); ++i) { + if (lhsList.at(i) != rhsList.at(i)) { + return lhsList.at(i).toString() < rhsList.at(i).toString(); + } + } + return false; + } + } break; + case PluginList::COL_MODINDEX: { + QString leftVal = plugins->getIndexPriority(left.row()); + QString rightVal = plugins->getIndexPriority(right.row()); + return leftVal < rightVal; + } break; + case PluginList::COL_AUTHOR: { + return QString::compare(plugins->getAuthor(left.row()), + plugins->getAuthor(right.row()), Qt::CaseInsensitive) < 0; + } break; + case PluginList::COL_DESCRIPTION: { + return QString::compare(plugins->getDescription(left.row()), + plugins->getDescription(right.row()), + Qt::CaseInsensitive) < 0; + } break; + default: { + return plugins->getPriority(left.row()) < plugins->getPriority(right.row()); + } break; + } +} + +bool PluginListSortProxy::dropMimeData(const QMimeData* data, Qt::DropAction action, + int row, int column, const QModelIndex& parent) +{ + if ((sortColumn() != PluginList::COL_PRIORITY) && + (sortColumn() != PluginList::COL_MODINDEX)) { + QWidget* wid = qApp->activeWindow()->findChild("espList"); + MessageDialog::showMessage( + tr("Drag&Drop is only supported when sorting by priority or mod index"), wid); + return false; + } + + if ((row == -1) && (column == -1)) { + return this->sourceModel()->dropMimeData(data, action, -1, -1, mapToSource(parent)); + } + // in the regular model, when dropping between rows, the row-value passed to + // the sourceModel is inconsistent between ascending and descending ordering. + // This should fix that + if (sortOrder() == Qt::DescendingOrder) { + --row; + } + + QModelIndex proxyIndex = index(row, column, parent); + QModelIndex sourceIndex = mapToSource(proxyIndex); + return this->sourceModel()->dropMimeData(data, action, sourceIndex.row(), + sourceIndex.column(), sourceIndex.parent()); +} + +bool PluginListSortProxy::filterMatchesPlugin(const QString& plugin) const +{ + if (!m_CurrentFilter.isEmpty()) { + + bool display = false; + QString filterCopy = QString(m_CurrentFilter); + filterCopy.replace("||", ";").replace("OR", ";").replace("|", ";"); + QStringList ORList = filterCopy.split(";", Qt::SkipEmptyParts); + + bool segmentGood = true; + + // split in ORSegments that internally use AND logic + for (auto& ORSegment : ORList) { + QStringList ANDKeywords = ORSegment.split(" ", Qt::SkipEmptyParts); + segmentGood = true; + + // check each word in the segment for match, each word needs to be matched but it + // doesn't matter where. + for (auto& currentKeyword : ANDKeywords) { + if (!plugin.contains(currentKeyword, Qt::CaseInsensitive)) { + segmentGood = false; + break; + } + } + + if (segmentGood) { + // the last AND loop didn't break so the ORSegments is true so mod matches + // filter + return true; + } + + } // for ORList loop + + return false; + } else + return true; +} diff --git a/src/src/pluginlistsortproxy.h b/src/src/pluginlistsortproxy.h new file mode 100644 index 0000000..370dd87 --- /dev/null +++ b/src/src/pluginlistsortproxy.h @@ -0,0 +1,63 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef PLUGINLISTSORTPROXY_H +#define PLUGINLISTSORTPROXY_H + +#include "pluginlist.h" +#include +#include + +class PluginListSortProxy : public QSortFilterProxyModel +{ + Q_OBJECT +public: + enum ESorting + { + SORT_ASCENDING, + SORT_DESCENDING + }; + +public: + explicit PluginListSortProxy(QObject* parent = 0); + + void setEnabledColumns(unsigned int columns); + + virtual bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, + int column, const QModelIndex& parent); + + bool filterMatchesPlugin(const QString& plugin) const; + +public slots: + + void updateFilter(const QString& filter); + +protected: + virtual bool filterAcceptsRow(int row, const QModelIndex& parent) const; + virtual bool lessThan(const QModelIndex& left, const QModelIndex& right) const; + +private: + int m_SortIndex; + Qt::SortOrder m_SortOrder; + + std::bitset m_EnabledColumns; + QString m_CurrentFilter; +}; + +#endif // PLUGINLISTSORTPROXY_H diff --git a/src/src/pluginlistview.cpp b/src/src/pluginlistview.cpp new file mode 100644 index 0000000..e90b8b8 --- /dev/null +++ b/src/src/pluginlistview.cpp @@ -0,0 +1,420 @@ +#include "pluginlistview.h" + +#include +#include + +#include +#include + +#include "copyeventfilter.h" +#include "gameplugins.h" +#include "genericicondelegate.h" +#include "mainwindow.h" +#include "modelutils.h" +#include "modlistview.h" +#include "modlistviewactions.h" +#include "organizercore.h" +#include "pluginlistcontextmenu.h" +#include "pluginlistsortproxy.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "ui_mainwindow.h" + +using namespace MOBase; + +PluginListView::PluginListView(QWidget* parent) + : QTreeView(parent), m_sortProxy(nullptr), + m_Scrollbar(new ViewMarkingScrollBar(this, Qt::BackgroundRole)), + m_didUpdateMasterList(false) +{ + setVerticalScrollBar(m_Scrollbar); + MOBase::setCustomizableColumns(this); + installEventFilter(new CopyEventFilter(this)); +} + +void PluginListView::activated() +{ + // update highlighted mods + selectionModel()->selectionChanged({}, {}); +} + +int PluginListView::sortColumn() const +{ + return m_sortProxy ? m_sortProxy->sortColumn() : -1; +} + +QModelIndex PluginListView::indexModelToView(const QModelIndex& index) const +{ + return MOShared::indexModelToView(index, this); +} + +QModelIndexList PluginListView::indexModelToView(const QModelIndexList& index) const +{ + return MOShared::indexModelToView(index, this); +} + +QModelIndex PluginListView::indexViewToModel(const QModelIndex& index) const +{ + return MOShared::indexViewToModel(index, m_core->pluginList()); +} + +QModelIndexList PluginListView::indexViewToModel(const QModelIndexList& index) const +{ + return MOShared::indexViewToModel(index, m_core->pluginList()); +} + +void PluginListView::updatePluginCount() +{ + int activeMasterCount = 0; + int activeMediumMasterCount = 0; + int activeLightMasterCount = 0; + int activeBlueprintCount = 0; + int activeRegularCount = 0; + int masterCount = 0; + int mediumMasterCount = 0; + int lightMasterCount = 0; + int blueprintCount = 0; + int regularCount = 0; + int activeVisibleCount = 0; + + PluginList* list = m_core->pluginList(); + QString filter = ui.filter->text(); + + for (QString plugin : list->pluginNames()) { + bool active = list->isEnabled(plugin); + bool visible = m_sortProxy->filterMatchesPlugin(plugin); + if (list->isMediumFlagged(plugin)) { + mediumMasterCount++; + activeMediumMasterCount += active; + activeVisibleCount += visible && active; + } else if (list->hasLightExtension(plugin) || list->isLightFlagged(plugin)) { + lightMasterCount++; + activeLightMasterCount += active; + activeVisibleCount += visible && active; + } else if (list->hasMasterExtension(plugin) || list->isMasterFlagged(plugin)) { + masterCount++; + activeMasterCount += active; + activeVisibleCount += visible && active; + } else { + regularCount++; + activeRegularCount += active; + activeVisibleCount += visible && active; + } + + if (list->isBlueprintFlagged(plugin)) { + // separate if-statement because blueprint masters are also counted as + // (medium/light) masters + blueprintCount++; + activeBlueprintCount += active; + } + } + + int activeCount = activeMasterCount + activeMediumMasterCount + + activeLightMasterCount + activeRegularCount; + int totalCount = masterCount + mediumMasterCount + lightMasterCount + regularCount; + + auto toolTip = + tr("" + "" + "" + "" + "" + "") + .arg(activeCount) + .arg(totalCount) + .arg(activeMasterCount) + .arg(masterCount) + .arg(activeRegularCount) + .arg(regularCount) + .arg(activeMasterCount + activeRegularCount) + .arg(masterCount + regularCount); + + auto gamePlugins = m_core->gameFeatures().gameFeature(); + const bool lightPluginsAreSupported = + gamePlugins ? gamePlugins->lightPluginsAreSupported() : false; + const bool mediumPluginsAreSupported = + gamePlugins ? gamePlugins->mediumPluginsAreSupported() : false; + const bool blueprintPluginsAreSupported = + gamePlugins ? gamePlugins->blueprintPluginsAreSupported() : false; + + if (mediumPluginsAreSupported) { + toolTip += + tr("") + .arg(activeMediumMasterCount) + .arg(mediumMasterCount); + } + if (lightPluginsAreSupported) { + toolTip += + tr("") + .arg(activeLightMasterCount) + .arg(lightMasterCount); + } + if (blueprintPluginsAreSupported) { + toolTip += tr("") + .arg(activeBlueprintCount) + .arg(blueprintCount); + } + + toolTip += "
        TypeActive Total
        All plugins:%1 %2
        ESMs:%3 %4
        ESPs:%5 %6
        ESMs+ESPs:%7 %8
        ESHs:%1 %2
        ESLs:%1 %2
        Blueprint masters:%1 %2
        "; + + ui.counter->display(activeVisibleCount); + ui.counter->setToolTip(toolTip); +} + +void PluginListView::onFilterChanged(const QString& filter) +{ + if (!filter.isEmpty()) { + setStyleSheet("QTreeView { border: 2px ridge #f00; }"); + ui.counter->setStyleSheet("QLCDNumber { border: 2px ridge #f00; }"); + } else { + setStyleSheet(""); + ui.counter->setStyleSheet(""); + } + updatePluginCount(); +} + +void PluginListView::onSortButtonClicked() +{ + const bool offline = m_core->settings().network().offlineMode(); + + auto r = QMessageBox::No; + + if (offline) { + r = QMessageBox::question(topLevelWidget(), tr("Sorting plugins"), + tr("Are you sure you want to sort your plugins list?") + + "\r\n\r\n" + + tr("Note: You are currently in offline mode and LOOT " + "will not update the master list."), + QMessageBox::Yes | QMessageBox::No); + } else { + r = QMessageBox::question(topLevelWidget(), tr("Sorting plugins"), + tr("Are you sure you want to sort your plugins list?"), + QMessageBox::Yes | QMessageBox::No); + } + + if (r != QMessageBox::Yes) { + return; + } + + m_core->savePluginList(); + + topLevelWidget()->setEnabled(false); + Guard g([=]() { + topLevelWidget()->setEnabled(true); + }); + + // don't try to update the master list in offline mode + const bool didUpdateMasterList = offline ? true : m_didUpdateMasterList; + + if (runLoot(topLevelWidget(), *m_core, didUpdateMasterList)) { + // don't assume the master list was updated in offline mode + if (!offline) { + m_didUpdateMasterList = true; + } + + m_core->refreshESPList(false); + m_core->savePluginList(); + } +} + +std::pair PluginListView::selected() const +{ + return {indexViewToModel(currentIndex()), + indexViewToModel(selectionModel()->selectedRows())}; +} + +void PluginListView::setSelected(const QModelIndex& current, + const QModelIndexList& selected) +{ + setCurrentIndex(indexModelToView(current)); + for (auto idx : selected) { + selectionModel()->select(indexModelToView(idx), + QItemSelectionModel::Select | QItemSelectionModel::Rows); + } +} + +void PluginListView::setup(OrganizerCore& core, MainWindow* mw, Ui::MainWindow* mwui) +{ + m_core = &core; + ui = {mwui->activePluginsCounter, mwui->espFilterEdit}; + m_modActions = &mwui->modList->actions(); + + m_sortProxy = new PluginListSortProxy(&core); + m_sortProxy->setSourceModel(core.pluginList()); + setModel(m_sortProxy); + + sortByColumn(PluginList::COL_PRIORITY, Qt::AscendingOrder); + setItemDelegateForColumn(PluginList::COL_FLAGS, new GenericIconDelegate(this)); + + // counter + connect(core.pluginList(), &PluginList::writePluginsList, [=] { + updatePluginCount(); + }); + connect(core.pluginList(), &PluginList::esplist_changed, [=] { + updatePluginCount(); + }); + + // sort + connect(mwui->sortButton, &QPushButton::clicked, [=] { + onSortButtonClicked(); + }); + + // filter + connect(ui.filter, &QLineEdit::textChanged, m_sortProxy, + &PluginListSortProxy::updateFilter); + connect(ui.filter, &QLineEdit::textChanged, this, &PluginListView::onFilterChanged); + + // highlight mod list when selected + connect(selectionModel(), &QItemSelectionModel::selectionChanged, [=] { + std::set mods; + auto& directoryEntry = *m_core->directoryStructure(); + auto pluginIndices = indexViewToModel(selectionModel()->selectedRows()); + for (auto& idx : pluginIndices) { + QString pluginName = m_core->pluginList()->getName(idx.row()); + + const MOShared::FileEntryPtr fileEntry = + directoryEntry.findFile(pluginName.toStdWString()); + if (fileEntry.get() != nullptr) { + QString originName = QString::fromStdWString( + directoryEntry.getOriginByID(fileEntry->getOrigin()).getName()); + mods.insert(originName); + } + } + mwui->modList->setHighlightedMods(mods); + m_core->pluginList()->highlightMasters(pluginIndices); + repaint(); + verticalScrollBar()->repaint(); + }); + + // using a lambda here to avoid storing the mod list actions + connect(this, &QTreeView::customContextMenuRequested, [=](auto&& pos) { + onCustomContextMenuRequested(pos); + }); + connect(this, &QTreeView::doubleClicked, [=](auto&& index) { + onDoubleClicked(index); + }); +} + +void PluginListView::onCustomContextMenuRequested(const QPoint& pos) +{ + try { + PluginListContextMenu menu(indexViewToModel(indexAt(pos)), *m_core, this); + connect(&menu, &PluginListContextMenu::openModInformation, [=](auto&& modIndex) { + m_modActions->displayModInformation(modIndex); + }); + menu.exec(viewport()->mapToGlobal(pos)); + } catch (const std::exception& e) { + reportError(tr("Exception: ").arg(e.what())); + } catch (...) { + reportError(tr("Unknown exception")); + } +} + +void PluginListView::onDoubleClicked(const QModelIndex& index) +{ + if (!index.isValid()) { + return; + } + + if (m_core->pluginList()->timeElapsedSinceLastChecked() <= + QApplication::doubleClickInterval()) { + // don't interpret double click if we only just checked a plugin + return; + } + + try { + if (selectionModel()->hasSelection() && + selectionModel()->selectedRows().count() == 1) { + + QModelIndex idx = selectionModel()->currentIndex(); + QString fileName = idx.data().toString(); + + if (ModInfo::getIndex(m_core->pluginList()->origin(fileName)) == UINT_MAX) { + return; + } + + auto modIndex = ModInfo::getIndex(m_core->pluginList()->origin(fileName)); + ModInfo::Ptr modInfo = ModInfo::getByIndex(modIndex); + + if (modInfo->isRegular() || modInfo->isOverwrite()) { + + Qt::KeyboardModifiers modifiers = QApplication::queryKeyboardModifiers(); + if (modifiers.testFlag(Qt::ControlModifier)) { + m_modActions->openExplorer({m_core->modList()->index(modIndex, 0)}); + } else { + m_modActions->displayModInformation( + ModInfo::getIndex(m_core->pluginList()->origin(fileName))); + } + + // workaround to cancel the editor that might have opened because of + // selection-click + closePersistentEditor(index); + } + } + } catch (const std::exception& e) { + reportError(e.what()); + } +} + +bool PluginListView::moveSelection(int key) +{ + auto [cindex, sourceRows] = selected(); + + int offset = key == Qt::Key_Up ? -1 : 1; + if (m_sortProxy->sortOrder() == Qt::DescendingOrder) { + offset = -offset; + } + + m_core->pluginList()->shiftPluginsPriority(sourceRows, offset); + + // reset the selection and the index + setSelected(cindex, sourceRows); + + return true; +} + +bool PluginListView::toggleSelectionState() +{ + if (!selectionModel()->hasSelection()) { + return true; + } + m_core->pluginList()->toggleState(indexViewToModel(selectionModel()->selectedRows())); + return true; +} + +bool PluginListView::event(QEvent* event) +{ + if (event->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(event); + + if (keyEvent->modifiers() == Qt::ControlModifier && + (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter)) { + if (selectionModel()->hasSelection() && + selectionModel()->selectedRows().count() == 1) { + QModelIndex idx = selectionModel()->currentIndex(); + QString fileName = idx.data().toString(); + + if (ModInfo::getIndex(m_core->pluginList()->origin(fileName)) == UINT_MAX) { + return false; + } + + auto modIndex = ModInfo::getIndex(m_core->pluginList()->origin(fileName)); + m_modActions->openExplorer({m_core->modList()->index(modIndex, 0)}); + return true; + } + } else if (keyEvent->modifiers() == Qt::ControlModifier && + (sortColumn() == PluginList::COL_PRIORITY || + sortColumn() == PluginList::COL_MODINDEX) && + (keyEvent->key() == Qt::Key_Up || keyEvent->key() == Qt::Key_Down)) { + return moveSelection(keyEvent->key()); + } else if (keyEvent->key() == Qt::Key_Space) { + return toggleSelectionState(); + } + return QTreeView::event(event); + } + return QTreeView::event(event); +} diff --git a/src/src/pluginlistview.h b/src/src/pluginlistview.h new file mode 100644 index 0000000..e27f290 --- /dev/null +++ b/src/src/pluginlistview.h @@ -0,0 +1,86 @@ +#ifndef PLUGINLISTVIEW_H +#define PLUGINLISTVIEW_H + +#include "viewmarkingscrollbar.h" +#include +#include + +namespace Ui +{ +class MainWindow; +} + +class OrganizerCore; +class MainWindow; +class ModListViewActions; +class PluginListSortProxy; + +class PluginListView : public QTreeView +{ + Q_OBJECT +public: + explicit PluginListView(QWidget* parent = nullptr); + + void activated(); + void setup(OrganizerCore& core, MainWindow* mw, Ui::MainWindow* mwui); + + // the column by which the plugin list is currently sorted + // + int sortColumn() const; + + // update the plugin counter + // + void updatePluginCount(); + +protected slots: + + void onCustomContextMenuRequested(const QPoint& pos); + void onDoubleClicked(const QModelIndex& index); + + void onFilterChanged(const QString& filter); + void onSortButtonClicked(); + +protected: + friend class PluginListContextMenu; + + // map from/to the view indexes to the model + // + QModelIndex indexModelToView(const QModelIndex& index) const; + QModelIndexList indexModelToView(const QModelIndexList& index) const; + QModelIndex indexViewToModel(const QModelIndex& index) const; + QModelIndexList indexViewToModel(const QModelIndexList& index) const; + + // method to react to various key events + // + bool moveSelection(int key); + bool toggleSelectionState(); + + // get/set the selected items on the view, this method return/take indices + // from the mod list model, not the view, so it's safe to restore + // + std::pair selected() const; + void setSelected(const QModelIndex& current, const QModelIndexList& selected); + + bool event(QEvent* event) override; + +private: + struct PluginListViewUi + { + // the plguin counter + QLCDNumber* counter; + + // the filter + QLineEdit* filter; + }; + + OrganizerCore* m_core; + PluginListViewUi ui; + + PluginListSortProxy* m_sortProxy; + ModListViewActions* m_modActions; + ViewMarkingScrollBar* m_Scrollbar; + + bool m_didUpdateMasterList; +}; + +#endif // PLUGINLISTVIEW_H diff --git a/src/src/previewdialog.cpp b/src/src/previewdialog.cpp new file mode 100644 index 0000000..e680012 --- /dev/null +++ b/src/src/previewdialog.cpp @@ -0,0 +1,65 @@ +#include "previewdialog.h" +#include "settings.h" +#include "ui_previewdialog.h" +#include + +PreviewDialog::PreviewDialog(const QString& fileName, QWidget* parent) + : QDialog(parent), ui(new Ui::PreviewDialog) +{ + ui->setupUi(this); + ui->nameLabel->setText(QFileInfo(fileName).fileName()); + ui->nextButton->setEnabled(false); + ui->previousButton->setEnabled(false); +} + +PreviewDialog::~PreviewDialog() +{ + delete ui; +} + +int PreviewDialog::exec() +{ + GeometrySaver gs(Settings::instance(), this); + return QDialog::exec(); +} + +void PreviewDialog::addVariant(const QString& modName, QWidget* widget) +{ + widget->setProperty("modName", modName); + ui->variantsStack->addWidget(widget); + if (ui->variantsStack->count() > 1) { + ui->nextButton->setEnabled(true); + ui->previousButton->setEnabled(true); + } +} + +int PreviewDialog::numVariants() const +{ + return ui->variantsStack->count(); +} + +void PreviewDialog::on_variantsStack_currentChanged(int index) +{ + ui->modLabel->setText( + ui->variantsStack->widget(index)->property("modName").toString()); +} + +void PreviewDialog::on_closeButton_clicked() +{ + this->accept(); +} + +void PreviewDialog::on_previousButton_clicked() +{ + int i = ui->variantsStack->currentIndex() - 1; + if (i < 0) { + i = ui->variantsStack->count() - 1; + } + ui->variantsStack->setCurrentIndex(i); +} + +void PreviewDialog::on_nextButton_clicked() +{ + ui->variantsStack->setCurrentIndex((ui->variantsStack->currentIndex() + 1) % + ui->variantsStack->count()); +} diff --git a/src/src/previewdialog.h b/src/src/previewdialog.h new file mode 100644 index 0000000..1faf9df --- /dev/null +++ b/src/src/previewdialog.h @@ -0,0 +1,40 @@ +#ifndef PREVIEWDIALOG_H +#define PREVIEWDIALOG_H + +#include + +namespace Ui +{ +class PreviewDialog; +} + +class PreviewDialog : public QDialog +{ + Q_OBJECT + +public: + explicit PreviewDialog(const QString& fileName, QWidget* parent = 0); + ~PreviewDialog(); + + // also saves and restores geometry + // + int exec() override; + + void addVariant(const QString& modName, QWidget* widget); + int numVariants() const; + +private slots: + + void on_variantsStack_currentChanged(int arg1); + + void on_closeButton_clicked(); + + void on_previousButton_clicked(); + + void on_nextButton_clicked(); + +private: + Ui::PreviewDialog* ui; +}; + +#endif // PREVIEWDIALOG_H diff --git a/src/src/previewdialog.ui b/src/src/previewdialog.ui new file mode 100644 index 0000000..e2dc824 --- /dev/null +++ b/src/src/previewdialog.ui @@ -0,0 +1,90 @@ + + + PreviewDialog + + + + 0 + 0 + 736 + 583 + + + + Preview + + + + + + + + + + + + + + + + + + + + + + + + + + :/MO/gui/previous:/MO/gui/previous + + + + + + + + + + + :/MO/gui/next:/MO/gui/next + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Close + + + + + + + + + + + + diff --git a/src/src/previewgenerator.cpp b/src/src/previewgenerator.cpp new file mode 100644 index 0000000..239bd57 --- /dev/null +++ b/src/src/previewgenerator.cpp @@ -0,0 +1,78 @@ +/* +Copyright (C) 2014 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "previewgenerator.h" + +#include +#include +#include +#include +#include + +#include "plugincontainer.h" + +using namespace MOBase; + +PreviewGenerator::PreviewGenerator(const PluginContainer& pluginContainer) + : m_PluginContainer(pluginContainer) +{ + m_MaxSize = QGuiApplication::primaryScreen()->size() * 0.8; +} + +bool PreviewGenerator::previewSupported(const QString& fileExtension, + const bool& isArchive) const +{ + auto& previews = m_PluginContainer.plugins(); + for (auto* preview : previews) { + if (preview->supportedExtensions().contains(fileExtension)) { + if (!isArchive) + return true; + if (preview->supportsArchives()) + return true; + } + } + return false; +} + +QWidget* PreviewGenerator::genPreview(const QString& fileName) const +{ + const QString ext = QFileInfo(fileName).suffix().toLower(); + auto& previews = m_PluginContainer.plugins(); + for (auto* preview : previews) { + if (m_PluginContainer.isEnabled(preview) && + preview->supportedExtensions().contains(ext)) { + return preview->genFilePreview(fileName, m_MaxSize); + } + } + return nullptr; +} + +QWidget* PreviewGenerator::genArchivePreview(const QByteArray& fileData, + const QString& fileName) const +{ + const QString ext = QFileInfo(fileName).suffix().toLower(); + auto& previews = m_PluginContainer.plugins(); + for (auto* preview : previews) { + if (m_PluginContainer.isEnabled(preview) && + preview->supportedExtensions().contains(ext) && preview->supportsArchives()) { + return preview->genDataPreview(fileData, fileName, m_MaxSize); + } + } + return nullptr; +} diff --git a/src/src/previewgenerator.h b/src/src/previewgenerator.h new file mode 100644 index 0000000..df1196f --- /dev/null +++ b/src/src/previewgenerator.h @@ -0,0 +1,47 @@ +/* +Copyright (C) 2014 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef PREVIEWGENERATOR_H +#define PREVIEWGENERATOR_H + +#include +#include +#include +#include +#include + +class PluginContainer; + +class PreviewGenerator +{ +public: + PreviewGenerator(const PluginContainer& pluginContainer); + + bool previewSupported(const QString& fileExtension, const bool& isArchive) const; + + QWidget* genPreview(const QString& fileName) const; + + QWidget* genArchivePreview(const QByteArray& fileData, const QString& fileName) const; + +private: + const PluginContainer& m_PluginContainer; + QSize m_MaxSize; +}; + +#endif // PREVIEWGENERATOR_H diff --git a/src/src/problemsdialog.cpp b/src/src/problemsdialog.cpp new file mode 100644 index 0000000..8a1791e --- /dev/null +++ b/src/src/problemsdialog.cpp @@ -0,0 +1,118 @@ +#include "problemsdialog.h" +#include "organizercore.h" +#include "ui_problemsdialog.h" +#include +#ifdef _WIN32 +#include +#endif +#include +#include +#include + +#include "plugincontainer.h" + +using namespace MOBase; + +ProblemsDialog::ProblemsDialog(const PluginContainer& pluginContainer, QWidget* parent) + : QDialog(parent), ui(new Ui::ProblemsDialog), m_PluginContainer(pluginContainer), + m_hasProblems(false) +{ + ui->setupUi(this); + ui->problemsWidget->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + + runDiagnosis(); + + connect(ui->problemsWidget, SIGNAL(itemSelectionChanged()), this, + SLOT(selectionChanged())); + connect(ui->descriptionText, SIGNAL(anchorClicked(QUrl)), this, + SLOT(urlClicked(QUrl))); +} + +ProblemsDialog::~ProblemsDialog() +{ + delete ui; +} + +int ProblemsDialog::exec() +{ + GeometrySaver gs(Settings::instance(), this); + return QDialog::exec(); +} + +void ProblemsDialog::runDiagnosis() +{ + m_hasProblems = false; + ui->problemsWidget->clear(); + + for (IPluginDiagnose* diagnose : m_PluginContainer.plugins()) { + if (!m_PluginContainer.isEnabled(diagnose)) { + continue; + } + + std::vector activeProblems = diagnose->activeProblems(); + foreach (unsigned int key, activeProblems) { + QTreeWidgetItem* newItem = new QTreeWidgetItem(); + newItem->setText(0, diagnose->shortDescription(key)); + newItem->setData(0, Qt::UserRole, diagnose->fullDescription(key)); + + ui->problemsWidget->addTopLevelItem(newItem); + m_hasProblems = true; + + if (diagnose->hasGuidedFix(key)) { + QPushButton* fixButton = new QPushButton(tr("Fix")); + fixButton->setProperty("fix", + QVariant::fromValue(reinterpret_cast(diagnose))); + fixButton->setProperty("key", key); + connect(fixButton, SIGNAL(clicked()), this, SLOT(startFix())); + ui->problemsWidget->setItemWidget(newItem, 1, fixButton); + } else { + newItem->setText(1, tr("No guided fix")); + } + } + } + + if (!m_hasProblems) { + auto* item = new QTreeWidgetItem; + + item->setText(0, tr("(There are no notifications)")); + item->setText(1, ""); + item->setData(0, Qt::UserRole, QString()); + + QFont font = item->font(0); + font.setItalic(true); + item->setFont(0, font); + + ui->problemsWidget->addTopLevelItem(item); + } +} + +bool ProblemsDialog::hasProblems() const +{ + return m_hasProblems; +} + +void ProblemsDialog::selectionChanged() +{ + QString text = ui->problemsWidget->currentItem()->data(0, Qt::UserRole).toString(); + ui->descriptionText->setText(text); + ui->descriptionText->setLineWrapMode(text.contains('\n') ? QTextEdit::NoWrap + : QTextEdit::WidgetWidth); +} + +void ProblemsDialog::startFix() +{ + QObject* fixButton = QObject::sender(); + if (fixButton == NULL) { + log::warn("no button"); + return; + } + IPluginDiagnose* plugin = + reinterpret_cast(fixButton->property("fix").value()); + plugin->startGuidedFix(fixButton->property("key").toUInt()); + runDiagnosis(); +} + +void ProblemsDialog::urlClicked(const QUrl& url) +{ + shell::Open(url); +} diff --git a/src/src/problemsdialog.h b/src/src/problemsdialog.h new file mode 100644 index 0000000..89a8ee5 --- /dev/null +++ b/src/src/problemsdialog.h @@ -0,0 +1,44 @@ +#ifndef PROBLEMSDIALOG_H +#define PROBLEMSDIALOG_H + +#include +#include +#include + +namespace Ui +{ +class ProblemsDialog; +} + +class PluginContainer; + +class ProblemsDialog : public QDialog +{ + Q_OBJECT + +public: + explicit ProblemsDialog(PluginContainer const& pluginContainer, QWidget* parent = 0); + ~ProblemsDialog(); + + // also saves and restores geometry + // + int exec() override; + + bool hasProblems() const; + +private: + void runDiagnosis(); + +private slots: + void selectionChanged(); + void urlClicked(const QUrl& url); + + void startFix(); + +private: + Ui::ProblemsDialog* ui; + const PluginContainer& m_PluginContainer; + bool m_hasProblems; +}; + +#endif // PROBLEMSDIALOG_H diff --git a/src/src/problemsdialog.ui b/src/src/problemsdialog.ui new file mode 100644 index 0000000..a474f73 --- /dev/null +++ b/src/src/problemsdialog.ui @@ -0,0 +1,102 @@ + + + ProblemsDialog + + + + 0 + 0 + 574 + 376 + + + + Notifications + + + + + + 2 + + + false + + + 350 + + + + 1 + + + + + 2 + + + + + + + + QTextEdit::NoWrap + + + true + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Click a notification above to get more details...</p></body></html> + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Close + + + + + + + + + + + closeButton + clicked() + ProblemsDialog + accept() + + + 526 + 354 + + + 286 + 187 + + + + + diff --git a/src/src/processrunner.cpp b/src/src/processrunner.cpp new file mode 100644 index 0000000..c07aa2a --- /dev/null +++ b/src/src/processrunner.cpp @@ -0,0 +1,1415 @@ +#include "processrunner.h" +#include "env.h" +#include "envmodule.h" +#include "instancemanager.h" +#include "iuserinterface.h" +#include "organizercore.h" +#include +#include +#include +#include +#ifndef _WIN32 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +using namespace MOBase; + +void adjustForVirtualized(const IPluginGame* game, spawn::SpawnParameters& sp, + const Settings& settings) +{ + const QString modsPath = settings.paths().mods(); + + // Check if this a request with either an executable or a working directory + // under our mods folder then will start the process in a virtualized + // "environment" with the appropriate paths fixed: + // (i.e. mods\FNIS\path\exe => game\data\path\exe) + QString cwdPath = sp.currentDirectory.absolutePath(); + QString trailedModsPath = modsPath; + if (!trailedModsPath.endsWith('/')) { + trailedModsPath = trailedModsPath + '/'; + } + bool virtualizedCwd = cwdPath.startsWith(trailedModsPath, Qt::CaseInsensitive); + QString binPath = sp.binary.absoluteFilePath(); + bool virtualizedBin = binPath.startsWith(trailedModsPath, Qt::CaseInsensitive); + if (virtualizedCwd || virtualizedBin) { + if (virtualizedCwd) { + int cwdOffset = cwdPath.indexOf('/', trailedModsPath.length()); + QString adjustedCwd = cwdPath.mid(cwdOffset, -1); + cwdPath = game->dataDirectory().absolutePath(); + if (cwdOffset >= 0) + cwdPath += adjustedCwd; + } + + if (virtualizedBin) { + int binOffset = binPath.indexOf('/', trailedModsPath.length()); + QString adjustedBin = binPath.mid(binOffset, -1); + binPath = game->dataDirectory().absolutePath(); + if (binOffset >= 0) + binPath += adjustedBin; + } + + QString cmdline = QString("launch \"%1\" \"%2\" %3") + .arg(QDir::toNativeSeparators(cwdPath), + QDir::toNativeSeparators(binPath), sp.arguments); + + sp.binary = QFileInfo(QCoreApplication::applicationFilePath()); + sp.arguments = cmdline; + sp.currentDirectory.setPath(QCoreApplication::applicationDirPath()); + } +} + +#ifdef _WIN32 +std::optional singleWait(HANDLE handle, DWORD pid) +{ + if (handle == INVALID_HANDLE_VALUE) { + return ProcessRunner::Error; + } + + const auto res = WaitForSingleObject(handle, 50); + + switch (res) { + case WAIT_OBJECT_0: { + log::debug("process {} completed", pid); + return ProcessRunner::Completed; + } + + case WAIT_TIMEOUT: { + // still running + return {}; + } + + case WAIT_FAILED: // fall-through + default: { + // error + const auto e = ::GetLastError(); + log::error("failed waiting for {}, {}", pid, formatSystemMessage(e)); + return ProcessRunner::Error; + } + } +} +#else +std::optional singleWait(HANDLE handle, DWORD pid) +{ + Q_UNUSED(handle); + Q_UNUSED(pid); + return ProcessRunner::Completed; +} +#endif + +enum class Interest +{ + None = 0, + Weak, + Strong +}; + +QString toString(Interest i) +{ + switch (i) { + case Interest::Weak: + return "weak"; + + case Interest::Strong: + return "strong"; + + case Interest::None: // fall-through + default: + return "no"; + } +} + +struct InterestingProcess +{ + env::Process p; + Interest interest = Interest::None; + env::HandlePtr handle; +}; + +InterestingProcess findRandomProcess(const env::Process& root) +{ + for (auto&& c : root.children()) { + env::HandlePtr h = c.openHandleForWait(); + if (h) { + return {c, Interest::Weak, std::move(h)}; + } + + auto r = findRandomProcess(c); + if (r.handle) { + return r; + } + } + + return {}; +} + +// returns a process that's in the hidden list, or the top-level process if +// they're all hidden; returns an invalid process if the list is empty +// +InterestingProcess findInterestingProcessInTrees(const env::Process& root) +{ + // Certain process names we wish to "hide" for aesthetic reason: + static const std::vector hiddenList = { + QFileInfo(QCoreApplication::applicationFilePath()).fileName(), "conhost.exe"}; + + if (root.children().empty()) { + return {}; + } + + auto isHidden = [&](auto&& p) { + for (auto& h : hiddenList) { + if (p.name().contains(h, Qt::CaseInsensitive)) { + return true; + } + } + + return false; + }; + + for (auto&& p : root.children()) { + if (!isHidden(p)) { + env::HandlePtr h = p.openHandleForWait(); + if (h) { + return {p, Interest::Strong, std::move(h)}; + } + } + + auto r = findInterestingProcessInTrees(p); + if (r.interest == Interest::Strong) { + return r; + } + } + + // everything is hidden, just pick the first one that can be used + return findRandomProcess(root); +} + +void dump(const env::Process& p, int indent) +{ + log::debug("{}{}, pid={}, ppid={}", std::string(indent * 4, ' '), p.name(), p.pid(), + p.ppid()); + + for (auto&& c : p.children()) { + dump(c, indent + 1); + } +} + +void dump(const env::Process& root) +{ + log::debug("process tree:"); + + for (auto&& p : root.children()) { + dump(p, 1); + } +} + +#ifdef _WIN32 +// gets the most interesting process in the list +// +InterestingProcess getInterestingProcess(HANDLE job) +{ + env::Process root = env::getProcessTree(job); + if (root.children().empty()) { + log::debug("nothing to wait for"); + return {}; + } + + dump(root); + + auto interest = findInterestingProcessInTrees(root); + if (!interest.handle) { + // this can happen if none of the processes can be opened + log::debug("no interesting process to wait for"); + return {}; + } + + return interest; +} +#endif + +#ifdef _WIN32 +const std::chrono::milliseconds Infinite(-1); + +// waits for completion, times out after `wait` if not Infinite +// +std::optional timedWait(HANDLE handle, DWORD pid, + UILocker::Session* ls, + std::chrono::milliseconds wait, + std::atomic& interrupt) +{ + using namespace std::chrono; + + high_resolution_clock::time_point start; + if (wait != Infinite) { + start = high_resolution_clock::now(); + } + + while (!interrupt) { + // wait for a very short while, allows for processing events below + const auto r = singleWait(handle, pid); + + if (r) { + // the process has either completed or an error was returned + return *r; + } + + // the process is still running + + // check the lock widget; the session can be null when running shortcuts + // with locking disabled, in which case the user cannot force unlock + if (ls) { + switch (ls->result()) { + case UILocker::StillLocked: { + break; + } + + case UILocker::ForceUnlocked: { + log::debug("waiting for {} force unlocked by user", pid); + return ProcessRunner::ForceUnlocked; + } + + case UILocker::Cancelled: { + log::debug("waiting for {} cancelled by user", pid); + return ProcessRunner::Cancelled; + } + + case UILocker::NoResult: // fall-through + default: { + // shouldn't happen + log::debug("unexpected result {} while waiting for {}", + static_cast(ls->result()), pid); + + return ProcessRunner::Error; + } + } + } + + if (wait != Infinite) { + // check if enough time has elapsed + const auto now = high_resolution_clock::now(); + if (duration_cast(now - start) >= wait) { + // if so, return an empty result + return {}; + } + } + } + + log::debug("waiting for {} interrupted", pid); + return ProcessRunner::ForceUnlocked; +} + +ProcessRunner::Results waitForProcessesThreadImpl(HANDLE job, UILocker::Session* ls, + std::atomic& interrupt) +{ + using namespace std::chrono; + + DWORD currentPID = 0; + + // if the interesting process that was found is weak (such as ModOrganizer.exe + // when starting a program from within the Data directory), start with a short + // wait and check for more interesting children + const milliseconds defaultWait(50); + auto wait = defaultWait; + + while (!interrupt) { + auto ip = getInterestingProcess(job); + if (!ip.handle) { + // nothing to wait on + return ProcessRunner::Completed; + } + + // update the lock widget; the session can be null when running shortcuts + // with locking disabled + if (ls) { + ls->setInfo(ip.p.pid(), ip.p.name()); + } + + if (ip.p.pid() != currentPID) { + // log any change in the process being waited for + currentPID = ip.p.pid(); + + log::debug("waiting for completion on {} ({}), {} interest", ip.p.name(), + ip.p.pid(), toString(ip.interest)); + } + + if (ip.interest == Interest::Strong) { + // don't bother with short wait, this is a good process to wait for + wait = Infinite; + } + + const auto r = timedWait(ip.handle.get(), ip.p.pid(), ls, wait, interrupt); + if (r) { + if (*r == ProcessRunner::Results::Completed) { + // process completed, check another one, reset the wait time to find + // interesting processes + wait = defaultWait; + } else if (*r != ProcessRunner::Results::Running) { + // something's wrong, or the user unlocked the ui + return *r; + } + } + + // exponentially increase the wait time between checks for interesting + // processes + wait = std::min(wait * 2, milliseconds(2000)); + } + + log::debug("waiting for processes interrupted"); + return ProcessRunner::ForceUnlocked; +} + +void waitForProcessesThread(ProcessRunner::Results& result, HANDLE job, + UILocker::Session* ls, std::atomic& interrupt) +{ + result = waitForProcessesThreadImpl(job, ls, interrupt); + + // the session can be null when running shortcuts with locking disabled + if (ls) { + ls->unlock(); + } +} + +ProcessRunner::Results waitForProcesses(const std::vector& initialProcesses, + UILocker::Session* ls) +{ + if (initialProcesses.empty()) { + // nothing to wait for + return ProcessRunner::Completed; + } + + // using a job so any child process started by any of those processes can also + // be captured and monitored + env::HandlePtr job(CreateJobObjectW(nullptr, nullptr)); + if (!job) { + const auto e = GetLastError(); + + log::error("failed to create job to wait for processes, {}", + formatSystemMessage(e)); + + return ProcessRunner::Error; + } + + bool oneWorked = false; + + for (auto&& h : initialProcesses) { + if (::AssignProcessToJobObject(job.get(), h)) { + oneWorked = true; + } else { + const auto e = GetLastError(); + + // this happens when closing MO while multiple processes are running, + // so the logging is disabled until it gets fixed + + // log::error( + // "can't assign process to job to wait for processes, {}", + // formatSystemMessage(e)); + + // keep going + } + } + + HANDLE monitor = INVALID_HANDLE_VALUE; + + if (oneWorked) { + monitor = job.get(); + } else { + // none of the handles could be added to the job, just monitor the first one + monitor = initialProcesses[0]; + } + + auto results = ProcessRunner::Running; + std::atomic interrupt(false); + + auto* t = QThread::create(waitForProcessesThread, std::ref(results), monitor, ls, + std::ref(interrupt)); + + QEventLoop events; + QObject::connect(t, &QThread::finished, [&] { + events.quit(); + }); + + t->start(); + events.exec(); + + if (t->isRunning()) { + interrupt = true; + t->wait(); + } + + delete t; + + return results; +} + +ProcessRunner::Results waitForProcess(HANDLE initialProcess, LPDWORD exitCode, + UILocker::Session* ls) +{ + std::vector processes = {initialProcess}; + + const auto r = waitForProcesses(processes, ls); + + // as long as it's not running anymore, try to get the exit code + if (exitCode && r != ProcessRunner::Running) { + if (!::GetExitCodeProcess(initialProcess, exitCode)) { + const auto e = ::GetLastError(); + log::warn("failed to get exit code of process, {}", formatSystemMessage(e)); + } + } + + return r; +} + +#else // !_WIN32 + +pid_t handleToPid(HANDLE h) +{ + return static_cast(reinterpret_cast(h)); +} + +QString readProcComm(pid_t pid) +{ + QFile f(QString("/proc/%1/comm").arg(pid)); + if (!f.open(QIODevice::ReadOnly)) { + return {}; + } + + return QString::fromUtf8(f.readAll()).trimmed(); +} + +std::unordered_map> buildProcChildrenMap() +{ + std::unordered_map> children; + DIR* proc = opendir("/proc"); + if (!proc) { + return children; + } + + struct dirent* entry = nullptr; + while ((entry = readdir(proc)) != nullptr) { + if (entry->d_type != DT_DIR && entry->d_type != DT_UNKNOWN) { + continue; + } + + const char* name = entry->d_name; + if (*name == '\0' || !std::isdigit(static_cast(*name))) { + continue; + } + + const pid_t pid = static_cast(std::strtol(name, nullptr, 10)); + std::ifstream status(QString("/proc/%1/status").arg(pid).toStdString()); + if (!status.is_open()) { + continue; + } + + std::string line; + pid_t ppid = 0; + while (std::getline(status, line)) { + if (line.rfind("PPid:", 0) == 0) { + ppid = static_cast(std::strtol(line.c_str() + 5, nullptr, 10)); + break; + } + } + + if (ppid > 0) { + children[ppid].push_back(pid); + } + } + + closedir(proc); + return children; +} + +std::unordered_set +collectDescendants(pid_t root, const std::unordered_map>& children) +{ + std::unordered_set out; + std::deque q; + q.push_back(root); + + while (!q.empty()) { + const pid_t cur = q.front(); + q.pop_front(); + + const auto it = children.find(cur); + if (it == children.end()) { + continue; + } + + for (pid_t child : it->second) { + if (out.insert(child).second) { + q.push_back(child); + } + } + } + + return out; +} + +QStringList buildExpectedExecutables(const QFileInfo& binary, const QString& arguments) +{ + QStringList expected; + auto addName = [&](QString name) { + name = name.trimmed().toLower(); + if (!name.isEmpty() && !expected.contains(name)) { + expected.push_back(name); + } + }; + + addName(binary.fileName()); + + const auto args = QProcess::splitCommand(arguments); + for (const QString& arg : args) { + const QFileInfo fi(arg); + const QString base = fi.fileName(); + if (base.endsWith(".exe", Qt::CaseInsensitive)) { + addName(base); + } + } + + return expected; +} + +pid_t findTrackedProcess(pid_t rootPid, const QStringList& expected, + QString* trackedNameOut) +{ + if (expected.isEmpty()) { + return 0; + } + + const auto children = buildProcChildrenMap(); + const auto descendants = collectDescendants(rootPid, children); + if (descendants.empty()) { + return 0; + } + + pid_t best = 0; + QString bestName; + for (pid_t pid : descendants) { + const QString comm = readProcComm(pid); + if (comm.isEmpty()) { + continue; + } + const QString lower = comm.toLower(); + if (expected.contains(lower)) { + best = pid; + bestName = comm; + break; + } + } + + if (best > 0 && trackedNameOut) { + *trackedNameOut = bestName; + } + return best; +} + +DWORD exitCodeFromWaitStatus(int status) +{ + if (WIFEXITED(status)) { + return static_cast(WEXITSTATUS(status)); + } + + if (WIFSIGNALED(status)) { + return static_cast(128 + WTERMSIG(status)); + } + + return 0; +} + +ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, UILocker::Session* ls, + const QStringList& expected) +{ + if (pid <= 0) { + return ProcessRunner::Error; + } + + // startDetached() creates a non-child process, so waitpid() will fail with + // ECHILD. Detect this on the first call and switch to kill(pid, 0) polling + // which works for any process owned by the same user. + bool useKillPoll = false; + { + int status = 0; + const pid_t probe = ::waitpid(pid, &status, WNOHANG); + if (probe == pid) { + // Already exited before we even started waiting + if (exitCode != nullptr) { + *exitCode = exitCodeFromWaitStatus(status); + } + log::debug("process {} completed immediately", pid); + return ProcessRunner::Completed; + } + if (probe < 0 && errno == ECHILD) { + // Not a child process (detached via startDetached), use kill(0) polling + useKillPoll = true; + log::debug("process {} is detached, using kill(0) polling", pid); + } + } + + bool seenTrackedProcess = false; + + while (true) { + QString trackedName; + pid_t displayPid = pid; + QString displayName = readProcComm(pid); + const pid_t tracked = findTrackedProcess(pid, expected, &trackedName); + if (tracked > 0) { + seenTrackedProcess = true; + displayPid = tracked; + displayName = trackedName; + } else if (seenTrackedProcess) { + if (exitCode != nullptr) { + *exitCode = 0; + } + log::debug("tracked child process for root {} exited", pid); + return ProcessRunner::Completed; + } + + if (ls != nullptr) { + ls->setInfo(static_cast(std::max(0, displayPid)), displayName); + } + + if (useKillPoll) { + // Poll for process existence via kill(pid, 0) + if (::kill(pid, 0) != 0) { + if (errno == ESRCH) { + if (exitCode != nullptr) { + *exitCode = 0; + } + log::debug("process {} completed", pid); + return ProcessRunner::Completed; + } + // EPERM means the process exists but we can't signal it; keep waiting + if (errno != EPERM) { + log::error("failed checking process {}, errno={}", pid, errno); + return ProcessRunner::Error; + } + } + } else { + int status = 0; + const pid_t waitResult = ::waitpid(pid, &status, WNOHANG); + + if (waitResult == pid) { + if (exitCode != nullptr) { + *exitCode = exitCodeFromWaitStatus(status); + } + log::debug("process {} completed", pid); + return ProcessRunner::Completed; + } + + if (waitResult < 0) { + if (errno == EINTR) { + continue; + } + if (errno == ECHILD) { + // Process was reparented, switch to kill polling + useKillPoll = true; + continue; + } + log::error("failed waiting for {}, errno={}", pid, errno); + return ProcessRunner::Error; + } + } + + if (ls != nullptr) { + switch (ls->result()) { + case UILocker::StillLocked: + break; + + case UILocker::ForceUnlocked: + log::debug("waiting for {} force unlocked by user", pid); + return ProcessRunner::ForceUnlocked; + + case UILocker::Cancelled: + log::debug("waiting for {} cancelled by user, terminating", displayPid); + if (::kill(displayPid, SIGTERM) != 0 && errno != ESRCH) { + log::warn("failed to terminate {}, errno={}", displayPid, errno); + } + return ProcessRunner::Cancelled; + + case UILocker::NoResult: + default: + log::debug("unexpected lock result while waiting for {}", pid); + return ProcessRunner::Error; + } + } + + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + QThread::msleep(50); + } +} + +ProcessRunner::Results waitForProcess(HANDLE initialProcess, LPDWORD exitCode, + UILocker::Session* ls, + const QStringList& expected) +{ + return waitForPid(handleToPid(initialProcess), exitCode, ls, expected); +} + +ProcessRunner::Results waitForProcesses(const std::vector& initialProcesses, + UILocker::Session* ls, + const QStringList& expected) +{ + if (initialProcesses.empty()) { + return ProcessRunner::Completed; + } + + for (HANDLE h : initialProcesses) { + DWORD ignored = 0; + const auto r = waitForPid(handleToPid(h), &ignored, ls, expected); + if (r != ProcessRunner::Completed) { + return r; + } + } + + return ProcessRunner::Completed; +} + +#endif // _WIN32 + +ProcessRunner::ProcessRunner(OrganizerCore& core, IUserInterface* ui) + : m_core(core), m_ui(ui), m_lockReason(UILocker::NoReason), m_waitFlags(NoFlags), + m_handle(INVALID_HANDLE_VALUE), m_exitCode(-1) +{ + // all processes started in ProcessRunner are hooked by default + setHooked(true); +} + +ProcessRunner& ProcessRunner::setBinary(const QFileInfo& binary) +{ +#ifndef _WIN32 + m_sp.binary = QFileInfo(MOBase::normalizePathForHost(binary.filePath())); +#else + m_sp.binary = binary; +#endif + return *this; +} + +ProcessRunner& ProcessRunner::setArguments(const QString& arguments) +{ + m_sp.arguments = arguments; + return *this; +} + +ProcessRunner& ProcessRunner::setCurrentDirectory(const QDir& directory) +{ +#ifndef _WIN32 + m_sp.currentDirectory.setPath(MOBase::normalizePathForHost(directory.path())); +#else + m_sp.currentDirectory = directory; +#endif + return *this; +} + +ProcessRunner& ProcessRunner::setSteamID(const QString& steamID) +{ + m_sp.steamAppID = steamID; + return *this; +} + +ProcessRunner& ProcessRunner::setCustomOverwrite(const QString& customOverwrite) +{ + m_customOverwrite = customOverwrite; + return *this; +} + +ProcessRunner& ProcessRunner::setForcedLibraries(const ForcedLibraries& forcedLibraries) +{ + m_forcedLibraries = forcedLibraries; + return *this; +} + +ProcessRunner& ProcessRunner::setProfileName(const QString& profileName) +{ + m_profileName = profileName; + return *this; +} + +ProcessRunner& ProcessRunner::setWaitForCompletion(WaitFlags flags, + UILocker::Reasons reason) +{ + m_waitFlags = flags; + m_lockReason = reason; + + if (m_waitFlags.testFlag(WaitForRefresh) && !m_waitFlags.testFlag(TriggerRefresh)) { + log::warn("process runner: WaitForRefresh without TriggerRefresh " + "makes no sense, will be ignored"); + } + + return *this; +} + +ProcessRunner& ProcessRunner::setHooked(bool b) +{ + m_sp.hooked = b; + return *this; +} + +ProcessRunner& ProcessRunner::setFromFile(QWidget* parent, const QFileInfo& targetInfo) +{ + if (!parent && m_ui) { + parent = m_ui->mainWindow(); + } + + // if the file is a .exe, start it directly; if it's anything else, ask the + // shell to start it + + const auto fec = spawn::getFileExecutionContext(parent, targetInfo); + + switch (fec.type) { + case spawn::FileExecutionTypes::Executable: { + setBinary(fec.binary); + setArguments(fec.arguments); + setCurrentDirectory(targetInfo.absoluteDir()); + break; + } + + case spawn::FileExecutionTypes::Other: // fall-through + default: { + m_shellOpen = targetInfo; + setHooked(false); + break; + } + } + + return *this; +} + +ProcessRunner& ProcessRunner::setFromExecutable(const Executable& exe) +{ + const auto profile = m_core.currentProfile(); + if (!profile) { + throw MyException(QObject::tr("No profile set")); + } + + const QString customOverwrite = + profile->setting("custom_overwrites", exe.title()).toString(); + + ForcedLibraries forcedLibraries; + if (profile->forcedLibrariesEnabled(exe.title())) { + forcedLibraries = profile->determineForcedLibraries(exe.title()); + } + + QString currentDirectory = exe.workingDirectory(); + if (currentDirectory.isEmpty()) { + currentDirectory = exe.binaryInfo().absolutePath(); + } + + setBinary(exe.binaryInfo()); + setArguments(exe.arguments()); + setCurrentDirectory(currentDirectory); + setSteamID(exe.steamAppID()); + setCustomOverwrite(customOverwrite); + setForcedLibraries(forcedLibraries); + + return *this; +} + +ProcessRunner& ProcessRunner::setFromShortcut(const MOShortcut& shortcut) +{ + const auto currentInstance = InstanceManager::singleton().currentInstance(); + + if (currentInstance) { + if (shortcut.hasInstance() && !shortcut.isForInstance(*currentInstance)) { + MOBase::reportError( + QObject::tr( + "This shortcut is for instance '%1' but Mod Organizer is currently " + "running for '%2'. Exit Mod Organizer before running the shortcut or " + "change the active instance.") + .arg(shortcut.instanceDisplayName()) + .arg(currentInstance->displayName())); + + throw std::exception(); + } + } + + const auto* exes = m_core.executablesList(); + const auto exe = exes->find(shortcut.executableName()); + + if (exe != exes->end()) { + setFromExecutable(*exe); + } else { + MOBase::reportError(QObject::tr("Executable '%1' does not exist in instance '%2'.") + .arg(shortcut.executableName()) + .arg(currentInstance->displayName())); + + throw std::exception(); + } + + return *this; +} + +ProcessRunner& ProcessRunner::setFromFileOrExecutable( + const QString& executable, const QStringList& args, const QString& cwd, + const QString& profileOverride, const QString& forcedCustomOverwrite, + bool ignoreCustomOverwrite) +{ + const auto profile = m_core.currentProfile(); + if (!profile) { + throw MyException(QObject::tr("No profile set")); + } + + setBinary(QFileInfo(executable)); + setArguments(args.join(" ")); + setCurrentDirectory(cwd); + setProfileName(profileOverride); + + if (executable.contains('\\') || executable.contains('/')) { + // file path + + if (m_sp.binary.isRelative()) { + // relative path, should be relative to game directory + setBinary(QFileInfo( + m_core.managedGame()->gameDirectory().absoluteFilePath(executable))); + } + + if (cwd == "") { + setCurrentDirectory(m_sp.binary.absolutePath()); + } + + try { + const Executable& exe = m_core.executablesList()->getByBinary(m_sp.binary); + + setSteamID(exe.steamAppID()); + setCustomOverwrite(profile->setting("custom_overwrites", exe.title()).toString()); + + if (profile->forcedLibrariesEnabled(exe.title())) { + setForcedLibraries(profile->determineForcedLibraries(exe.title())); + } + } catch (const std::runtime_error&) { + // nop + } + } else { + // only a file name, search executables list + try { + const Executable& exe = m_core.executablesList()->get(executable); + + setSteamID(exe.steamAppID()); + setCustomOverwrite(profile->setting("custom_overwrites", exe.title()).toString()); + + if (profile->forcedLibrariesEnabled(exe.title())) { + setForcedLibraries(profile->determineForcedLibraries(exe.title())); + } + + if (args.isEmpty()) { + setArguments(exe.arguments()); + } + + setBinary(exe.binaryInfo()); + + if (cwd == "") { + setCurrentDirectory(exe.workingDirectory()); + } + } catch (const std::runtime_error&) { + log::warn("\"{}\" not set up as executable", executable); + } + } + + if (ignoreCustomOverwrite) { + setCustomOverwrite(""); + } else if (!forcedCustomOverwrite.isEmpty()) { + setCustomOverwrite(forcedCustomOverwrite); + } + + return *this; +} + +bool ProcessRunner::shouldRunShell() const +{ + return !m_shellOpen.filePath().isEmpty(); +} + +ProcessRunner::Results ProcessRunner::run() +{ + // check if setHooked() was called after setFromFile(); this needs to + // modify the settings to run the associated executable instead of using + // shell::Open() + + if (shouldRunShell() && m_sp.hooked) { + // this is a non-executable file, but it should be hooked; the associated + // executable needs to be retrieved and run instead + auto assoc = env::getAssociation(m_shellOpen); + if (!assoc.executable.filePath().isEmpty()) { + setBinary(assoc.executable); + setArguments(assoc.formattedCommandLine); + setCurrentDirectory(assoc.executable.absoluteDir()); + m_shellOpen = {}; + } else { + // if it fails, just use the regular shell open + log::error("failed to get the associated executable, running unhooked"); + m_sp.hooked = false; + } + } else if (!shouldRunShell() && !m_sp.hooked) { + // this is an executable that should not be hooked; just run it through + // the shell + m_shellOpen = m_sp.binary; + } + + std::optional r; + + if (shouldRunShell()) { + r = runShell(); + } else { + r = runBinary(); + } + + if (r) { + // early result: something went wrong and the process cannot be waited for + return *r; + } + + return postRun(); +} + +std::optional ProcessRunner::runShell() +{ + const auto file = MOBase::normalizePathForHost(m_shellOpen.absoluteFilePath()); + + log::debug("executing from shell: '{}'", file); + + auto r = shell::Open(file); + if (!r.success()) { + return Error; + } + + m_handle.reset(r.stealProcessHandle()); + + // not all files will return a valid handle even if opening them was + // successful, such as inproc handlers (like the photo viewer); in this + // case it's impossible to determine the status, so just say it's still + // running + if (m_handle.get() == INVALID_HANDLE_VALUE) { + log::debug("shell didn't report an error, but no handle is available"); + return Running; + } + + return {}; +} + +std::optional ProcessRunner::runBinary() +{ + if (m_profileName.isEmpty()) { + // get the current profile name if it wasn't overridden + const auto profile = m_core.currentProfile(); + if (!profile) { + throw MyException(QObject::tr("No profile set")); + } + + m_profileName = profile->name(); + } + + // saves profile, sets up usvfs, notifies plugins, etc.; can return false if + // a plugin doesn't want the program to run (such as when checkFNIS fails to + // run FNIS and the user clicks cancel) + if (!m_core.beforeRun(m_sp.binary, m_sp.currentDirectory, m_sp.arguments, + m_profileName, m_customOverwrite, m_forcedLibraries)) { + return Error; + } + + // parent widget used for any dialog popped up while checking for things + QWidget* parent = (m_ui ? m_ui->mainWindow() : nullptr); + + const auto* game = m_core.managedGame(); + auto& settings = m_core.settings(); + + if (m_sp.steamAppID.trimmed().isEmpty()) { + const QString gameSteamId = game->steamAPPId().trimmed(); + if (!gameSteamId.isEmpty()) { + m_sp.steamAppID = gameSteamId; + log::debug("process runner: using game steam app id '{}' for launch", + m_sp.steamAppID); + } + } + + // start steam if needed + if (!checkSteam(parent, m_sp, game->gameDirectory(), m_sp.steamAppID, settings)) { + return Error; + } + + // warn if the executable is on the blacklist + if (!checkBlacklist(parent, m_sp, settings)) { + return Error; + } + + // if the executable is inside the mods folder another instance of + // ModOrganizer.exe is spawned instead to launch it + adjustForVirtualized(game, m_sp, settings); + + // run the binary +#ifdef _WIN32 + m_handle.reset(startBinary(parent, m_sp)); +#else + m_handle.reset(reinterpret_cast(static_cast(startBinary(parent, m_sp)))); +#endif + if (m_handle.get() == INVALID_HANDLE_VALUE) { + return Error; + } + + return {}; +} + +bool ProcessRunner::shouldRefresh(Results r) const +{ + // afterRun() is only called with the Refresh flag; it refreshes the + // directory structure and notifies plugins + // + // refreshing is not always required and can actually cause problems: + // + // 1) running shortcuts doesn't need refreshing because MO closes right + // after + // + // 2) the mod info dialog is not set up to deal with refreshes, so that + // it will crash because the old DirectoryEntry's are still being used + // in the list + if (!m_waitFlags.testFlag(TriggerRefresh)) { + log::debug("process runner: not refreshing because the flag isn't set"); + return false; + } + + switch (r) { + case Completed: { + log::debug("process runner: refreshing because the process completed"); + return true; + } + + case ForceUnlocked: { + // The process may still be running when the user force-unlocks. + // Refreshing in that state can race with file updates. + log::debug("process runner: not refreshing because the ui was force unlocked"); + return false; + } + + case Error: // fall-through + case Cancelled: + case Running: + default: { + return false; + } + } +} + +ProcessRunner::Results ProcessRunner::postRun() +{ + const bool mustWait = (m_waitFlags & ForceWait); + + if (!m_sp.hooked && !mustWait) { + // the process wasn't hooked and there's no force wait, don't lock + return Running; + } + + if (mustWait && m_lockReason == UILocker::NoReason) { + // never lock the ui without an escape hatch for the user + log::debug("the ForceWait flag is set but the lock reason wasn't, " + "defaulting to LockUI"); + + m_lockReason = UILocker::LockUI; + } + + const bool lockEnabled = m_core.settings().interface().lockGUI(); + const QStringList expectedExecutables = + buildExpectedExecutables(m_sp.binary, m_sp.arguments); + + if (mustWait) { + if (!lockEnabled) { + // at least tell the user what's going on + log::debug("locking is disabled, but the output of the application is required; " + "overriding this setting and locking the ui"); + } + } else { + // no force wait + + if (m_lockReason == UILocker::NoReason) { + // no locking requested +#ifndef _WIN32 + // Main window launches typically use TriggerRefresh without waiting/locking. + // In that mode we still need post-run refresh/sync once the process exits. + if (m_waitFlags.testFlag(TriggerRefresh)) { + const pid_t pid = static_cast(reinterpret_cast(m_handle.get())); + const QFileInfo binary = m_sp.binary; + QPointer core = &m_core; + + std::thread([core, binary, pid]() { + int status = 0; + pid_t waited = -1; + do { + waited = ::waitpid(pid, &status, 0); + } while (waited == -1 && errno == EINTR); + + DWORD exitCode = 0; + if (waited == pid) { + if (WIFEXITED(status)) { + exitCode = static_cast(WEXITSTATUS(status)); + } else if (WIFSIGNALED(status)) { + exitCode = static_cast(128 + WTERMSIG(status)); + } + } else { + MOBase::log::warn("process runner: waitpid failed for pid {}: {}", pid, + errno); + } + + if (!core) { + return; + } + + QMetaObject::invokeMethod( + core, [core, binary, exitCode]() { + if (core) { + core->afterRun(binary, exitCode); + } + }, + Qt::QueuedConnection); + }).detach(); + + log::debug("process runner: scheduled async post-run refresh for pid {}", pid); + } +#endif + return Running; + } + + if (!lockEnabled) { + // disabling locking is like clicking on unlock immediately + log::debug("process runner: not waiting for process because " + "locking is disabled"); + + return ForceUnlocked; + } + } + + auto r = Error; + + if (mustWait && m_lockReason == UILocker::PreventExit && !lockEnabled) { + // this happens when running shortcuts and locking is disabled + // + // MO must stay alive until all processes are dead or child processes + // may not get hooked properly, but the user has disabled locking the ui + // + // this is a bit of an edge case, but that means the user wants to run + // shortcuts without seeing the lock dialog, so allow them to do that + // + // MO will be running in the background with no visual feedback, but that's + // how it is + r = waitForProcess(m_handle.get(), &m_exitCode, nullptr, expectedExecutables); + } else { + withLock([&](auto& ls) { + r = waitForProcess(m_handle.get(), &m_exitCode, &ls, expectedExecutables); + }); + } + + if (shouldRefresh(r)) { + QEventLoop loop; + const bool wait = m_waitFlags.testFlag(WaitForRefresh); + + if (wait) { + QObject::connect(&m_core, &OrganizerCore::directoryStructureReady, &loop, + &QEventLoop::quit, Qt::ConnectionType::QueuedConnection); + } + + m_core.afterRun(m_sp.binary, m_exitCode); + + if (wait) { + log::debug("process runner: waiting until refresh finishes"); + loop.exec(); + log::debug("process runner: refresh is done"); + } + } + + return r; +} + +#ifdef _WIN32 +ProcessRunner::Results ProcessRunner::attachToProcess(HANDLE h) +{ + m_handle.reset(h); + return postRun(); +} +#else +ProcessRunner::Results ProcessRunner::attachToProcess(pid_t pid) +{ + m_handle.reset(reinterpret_cast(static_cast(pid))); + return postRun(); +} +#endif + +DWORD ProcessRunner::exitCode() const +{ + return m_exitCode; +} + +#ifdef _WIN32 +HANDLE ProcessRunner::getProcessHandle() const +{ + return m_handle.get(); +} +#else +pid_t ProcessRunner::getProcessHandle() const +{ + return static_cast(reinterpret_cast(m_handle.get())); +} +#endif + +env::HandlePtr ProcessRunner::stealProcessHandle() +{ + auto h = m_handle.release(); + m_handle.reset(INVALID_HANDLE_VALUE); + return env::HandlePtr(h); +} + +#ifdef _WIN32 +ProcessRunner::Results +ProcessRunner::waitForAllUSVFSProcessesWithLock(UILocker::Reasons reason) +{ + m_lockReason = reason; + + if (!m_core.settings().interface().lockGUI()) { + // disabling locking is like clicking on unlock immediately + return ForceUnlocked; + } + + auto r = Error; + + for (;;) { + withLock([&](auto& ls) { + const auto processes = getRunningUSVFSProcesses(); + if (processes.empty()) { + r = Completed; + return; + } + + r = waitForProcesses(processes, &ls); + + if (r != Completed) { + // error, cancelled, or unlocked + return; + } + + // this process is completed, check for others + r = Running; + }); + + if (r != Running) { + break; + } + } + + return r; +} +#endif + +void ProcessRunner::withLock(std::function f) +{ + auto ls = UILocker::instance().lock(m_lockReason); + f(*ls); +} diff --git a/src/src/processrunner.h b/src/src/processrunner.h new file mode 100644 index 0000000..2736c86 --- /dev/null +++ b/src/src/processrunner.h @@ -0,0 +1,211 @@ +#ifndef PROCESSRUNNER_H +#define PROCESSRUNNER_H + +#include "envmodule.h" +#include "spawn.h" +#include "uilocker.h" +#include + +#ifndef _WIN32 +#include +#include +#endif + +class OrganizerCore; +class IUserInterface; +class Executable; +class MOShortcut; + +// handles spawning a process and waiting for it, including setting up the lock +// widget if required +// +class ProcessRunner +{ +public: + enum Results + { + // the process is still running + Running = 1, + + // the process has run to completion + Completed, + + // the process couldn't be started or waited for + Error, + + // the user has clicked the cancel button in the lock widget + Cancelled, + + // the user has clicked the unlock button in the lock widget + ForceUnlocked + }; + + enum WaitFlag + { + NoFlags = 0x00, + + // the directory structure will be refreshed once the process has completed + TriggerRefresh = 0x01, + + // the process will be waited for even if locking is disabled or the + // process is not hooked + ForceWait = 0x02, + + // only valid with TriggerRefresh; run() will block until the refresh has + // completed + WaitForRefresh = 0x04, + + // combination of flags used to run programs from the command line + // + // 1) TriggerRefresh: MO must refresh after running the program because + // programs can modify files behind its back; for example, external + // LOOT will modify loadorder.txt, so MO must read it back or it will + // write back the old order when exiting + // + // 2) WaitForRefresh: refreshing is asynchronous, so the refresh must + // complete before MO exits or stale data might be written to disk + // + // 3) ForceWait: MO must wait for the program to finish even if locking the + // ui is disabled + ForCommandLine = TriggerRefresh | WaitForRefresh | ForceWait + }; + + using WaitFlags = QFlags; + using ForcedLibraries = QList; + + ProcessRunner(OrganizerCore& core, IUserInterface* ui); + + // move only + ProcessRunner(ProcessRunner&&) = default; + ProcessRunner& operator=(const ProcessRunner&) = delete; + ProcessRunner(const ProcessRunner&) = delete; + ProcessRunner& operator=(ProcessRunner&&) = delete; + + ProcessRunner& setBinary(const QFileInfo& binary); + ProcessRunner& setArguments(const QString& arguments); + ProcessRunner& setCurrentDirectory(const QDir& directory); + ProcessRunner& setSteamID(const QString& steamID); + ProcessRunner& setCustomOverwrite(const QString& customOverwrite); + ProcessRunner& setForcedLibraries(const ForcedLibraries& forcedLibraries); + ProcessRunner& setProfileName(const QString& profileName); + ProcessRunner& setWaitForCompletion(WaitFlags flags = NoFlags, + UILocker::Reasons reason = UILocker::LockUI); + ProcessRunner& setHooked(bool b); + + // - if the target is an executable file, runs it hooked + // - if the target is a file: + // - if forceHook is false, calls ShellExecute() on it + // - if forceHook is true, gets the executable associated with the file + // and runs that hooked by passing the file as an argument + // + ProcessRunner& setFromFile(QWidget* parent, const QFileInfo& targetInfo); + + ProcessRunner& setFromExecutable(const Executable& exe); + ProcessRunner& setFromShortcut(const MOShortcut& shortcut); + + // this is a messy one that's used for running an arbitrary file from the + // command line, or by plugins (see OrganizerProxy::startApplication()) + // + // 1) if `executable` contains a path separator, it's treated as a binary on + // disk and will be launched with given settings; it's also looked up in + // the list of configured executables, which sets the steam ID and forced + // libraries + // + // 2) if `executable` has no path separators, it's treated purely as an + // executable, but its arguments, current directory and custom overwrite + // can also be overridden + // + // if the executable is not found in the list, the binary is run solely + // based on the parameters given + // + ProcessRunner& setFromFileOrExecutable(const QString& executable, + const QStringList& args, + const QString& cwd = {}, + const QString& profile = {}, + const QString& forcedCustomOverwrite = {}, + bool ignoreCustomOverwrite = false); + + // spawns the process and waits for it if required + // + Results run(); + + // takes ownership of the given handle and waits for it if required + // +#ifdef _WIN32 + Results attachToProcess(HANDLE h); +#else + Results attachToProcess(pid_t pid); +#endif + + // exit code of the process, will return -1 if the process wasn't waited for + // + DWORD exitCode() const; + + // this may be INVALID_HANDLE_VALUE if: + // + // 1) no process was started, or + // 2) the process was started successfully, but the system didn't return a + // handle for it; this can happen for inproc handlers, for example, such + // the photo viewer + // + // note that the handle is still owned by this ProcessRunner and will be + // closed when destroyed; see stealProcessHandle() + // +#ifdef _WIN32 + HANDLE getProcessHandle() const; +#else + pid_t getProcessHandle() const; +#endif + + // releases ownership of the process handle; if this is called after the + // process is completed, exitCode() will still return the correct value + // + env::HandlePtr stealProcessHandle(); + +#ifdef _WIN32 + // waits for all usvfs processes spawned by this instance of MO; returns + // immediately with ForceUnlocked if locking is disabled + // + // strictly speaking, this shouldn't be here, as it has nothing to do with + // running a process, but it uses the same internal stuff as when running a + // process + // + Results waitForAllUSVFSProcessesWithLock(UILocker::Reasons reason); +#endif + +private: + OrganizerCore& m_core; + IUserInterface* m_ui; + spawn::SpawnParameters m_sp; + QString m_customOverwrite; + ForcedLibraries m_forcedLibraries; + QString m_profileName; + UILocker::Reasons m_lockReason; + WaitFlags m_waitFlags; + QFileInfo m_shellOpen; + env::HandlePtr m_handle; + DWORD m_exitCode; + + bool shouldRunShell() const; + bool shouldRefresh(Results r) const; + + // runs the command in m_shellOpen; returns empty if it can be waited for + // + std::optional runShell(); + + // runs the binary; returns empty if it can be waited for + // + std::optional runBinary(); + + // waits for process completion if required + // + Results postRun(); + + // creates the lock widget and calls f() + // + void withLock(std::function f); +}; + +Q_DECLARE_OPERATORS_FOR_FLAGS(ProcessRunner::WaitFlags); + +#endif // PROCESSRUNNER_H diff --git a/src/src/profile.cpp b/src/src/profile.cpp new file mode 100644 index 0000000..be22520 --- /dev/null +++ b/src/src/profile.cpp @@ -0,0 +1,1328 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "profile.h" + +#include "filesystemutilities.h" +#include "game_features.h" +#include "modinfo.h" +#include "modinfoforeign.h" +#include "registry.h" +#include "settings.h" +#include "shared/appconfig.h" +#include "shared/util.h" +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include // for QFile +#include // for operator|, QFlags +#include // for QIODevice, etc +#include +#include +#include // for QStringList +#include // for qUtf8Printable + +#ifdef _WIN32 +#include +#endif + +#include // for assert +#include // for UINT_MAX, INT_MAX, etc +#include // for size_t +#include // for wcslen + +#include // for max, min +#include // for exception +#include +#include // for set +#include +#include // for find + +using namespace MOBase; +using namespace MOShared; + +namespace +{ +QString resolveExistingFileCaseInsensitive(const QString& path) +{ +#ifdef _WIN32 + return QDir::cleanPath(path); +#else + const QFileInfo info(path); + if (info.exists()) { + return info.absoluteFilePath(); + } + + QDir dir(info.path()); + if (!dir.exists()) { + return QDir::cleanPath(path); + } + + const QString target = info.fileName(); + const QStringList entries = + dir.entryList(QDir::Files | QDir::Readable | QDir::Hidden | QDir::System); + for (const QString& entry : entries) { + if (entry.compare(target, Qt::CaseInsensitive) == 0) { + return dir.absoluteFilePath(entry); + } + } + + return QDir::cleanPath(path); +#endif +} +} // namespace + +void Profile::touchFile(QString fileName) +{ + QFile modList(m_Directory.filePath(fileName)); + if (!modList.open(QIODevice::ReadWrite)) { + throw std::runtime_error(QObject::tr("failed to create %1") + .arg(m_Directory.filePath(fileName)) + .toUtf8() + .constData()); + } +} + +Profile::Profile(const QString& name, IPluginGame const* gamePlugin, + GameFeatures const& gameFeatures, bool useDefaultSettings) + : m_ModListWriter(std::bind(&Profile::doWriteModlist, this)), + m_GamePlugin(gamePlugin), m_GameFeatures(gameFeatures) +{ + QString profilesDir = Settings::instance().paths().profiles(); + QDir profileBase(profilesDir); + QString fixedName = name; + if (!fixDirectoryName(fixedName)) { + throw MyException(tr("invalid profile name: %1").arg(qUtf8Printable(name))); + } + + if (!profileBase.exists() || !profileBase.mkdir(fixedName)) { + throw MyException(tr("failed to create %1").arg(fixedName).toUtf8().constData()); + } + QString fullPath = profilesDir + "/" + fixedName; + m_Directory = QDir(fullPath); + m_Settings = + new QSettings(m_Directory.absoluteFilePath("settings.ini"), QSettings::IniFormat); + + try { + // create files. Needs to happen after m_Directory was set! + touchFile("modlist.txt"); + touchFile("archives.txt"); + + IPluginGame::ProfileSettings settings = + IPluginGame::CONFIGURATION | IPluginGame::MODS | IPluginGame::SAVEGAMES; + + if (useDefaultSettings) { + settings |= IPluginGame::PREFER_DEFAULTS; + } + + gamePlugin->initializeProfile(fullPath, settings); + findProfileSettings(); + } catch (...) { + // clean up in case of an error + shellDelete(QStringList(profileBase.absoluteFilePath(fixedName))); + throw; + } + refreshModStatus(); +} + +Profile::Profile(const QDir& directory, IPluginGame const* gamePlugin, + GameFeatures const& gameFeatures) + : m_Directory(directory), m_GamePlugin(gamePlugin), m_GameFeatures(gameFeatures), + m_ModListWriter(std::bind(&Profile::doWriteModlist, this)) +{ + assert(gamePlugin != nullptr); + + m_Settings = + new QSettings(directory.absoluteFilePath("settings.ini"), QSettings::IniFormat); + findProfileSettings(); + + if (!QFile::exists(m_Directory.filePath("modlist.txt"))) { + log::warn("missing modlist.txt in {}", directory.path()); + touchFile(m_Directory.filePath("modlist.txt")); + } + + IPluginGame::ProfileSettings settings = IPluginGame::MODS | IPluginGame::SAVEGAMES; + gamePlugin->initializeProfile(directory, settings); + + refreshModStatus(); +} + +Profile::Profile(const Profile& reference) + : m_Directory(reference.m_Directory), + m_ModListWriter(std::bind(&Profile::doWriteModlist, this)), + m_GamePlugin(reference.m_GamePlugin), m_GameFeatures(reference.m_GameFeatures) + +{ + m_Settings = + new QSettings(m_Directory.absoluteFilePath("settings.ini"), QSettings::IniFormat); + findProfileSettings(); + refreshModStatus(); +} + +Profile::~Profile() +{ + delete m_Settings; + m_ModListWriter.writeImmediately(true); +} + +void Profile::findProfileSettings() +{ + if (setting("", "LocalSaves") == QVariant()) { + if (m_Directory.exists("saves")) { + if (!Settings::instance().profileLocalSaves()) { + m_Directory.rename("saves", "_saves"); + storeSetting("", "LocalSaves", false); + } else { + storeSetting("", "LocalSaves", true); + } + } else { + if (m_Directory.exists("_saves")) { + if (Settings::instance().profileLocalSaves()) { + m_Directory.rename("_saves", "saves"); + storeSetting("", "LocalSaves", true); + } else { + storeSetting("", "LocalSaves", false); + } + } else { + storeSetting("", "LocalSaves", Settings::instance().profileLocalSaves()); + } + } + } + + if (setting("", "LocalSettings") == QVariant()) { + QString backupFile = getIniFileName() + "_"; + if (m_Directory.exists(backupFile)) { + storeSetting("", "LocalSettings", true); + m_Directory.rename(backupFile, getIniFileName()); + } else if (Settings::instance().profileLocalInis()) { + storeSetting("", "LocalSettings", true); + enableLocalSettings(true); + } else { + storeSetting("", "LocalSettings", false); + } + } + + if (setting("", "AutomaticArchiveInvalidation") == QVariant()) { + auto invalidation = m_GameFeatures.gameFeature(); + auto dataArchives = m_GameFeatures.gameFeature(); + bool found = false; + if ((invalidation != nullptr) && (dataArchives != nullptr)) { + for (const QString& archive : dataArchives->archives(this)) { + if (invalidation->isInvalidationBSA(archive)) { + found = true; + break; + } + } + } + if (found) { + if (!Settings::instance().profileArchiveInvalidation()) { + deactivateInvalidation(); + } else { + storeSetting("", "AutomaticArchiveInvalidation", true); + } + } else { + if (Settings::instance().profileArchiveInvalidation()) { + activateInvalidation(); + } else { + storeSetting("", "AutomaticArchiveInvalidation", false); + } + } + } +} + +bool Profile::exists() const +{ + return m_Directory.exists(); +} + +void Profile::writeModlist() +{ + m_ModListWriter.write(); +} + +void Profile::writeModlistNow(bool onlyIfPending) +{ + m_ModListWriter.writeImmediately(onlyIfPending); +} + +void Profile::cancelModlistWrite() +{ + m_ModListWriter.cancel(); +} + +void Profile::doWriteModlist() +{ + if (!m_Directory.exists()) + return; + + try { + QString fileName = getModlistFileName(); + SafeWriteFile file(fileName); + + file->write(QString("# This file was automatically generated by Mod Organizer.\r\n") + .toUtf8()); + if (m_ModStatus.empty()) { + return; + } + + for (auto iter = m_ModIndexByPriority.crbegin(); + iter != m_ModIndexByPriority.crend(); iter++) { + // the priority order was inverted on load so it has to be inverted again + const auto index = iter->second; + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + if (!modInfo->hasAutomaticPriority()) { + if (modInfo->isForeign()) { + file->write("*"); + } else if (m_ModStatus[index].m_Enabled) { + file->write("+"); + } else { + file->write("-"); + } + file->write(modInfo->name().toUtf8()); + file->write("\r\n"); + } + } + + file->commit(); + } catch (const std::exception& e) { + reportError(tr("failed to write mod list: %1").arg(e.what())); + return; + } +} + +void Profile::createTweakedIniFile() +{ + QString tweakedIni = m_Directory.absoluteFilePath("initweaks.ini"); + + if (QFile::exists(tweakedIni) && !shellDeleteQuiet(tweakedIni)) { + const auto e = GetLastError(); + reportError(tr("failed to update tweaked ini file, wrong settings may be used: %1") + .arg(QString::fromStdWString(formatSystemMessage(e)))); + return; + } + + for (const auto& [priority, index] : m_ModIndexByPriority) { + if (m_ModStatus[index].m_Enabled) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + mergeTweaks(modInfo, tweakedIni); + } + } + + mergeTweak(getProfileTweaks(), tweakedIni); + + bool error = false; +#ifdef _WIN32 + if (!MOBase::WriteRegistryValue(L"Archive", L"bInvalidateOlderFiles", L"1", + ToWString(tweakedIni).c_str())) { + error = true; + } +#else + if (!MOBase::WriteRegistryValue( + QStringLiteral("Archive"), QStringLiteral("bInvalidateOlderFiles"), + QStringLiteral("1"), tweakedIni)) { + error = true; + } +#endif + + if (error) { + const auto e = ::GetLastError(); + reportError(tr("failed to create tweaked ini: %1") + .arg(QString::fromStdWString(formatSystemMessage(e)))); + } +} + +// static +void Profile::renameModInAllProfiles(const QString& oldName, const QString& newName) +{ + QDir profilesDir(Settings::instance().paths().profiles()); + profilesDir.setFilter(QDir::AllDirs | QDir::NoDotAndDotDot); + QDirIterator profileIter(profilesDir); + while (profileIter.hasNext()) { + profileIter.next(); + QFile modList(profileIter.filePath() + "/modlist.txt"); + if (modList.exists()) + renameModInList(modList, oldName, newName); + else + log::warn("Profile has no modlist.txt: {}", profileIter.filePath()); + } +} + +// static +void Profile::renameModInList(QFile& modList, const QString& oldName, + const QString& newName) +{ + if (!modList.open(QIODevice::ReadOnly)) { + reportError(tr("failed to open %1").arg(modList.fileName())); + return; + } + + QBuffer outBuffer; + outBuffer.open(QIODevice::WriteOnly); + + int renamed = 0; + while (!modList.atEnd()) { + QByteArray line = modList.readLine(); + + if (line.length() == 0) { + // ignore empty lines + log::warn("mod list contained invalid data: empty line"); + continue; + } + + char spec = line.at(0); + if (spec == '#') { + // don't touch comments + outBuffer.write(line); + continue; + } + + QString modName = QString::fromUtf8(line).mid(1).trimmed(); + + if (modName.isEmpty()) { + // file broken? + log::warn("mod list contained invalid data: missing mod name"); + continue; + } + + outBuffer.write(QByteArray(1, spec)); + if (modName == oldName) { + modName = newName; + ++renamed; + } + outBuffer.write(qUtf8Printable(modName)); + outBuffer.write("\r\n"); + } + modList.close(); + + if (renamed) { + modList.open(QIODevice::WriteOnly); + modList.write(outBuffer.buffer()); + modList.close(); + } + + if (renamed) + log::debug("Renamed {} \"{}\" mod to \"{}\" in {}", renamed, oldName, newName, + modList.fileName()); +} + +void Profile::refreshModStatus() +{ + // this function refreshes mod status (enabled/disabled) and priority + // using the profile mod list file and the mods in the mods folder using + // the following steps + // + // 1) the mod list file is read and mods status/priority are updated by + // considering the content of the file (for status) and the order (for + // priority), missing or invalid mods are discarded (with a warning) + // 2) the priority are reversed to match the plugin list (highest wins) + // since the mod list is written in reverse order + // 3) at the same time, new mods (not in the mod list file) are added + // - foreign mods are given low priority (below 0) + // - regular mods are given high priority (above mods from the mod list) + // 4) the priority are shifted to ensure that the minimum priority is 0 + // 5) the priority of backups are computed such that the first backup is + // above all regular mods + // + // in the context of the profile, "regular mods" means a mod whose priority + // can be set by the user (i.e. not a backup or overwrite) + // + // this method ensures that the mods priority is as follow + // + // 0 mod1 + // 1 mod2 + // ... + // K-1 modK (K = m_NumRegularMods) + // K backup1 + // K+1 backup2 + // ... + // N-2 backupX + // N-1 overwrite (N = number of mods) + // + + writeModlistNow(true); // if there are pending changes write them first + + QFile file(getModlistFileName()); + if (!file.open(QIODevice::ReadOnly)) { + throw MyException( + tr("\"%1\" is missing or inaccessible").arg(getModlistFileName())); + } + + bool modStatusModified = false; + m_ModStatus.clear(); + m_ModStatus.resize(ModInfo::getNumMods()); + + std::set namesRead; + + bool warnAboutOverwrite = false; + + // load mods from file and update enabled state and priority for them + int index = 0; + while (!file.atEnd()) { + QByteArray line = file.readLine().trimmed(); + + // find the mod name and the enabled status + bool enabled = true; + QString modName; + if (line.length() == 0) { + // empty line + continue; + } else if (line.at(0) == '#') { + // comment line + continue; + } else if (line.at(0) == '-') { + enabled = false; + modName = QString::fromUtf8(line.mid(1).trimmed().constData()); + } else if (line.at(0) == '+' || line.at(0) == '*') { + modName = QString::fromUtf8(line.mid(1).trimmed().constData()); + } else { + modName = QString::fromUtf8(line.trimmed().constData()); + } + + if (modName.isEmpty()) { + continue; + } + + if (modName.compare("overwrite", Qt::CaseInsensitive) == 0) { + warnAboutOverwrite = true; + } + + // check if the name was already read + if (namesRead.find(modName) != namesRead.end()) { + continue; + } + namesRead.insert(modName); + + unsigned int modIndex = ModInfo::getIndex(modName); + if (modIndex == UINT_MAX) { + log::debug("mod not found: \"{}\" (profile \"{}\")", modName, m_Directory.path()); + // need to rewrite the modlist to fix this + modStatusModified = true; + continue; + } + + // find the mod and check that this is a regular mod (and not a backup) + ModInfo::Ptr info = ModInfo::getByIndex(modIndex); + if (modIndex < m_ModStatus.size() && !info->hasAutomaticPriority()) { + m_ModStatus[modIndex].m_Enabled = enabled; + if (m_ModStatus[modIndex].m_Priority == -1) { + if (static_cast(index) >= m_ModStatus.size()) { + throw Exception(tr("invalid mod index: %1").arg(index)); + } + m_ModStatus[modIndex].m_Priority = index++; + } + } else { + log::warn("no mod state for \"{}\" (profile \"{}\")", modName, + m_Directory.path()); + // need to rewrite the modlist to fix this + modStatusModified = true; + } + + } // while (!file.atEnd()) + + file.close(); + + const int numKnownMods = index; + int topInsert = 0; + + // invert priority order to match that of the pluginlist, also + // give priorities to mods not referenced in the profile and + // count the number of regular mods + m_NumRegularMods = 0; + for (size_t i = 0; i < m_ModStatus.size(); ++i) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(static_cast(i)); + if (modInfo->alwaysEnabled()) { + m_ModStatus[i].m_Enabled = true; + } + + if (modInfo->isOverwrite()) { + m_ModStatus[i].m_Priority = m_ModStatus.size() - 1; + continue; + } + + if (m_ModStatus[i].m_Priority != -1) { + m_ModStatus[i].m_Priority = numKnownMods - m_ModStatus[i].m_Priority - 1; + ++m_NumRegularMods; + } else { + if (static_cast(index) >= m_ModStatus.size()) { + throw Exception(tr("invalid mod index: %1").arg(index)); + } + + // skip backups on purpose to avoid inserting backups in-between + // regular mods + if (modInfo->isForeign()) { + m_ModStatus[i].m_Priority = --topInsert; + ++m_NumRegularMods; + } else if (!modInfo->isBackup()) { + m_ModStatus[i].m_Priority = index++; + ++m_NumRegularMods; + } + + // also, mark the mod-list as changed + modStatusModified = true; + } + } + + // to support insertion of new mods at the top we may now have mods with negative + // priority, so shift them all up to align priority with 0 + if (topInsert < 0) { + int offset = topInsert * -1; + for (size_t i = 0; i < m_ModStatus.size(); ++i) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(static_cast(i)); + if (modInfo->hasAutomaticPriority()) { + continue; + } + + m_ModStatus[i].m_Priority += offset; + } + } + + // set the backups priority + int backupPriority = m_NumRegularMods; + for (size_t i = 0; i < m_ModStatus.size(); ++i) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(static_cast(i)); + if (modInfo->isBackup()) { + m_ModStatus[i].m_Priority = backupPriority++; + } + } + + updateIndices(); + + // User has a mod named some variation of "overwrite". Tell them about it. + if (warnAboutOverwrite) { + reportError(tr("A mod named \"overwrite\" was detected, disabled, and moved to the " + "highest priority on the mod list. " + "You may want to rename this mod and enable it again.")); + // also, mark the mod-list as changed + modStatusModified = true; + } + + if (modStatusModified) { + m_ModListWriter.write(); + } +} + +void Profile::dumpModStatus() const +{ + for (unsigned int i = 0; i < m_ModStatus.size(); ++i) { + ModInfo::Ptr info = ModInfo::getByIndex(i); + log::warn("{}: {} - {} ({})", i, info->name(), m_ModStatus[i].m_Priority, + m_ModStatus[i].m_Enabled ? "enabled" : "disabled"); + } +} + +void Profile::updateIndices() +{ + m_ModIndexByPriority.clear(); + for (unsigned int i = 0; i < m_ModStatus.size(); ++i) { + int priority = m_ModStatus[i].m_Priority; + m_ModIndexByPriority[priority] = i; + } +} + +std::vector> Profile::getActiveMods() +{ + std::vector> result; + for (const auto& [priority, index] : m_ModIndexByPriority) { + if (m_ModStatus[index].m_Enabled) { + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + result.push_back(std::make_tuple(modInfo->internalName(), modInfo->absolutePath(), + m_ModStatus[index].m_Priority)); + } + } + + return result; +} + +void Profile::setModEnabled(unsigned int index, bool enabled) +{ + if (index >= m_ModStatus.size()) { + throw MyException(tr("invalid mod index: %1").arg(index)); + } + + ModInfo::Ptr modInfo = ModInfo::getByIndex(index); + + // we could quit in the following case, this shouldn't be a change anyway, + // but at least this allows the situation to be fixed in case of an error + if (modInfo->alwaysEnabled()) { + enabled = true; + } + if (modInfo->alwaysDisabled()) { + enabled = false; + } + + if (enabled != m_ModStatus[index].m_Enabled) { + m_ModStatus[index].m_Enabled = enabled; + emit modStatusChanged(index); + } +} + +void Profile::setModsEnabled(const QList& modsToEnable, + const QList& modsToDisable) +{ + QList dirtyMods; + for (auto idx : modsToEnable) { + if (idx >= m_ModStatus.size()) { + log::error("invalid mod index: {}", idx); + continue; + } + if (ModInfo::getByIndex(idx)->alwaysDisabled()) { + continue; + } + if (!m_ModStatus[idx].m_Enabled) { + m_ModStatus[idx].m_Enabled = true; + dirtyMods.append(idx); + } + } + for (auto idx : modsToDisable) { + if (idx >= m_ModStatus.size()) { + log::error("invalid mod index: {}", idx); + continue; + } + if (ModInfo::getByIndex(idx)->alwaysEnabled()) { + continue; + } + if (m_ModStatus[idx].m_Enabled) { + m_ModStatus[idx].m_Enabled = false; + dirtyMods.append(idx); + } + } + if (!dirtyMods.isEmpty()) { + emit modStatusChanged(dirtyMods); + } +} + +bool Profile::modEnabled(unsigned int index) const +{ + if (index >= m_ModStatus.size()) { + throw MyException(tr("invalid mod index: %1").arg(index)); + } + + return m_ModStatus[index].m_Enabled; +} + +int Profile::getModPriority(unsigned int index) const +{ + if (index >= m_ModStatus.size()) { + throw MyException(tr("invalid mod index: %1").arg(index)); + } + + return m_ModStatus[index].m_Priority; +} + +bool Profile::setModPriority(unsigned int index, int& newPriority) +{ + if (ModInfo::getByIndex(index)->hasAutomaticPriority()) { + // can't change priority of overwrite/backups + return false; + } + + newPriority = std::clamp(newPriority, 0, static_cast(m_NumRegularMods) - 1); + + int oldPriority = m_ModStatus.at(index).m_Priority; + int lastPriority = INT_MIN; + + if (newPriority == oldPriority) { + // nothing to do + return false; + } + + for (const auto& [priority, index] : m_ModIndexByPriority) { + if (newPriority < oldPriority && priority >= newPriority && + priority < oldPriority) { + m_ModStatus.at(index).m_Priority += 1; + } else if (newPriority > oldPriority && priority <= newPriority && + priority > oldPriority) { + m_ModStatus.at(index).m_Priority -= 1; + } + lastPriority = std::max(lastPriority, priority); + } + + newPriority = std::min(newPriority, lastPriority); + m_ModStatus.at(index).m_Priority = std::min(newPriority, lastPriority); + + updateIndices(); + m_ModListWriter.write(); + + return true; +} + +Profile* Profile::createPtrFrom(const QString& name, const Profile& reference, + MOBase::IPluginGame const* gamePlugin) +{ + QString profileDirectory = Settings::instance().paths().profiles() + "/" + name; + reference.copyFilesTo(profileDirectory); + return new Profile(QDir(profileDirectory), gamePlugin, reference.m_GameFeatures); +} + +void Profile::copyFilesTo(QString& target) const +{ + copyDir(m_Directory.absolutePath(), target, false); +} + +std::vector Profile::splitDZString(const wchar_t* buffer) const +{ + std::vector result; + const wchar_t* pos = buffer; + size_t length = wcslen(pos); + while (length != 0U) { + result.push_back(pos); + pos += length + 1; + length = wcslen(pos); + } + return result; +} + +void Profile::mergeTweak(const QString& tweakName, const QString& tweakedIni) const +{ +#ifdef _WIN32 + static const int bufferSize = 32768; + + std::wstring tweakNameW = ToWString(tweakName); + std::wstring tweakedIniW = ToWString(tweakedIni); + QScopedArrayPointer buffer(new wchar_t[bufferSize]); + + // retrieve a list of sections + DWORD size = + ::GetPrivateProfileSectionNamesW(buffer.data(), bufferSize, tweakNameW.c_str()); + + if (size == bufferSize - 2) { + // unfortunately there is no good way to find the required size + // of the buffer + throw MyException(QString("Buffer too small. Please report this as a bug. " + "For now you might want to split up %1") + .arg(tweakName)); + } + + std::vector sections = splitDZString(buffer.data()); + + // now iterate over all sections and retrieve a list of keys in each + for (std::vector::iterator iter = sections.begin(); + iter != sections.end(); ++iter) { + // retrieve the names of all keys + size = ::GetPrivateProfileStringW(iter->c_str(), nullptr, nullptr, buffer.data(), + bufferSize, tweakNameW.c_str()); + if (size == bufferSize - 2) { + throw MyException(QString("Buffer too small. Please report this as a bug. " + "For now you might want to split up %1") + .arg(tweakName)); + } + + std::vector keys = splitDZString(buffer.data()); + + for (std::vector::iterator keyIter = keys.begin(); + keyIter != keys.end(); ++keyIter) { + // TODO this treats everything as strings but how could I differentiate the type? + ::GetPrivateProfileStringW(iter->c_str(), keyIter->c_str(), nullptr, + buffer.data(), bufferSize, + ToWString(tweakName).c_str()); + MOBase::WriteRegistryValue(iter->c_str(), keyIter->c_str(), buffer.data(), + tweakedIniW.c_str()); + } + } +#else + // On Linux, use QSettings to read/merge INI tweaks + QSettings source(tweakName, QSettings::IniFormat); + QSettings dest(tweakedIni, QSettings::IniFormat); + + for (const QString& group : source.childGroups()) { + source.beginGroup(group); + dest.beginGroup(group); + + for (const QString& key : source.childKeys()) { + dest.setValue(key, source.value(key)); + } + + source.endGroup(); + dest.endGroup(); + } +#endif +} + +void Profile::mergeTweaks(ModInfo::Ptr modInfo, const QString& tweakedIni) const +{ + std::vector iniTweaks = modInfo->getIniTweaks(); + for (std::vector::iterator iter = iniTweaks.begin(); iter != iniTweaks.end(); + ++iter) { + mergeTweak(*iter, tweakedIni); + } +} + +bool Profile::invalidationActive(bool* supported) const +{ + auto invalidation = m_GameFeatures.gameFeature(); + auto dataArchives = m_GameFeatures.gameFeature(); + + if (supported != nullptr) { + *supported = ((invalidation != nullptr) && (dataArchives != nullptr)); + } + + return setting("", "AutomaticArchiveInvalidation", + Settings::instance().profileArchiveInvalidation()) + .toBool(); +} + +void Profile::deactivateInvalidation() +{ + auto invalidation = m_GameFeatures.gameFeature(); + + if (invalidation != nullptr) { + invalidation->deactivate(this); + } + + storeSetting("", "AutomaticArchiveInvalidation", false); +} + +void Profile::activateInvalidation() +{ + auto invalidation = m_GameFeatures.gameFeature(); + + if (invalidation != nullptr) { + invalidation->activate(this); + } + + storeSetting("", "AutomaticArchiveInvalidation", true); +} + +bool Profile::localSavesEnabled() const +{ + return setting("", "LocalSaves", Settings::instance().profileLocalSaves()).toBool(); +} + +bool Profile::enableLocalSaves(bool enable) +{ + if (enable) { + if (!m_Directory.exists("saves")) { + m_Directory.mkdir("saves"); + } + } else { + QDialogButtonBox::StandardButton res; + res = QuestionBoxMemory::query( + QApplication::activeModalWidget(), "deleteSavesQuery", + tr("Delete profile-specific save games?"), + tr("Do you want to delete the profile-specific save games? (If you select " + "\"No\", the " + "save games will show up again if you re-enable profile-specific save " + "games)"), + QDialogButtonBox::No | QDialogButtonBox::Yes | QDialogButtonBox::Cancel, + QDialogButtonBox::No); + if (res == QMessageBox::Yes) { + shellDelete(QStringList(m_Directory.absoluteFilePath("saves"))); + } else if (res == QMessageBox::No) { + // No action + } else { + return false; + } + } + storeSetting("", "LocalSaves", enable); + return true; +} + +bool Profile::localSettingsEnabled() const +{ + bool enabled = + setting("", "LocalSettings", Settings::instance().profileLocalInis()).toBool(); + if (enabled) { + QStringList missingFiles; + for (QString file : m_GamePlugin->iniFiles()) { + QString fileName = QFileInfo(file).fileName(); + if (!QFile::exists(m_Directory.filePath(fileName))) { + log::warn("missing {} in {}", fileName, m_Directory.path()); + missingFiles << fileName; + } + } + if (!missingFiles.empty()) { + m_GamePlugin->initializeProfile(m_Directory, IPluginGame::CONFIGURATION); + QMessageBox::StandardButton res = + QMessageBox::warning(QApplication::activeModalWidget(), + tr("Missing profile-specific game INI files!"), + tr("Some of your profile-specific game INI files were " + "missing. They will now be copied " + "from the vanilla game folder. You might want to " + "double-check your settings.\n\n" + "Missing files:\n") + + missingFiles.join("\n")); + } + } + return enabled; +} + +bool Profile::enableLocalSettings(bool enable) +{ + if (enable) { + m_GamePlugin->initializeProfile(m_Directory.absolutePath(), + IPluginGame::CONFIGURATION); + } else { + QDialogButtonBox::StandardButton res; + res = QuestionBoxMemory::query(QApplication::activeModalWidget(), "deleteINIQuery", + tr("Delete profile-specific game INI files?"), + tr("Do you want to delete the profile-specific game " + "INI files? (If you select \"No\", the " + "INI files will be used again if you re-enable " + "profile-specific game INI files.)"), + QDialogButtonBox::No | QDialogButtonBox::Yes | + QDialogButtonBox::Cancel, + QDialogButtonBox::No); + if (res == QMessageBox::Yes) { + QStringList filesToDelete; + for (QString file : m_GamePlugin->iniFiles()) { + filesToDelete << m_Directory.absoluteFilePath(QFileInfo(file).fileName()); + } + shellDelete(filesToDelete, true); + } else if (res == QMessageBox::No) { + // No action + } else { + return false; + } + } + storeSetting("", "LocalSettings", enable); + return true; +} + +QString Profile::getModlistFileName() const +{ + return QDir::cleanPath(m_Directory.absoluteFilePath("modlist.txt")); +} + +QString Profile::getPluginsFileName() const +{ + return QDir::cleanPath(m_Directory.absoluteFilePath("plugins.txt")); +} + +QString Profile::getLoadOrderFileName() const +{ + return QDir::cleanPath(m_Directory.absoluteFilePath("loadorder.txt")); +} + +QString Profile::getLockedOrderFileName() const +{ + return QDir::cleanPath(m_Directory.absoluteFilePath("lockedorder.txt")); +} + +QString Profile::getArchivesFileName() const +{ + return QDir::cleanPath(m_Directory.absoluteFilePath("archives.txt")); +} + +QString Profile::getIniFileName() const +{ + auto iniFiles = m_GamePlugin->iniFiles(); + if (iniFiles.isEmpty()) + return ""; + else + return m_Directory.absoluteFilePath(QFileInfo(iniFiles[0]).fileName()); +} + +QString Profile::absoluteIniFilePath(QString iniFile) const +{ + // This is the file to which the given iniFile would be mapped, as + // an absolute file path: + QFileInfo targetIniFile(m_GamePlugin->documentsDirectory(), iniFile); + + bool isGameIni = false; + for (auto gameIni : m_GamePlugin->iniFiles()) { + // We compare the target file, not the actual ones: + if (QFileInfo(m_GamePlugin->documentsDirectory(), gameIni) == targetIniFile) { + isGameIni = true; + break; + } + } + + // Local-settings are not enabled, or the iniFile is not in the list of INI + // files for the current game. + if (!localSettingsEnabled() || !isGameIni) { + return resolveExistingFileCaseInsensitive(targetIniFile.absoluteFilePath()); + } + + // If we reach here, the file is in the profile: + return resolveExistingFileCaseInsensitive( + m_Directory.absoluteFilePath(targetIniFile.fileName())); +} + +QString Profile::getProfileTweaks() const +{ + return QDir::cleanPath( + m_Directory.absoluteFilePath(ToQString(AppConfig::profileTweakIni()))); +} + +QString Profile::absolutePath() const +{ + return QDir::cleanPath(m_Directory.absolutePath()); +} + +QString Profile::savePath() const +{ + return QDir::cleanPath(m_Directory.absoluteFilePath("saves")); +} + +void Profile::rename(const QString& newName) +{ + QDir profileDir(Settings::instance().paths().profiles()); + profileDir.rename(name(), newName); + m_Directory.setPath(profileDir.absoluteFilePath(newName)); +} + +QString keyName(const QString& section, const QString& name) +{ + QString key = section; + + if (!name.isEmpty()) { + if (!key.isEmpty()) { + key += "/"; + } + + key += name; + } + + return key; +} + +QVariant Profile::setting(const QString& section, const QString& name, + const QVariant& fallback) const +{ + return m_Settings->value(keyName(section, name), fallback); +} + +void Profile::storeSetting(const QString& section, const QString& name, + const QVariant& value) +{ + m_Settings->setValue(keyName(section, name), value); +} + +void Profile::removeSetting(const QString& section, const QString& name) +{ + m_Settings->remove(keyName(section, name)); +} + +QVariantMap Profile::settingsByGroup(const QString& section) const +{ + QVariantMap results; + m_Settings->beginGroup(section); + for (auto key : m_Settings->childKeys()) { + results[key] = m_Settings->value(key); + } + m_Settings->endGroup(); + return results; +} + +void Profile::storeSettingsByGroup(const QString& section, const QVariantMap& values) +{ + m_Settings->beginGroup(section); + for (auto key : values.keys()) { + m_Settings->setValue(key, values[key]); + } + m_Settings->endGroup(); +} + +QList Profile::settingsByArray(const QString& prefix) const +{ + QList results; + int size = m_Settings->beginReadArray(prefix); + for (int i = 0; i < size; i++) { + m_Settings->setArrayIndex(i); + QVariantMap item; + for (auto key : m_Settings->childKeys()) { + item[key] = m_Settings->value(key); + } + results.append(item); + } + m_Settings->endArray(); + return results; +} + +void Profile::storeSettingsByArray(const QString& prefix, + const QList& values) +{ + m_Settings->beginWriteArray(prefix); + for (int i = 0; i < values.length(); i++) { + m_Settings->setArrayIndex(i); + for (auto key : values.at(i).keys()) { + m_Settings->setValue(key, values.at(i)[key]); + } + } + m_Settings->endArray(); +} + +bool Profile::forcedLibrariesEnabled(const QString& executable) const +{ + return setting("forced_libraries", executable + "/enabled", true).toBool(); +} + +void Profile::setForcedLibrariesEnabled(const QString& executable, bool enabled) +{ + storeSetting("forced_libraries", executable + "/enabled", enabled); +} + +QList +Profile::determineForcedLibraries(const QString& executable) const +{ + QList results; + + auto rawSettings = settingsByArray("forced_libraries/" + executable); + auto forcedLoads = m_GamePlugin->executableForcedLoads(); + + // look for enabled status on forced loads and add those + for (auto forcedLoad : forcedLoads) { + bool found = false; + for (auto rawSetting : rawSettings) { + if ((rawSetting.value("process").toString().compare(forcedLoad.process(), + Qt::CaseInsensitive) == 0) && + (rawSetting.value("library").toString().compare(forcedLoad.library(), + Qt::CaseInsensitive) == 0)) { + results.append( + forcedLoad.withEnabled(rawSetting.value("enabled", false).toBool())); + found = true; + } + } + if (!found) { + results.append(forcedLoad); + } + } + + // add everything else + for (auto rawSetting : rawSettings) { + bool add = true; + for (auto forcedLoad : forcedLoads) { + if ((rawSetting.value("process").toString().compare(forcedLoad.process(), + Qt::CaseInsensitive) == 0) && + (rawSetting.value("library").toString().compare(forcedLoad.library(), + Qt::CaseInsensitive) == 0)) { + add = false; + } + } + if (add) { + results.append(ExecutableForcedLoadSetting(rawSetting.value("process").toString(), + rawSetting.value("library").toString()) + .withEnabled(rawSetting.value("enabled", false).toBool())); + } + } + + return results; +} + +void Profile::storeForcedLibraries(const QString& executable, + const QList& values) +{ + QList rawSettings; + for (auto setting : values) { + QVariantMap rawSetting; + rawSetting["enabled"] = setting.enabled(); + rawSetting["process"] = setting.process(); + rawSetting["library"] = setting.library(); + rawSettings.append(rawSetting); + } + storeSettingsByArray("forced_libraries/" + executable, rawSettings); +} + +void Profile::removeForcedLibraries(const QString& executable) +{ + m_Settings->remove("forced_libraries/" + executable); +} + +void Profile::debugDump() const +{ + struct Pair + { + std::size_t enabled = 0; + std::size_t total = 0; + }; + + Pair total; + Pair real; + Pair backup; + Pair separators; + Pair dlc; + Pair cc; + Pair unmanaged; + + auto add = [](Pair& p, const ModStatus& status) { + ++p.total; + + if (status.m_Enabled) { + ++p.enabled; + } + }; + + for (const auto& status : m_ModStatus) { + auto index = m_ModIndexByPriority.find(status.m_Priority); + if (index == m_ModIndexByPriority.end()) { + log::error("mod with priority {} not in priority map", status.m_Priority); + continue; + } + + auto m = ModInfo::getByIndex(index->second); + if (!m) { + log::error("mod index {} with priority {} not found", index->second, + status.m_Priority); + continue; + } + + if (m->hasFlag(ModInfo::FLAG_OVERWRITE)) { + continue; + } + + add(total, status); + + if (m->hasFlag(ModInfo::FLAG_BACKUP)) { + add(backup, status); + } + + if (m->hasFlag(ModInfo::FLAG_SEPARATOR)) { + add(separators, status); + } + + if (m->hasFlag(ModInfo::FLAG_FOREIGN)) { + if (auto* f = dynamic_cast(m.get())) { + switch (f->modType()) { + case ModInfo::MOD_DLC: + add(dlc, status); + break; + + case ModInfo::MOD_CC: + add(cc, status); + break; + + default: + add(unmanaged, status); + break; + } + } + } + + if (!m->hasAnyOfTheseFlags({ModInfo::FLAG_BACKUP, ModInfo::FLAG_FOREIGN, + ModInfo::FLAG_SEPARATOR, ModInfo::FLAG_OVERWRITE})) { + add(real, status); + } + } + + log::debug("profile '{}' in '{}': " + "mods={}/{} backup={}/{} separators={}/{} real={}/{} dlc={}/{} " + "cc={}/{} unmanaged={}/{} localsaves={}, localsettings={}", + name(), absolutePath(), total.enabled, total.total, backup.enabled, + backup.total, separators.enabled, separators.total, real.enabled, + real.total, dlc.enabled, dlc.total, cc.enabled, cc.total, + unmanaged.enabled, unmanaged.total, localSavesEnabled() ? "yes" : "no", + localSettingsEnabled() ? "yes" : "no"); +} diff --git a/src/src/profile.h b/src/src/profile.h new file mode 100644 index 0000000..599ae58 --- /dev/null +++ b/src/src/profile.h @@ -0,0 +1,417 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef PROFILE_H +#define PROFILE_H + +#include "executableinfo.h" +#include "modinfo.h" +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace MOBase +{ +class IPluginGame; +} + +class GameFeatures; + +/** + * @brief represents a profile + **/ +class Profile : public QObject, public MOBase::IProfile +{ + + Q_OBJECT + +public: + using Ptr = boost::shared_ptr; + +public: + // the minimum and maximum priority achievable by mods + // + static constexpr int MinimumPriority = 0; + static constexpr int MaximumPriority = std::numeric_limits::max(); + +public: + /** + * @brief constructor + * + * This constructor is used to create a new profile so it is to be assumed a profile + * by this name does not yet exist + * @param name name of the new profile + * @param filter save game filter. Defaults to <no filter>. + **/ + Profile(const QString& name, MOBase::IPluginGame const* gamePlugin, + GameFeatures const& features, bool useDefaultSettings); + + /** + * @brief constructor + * + * This constructor is used to open an existing profile though it will also try to + *repair the profile if important files are missing (including the directory itself) + *so technically, invoking this should always produce a working profile + * @param directory directory to read the profile from + **/ + Profile(const QDir& directory, MOBase::IPluginGame const* gamePlugin, + GameFeatures const& features); + + Profile(const Profile& reference); + + ~Profile(); + + /** + * Determines the default settings for the profile based on the current state of the + *profile's files. This function should remain backwards compatible as much as + *possible. + **/ + + void findProfileSettings(void); + + /** + * @return true if this profile (still) exists on disc + */ + bool exists() const; + + /** + * @param name of the new profile + * @param reference profile to copy from + **/ + static Profile* createPtrFrom(const QString& name, const Profile& reference, + MOBase::IPluginGame const* gamePlugin); + + static void renameModInAllProfiles(const QString& oldName, const QString& newName); + + void writeModlist(); + + void writeModlistNow(bool onlyIfPending = false); + + void cancelModlistWrite(); + + /** + * @brief test if this profile uses archive invalidation + * + * @param supported if this is not null, the parameter will be set to false if + *invalidation is not supported in this profile + * @return true if archive invalidation is active + * @note currently, invalidation is not supported if the relevant entry in the ini + *file does not exist + **/ + bool invalidationActive(bool* supported) const; + + /** + * @brief deactivate archive invalidation if it was active + **/ + void deactivateInvalidation(); + + /** + * @brief activate archive invalidation + **/ + void activateInvalidation(); + + /** + * @return true if this profile uses local save games + */ + virtual bool localSavesEnabled() const override; + + /** + * @brief enables or disables the use of local save games for this profile + * when disabling the user will be asked if he wants to remove the save games + * in the profile + * @param enable if true, local saves are enabled, otherewise they are disabled + */ + bool enableLocalSaves(bool enable); + + /** + * @return true if this profile uses local ini files + */ + virtual bool localSettingsEnabled() const override; + + /** + * @brief enables or disables the use of local ini files for this profile + * disabling this does not delete existing ini files but the global ones will be used + * @param enable + */ + bool enableLocalSettings(bool enable); + + /** + * @return name of the profile (this is identical to its directory name) + **/ + virtual QString name() const override { return m_Directory.dirName(); } + + /** + * @return the path of the plugins file in this profile + * @todo is this required? can the functionality using this function be moved to the + *Profile-class? + **/ + QString getPluginsFileName() const; + + /** + * @return the path of the loadorder file in this profile + **/ + QString getLoadOrderFileName() const; + + /** + * @return the path of the file containing locked mod indices + */ + QString getLockedOrderFileName() const; + + /** + * @return the path of the modlist file in this profile + */ + QString getModlistFileName() const; + + /** + * @return path of the archives file in this profile + */ + QString getArchivesFileName() const; + + /** + * @return the path of the ini file in this profile + * @todo since the game can contain multiple ini files (i.e. skyrim.ini + *skyrimprefs.ini) the concept of this function is somewhat broken + **/ + QString getIniFileName() const; + + /** + * @brief Retrieve the absolute file to the corresponding file. + * + * @param iniFile INI file to retrieve a path for. This can either be the + * name of a file or a path to the absolute file outside of the profile. + * + * @return the absolute path for the given INI file for this profile. + */ + QString absoluteIniFilePath(QString iniFile) const override; + + /** + * @return the path of the tweak ini in this profile + */ + QString getProfileTweaks() const; + + /** + * @return path to this profile + **/ + virtual QString absolutePath() const override; + + /** + * @return path to this profile's save games + **/ + QString savePath() const; + + /** + * @brief rename profile + * @param newName new name of profile + */ + void rename(const QString& newName); + + /** + * @brief create the ini file to be used by the game + * + * the tweaked ini file constructed by this file is a merger + * of the game-ini of this profile with ini tweaks applied */ + void createTweakedIniFile(); + + /** + * @brief re-read the modlist.txt and update the mod status from it + **/ + void refreshModStatus(); + + /** + * @brief retrieve a list of mods that are enabled in this profile + * + * @return list of active mods sorted by priority (ascending). "first" is the mod + *name, "second" is its path + **/ + std::vector> getActiveMods(); + + /** + * @brief retrieve a mod of the indexes ordered by priority + * + * @return map of indexes by priority + **/ + const std::map& getAllIndexesByPriority() + { + return m_ModIndexByPriority; + } + + /** + * retrieve the number of mods for which this object has status information. + * This is usually the same as ModInfo::getNumMods() except between + * calls to ModInfo::updateFromDisc() and the Profile::refreshModStatus() + * + * @return number of mods for which the profile has status information + **/ + size_t numMods() const { return m_ModStatus.size(); } + + /** + * @brief enable or disable a mod + * + * @param index index of the mod to enable/disable + * @param enabled true if the mod is to be enabled, false if it is to be disabled + **/ + void setModEnabled(unsigned int index, bool enabled); + + /** + * @brief enable or disable multiple mods at once + * This is an abbreviated process and should be immediately followed by a full refresh + * to maintain data consistency. + * + * @param modsToEnable list of mod indicies to enable + * @param modsToDisable list of mod indicies to disable + **/ + void setModsEnabled(const QList& modsToEnable, + const QList& modsToDisable); + + // set the priority of a mod, and the priority of other mods in the range + // [old priority, new priority] such that no gaps are possible + // + // the priority is clamped in the range of valid priority (>= 0, and lower than + // the number of "regular" mods), you should use MinimumPriority or MaximumPriority + // to send a mod to the "top" or "bottom" of the priority list + // + // the function returns true if the priority was changed, or false if the mod + // was already at the given priority (or if the priority of the mod cannot be + // set) + // + bool setModPriority(unsigned int index, int& newPriority); + + /** + * @brief determine if a mod is enabled + * + * @param index index of the mod to look up + * @return true if the mod is enabled, false otherwise + **/ + bool modEnabled(unsigned int index) const; + + /** + * @brief query the priority of a mod + * + * @param index index of the mod to look up + * @return priority of the specified mod + **/ + int getModPriority(unsigned int index) const; + + void dumpModStatus() const; + + QVariant setting(const QString& section, const QString& name, + const QVariant& fallback = {}) const; + + void storeSetting(const QString& section, const QString& name, + const QVariant& value = {}); + + void removeSetting(const QString& section, const QString& name); + + QVariantMap settingsByGroup(const QString& section) const; + void storeSettingsByGroup(const QString& section, const QVariantMap& values); + + QList settingsByArray(const QString& prefix) const; + void storeSettingsByArray(const QString& prefix, const QList& values); + + bool forcedLibrariesEnabled(const QString& executable) const; + void setForcedLibrariesEnabled(const QString& executable, bool enabled); + QList + determineForcedLibraries(const QString& executable) const; + void storeForcedLibraries(const QString& executable, + const QList& values); + void removeForcedLibraries(const QString& executable); + + void debugDump() const; + + Profile& operator=(const Profile& reference) = delete; + +signals: + + /** + * @brief emitted whenever the status (enabled/disabled) of a mod changed + * + * @param index index of the mod that changed + **/ + void modStatusChanged(unsigned int index); + + /** + * @brief emitted whenever the status (enabled/disabled) of multiple mods change + * + * @param index list of indices of the mods that changed + **/ + void modStatusChanged(QList index); + +protected slots: + + // should only be called by DelayedFileWriter, use writeModlist() and + // writeModlistNow() instead + void doWriteModlist(); + +private: + class ModStatus + { + friend class Profile; + + public: + ModStatus() : m_Enabled(false), m_Priority(-1) {} + + private: + bool m_Enabled; + int m_Priority; + }; + +private: + void updateIndices(); + + void copyFilesTo(QString& target) const; + + std::vector splitDZString(const wchar_t* buffer) const; + void mergeTweak(const QString& tweakName, const QString& tweakedIni) const; + void mergeTweaks(ModInfo::Ptr modInfo, const QString& tweakedIni) const; + void touchFile(QString fileName); + + static void renameModInList(QFile& modList, const QString& oldName, + const QString& newName); + +private: + QDir m_Directory; + + QSettings* m_Settings; + + const GameFeatures& m_GameFeatures; + const MOBase::IPluginGame* m_GamePlugin; + + std::vector m_ModStatus; + std::map m_ModIndexByPriority; + + // "regular" here means mods with modifiable priority (i.e. not backups + // or overwrite) + std::size_t m_NumRegularMods; + + MOBase::DelayedFileWriter m_ModListWriter; +}; + +#endif // PROFILE_H diff --git a/src/src/profileinputdialog.cpp b/src/src/profileinputdialog.cpp new file mode 100644 index 0000000..b345889 --- /dev/null +++ b/src/src/profileinputdialog.cpp @@ -0,0 +1,45 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "profileinputdialog.h" +#include "filesystemutilities.h" +#include "ui_profileinputdialog.h" + +ProfileInputDialog::ProfileInputDialog(QWidget* parent) + : QDialog(parent), ui(new Ui::ProfileInputDialog) +{ + ui->setupUi(this); +} + +ProfileInputDialog::~ProfileInputDialog() +{ + delete ui; +} + +QString ProfileInputDialog::getName() const +{ + QString result = ui->nameEdit->text(); + MOBase::fixDirectoryName(result); + return result; +} + +bool ProfileInputDialog::getPreferDefaultSettings() const +{ + return ui->defaultSettingsBox->checkState() == Qt::Checked; +} diff --git a/src/src/profileinputdialog.h b/src/src/profileinputdialog.h new file mode 100644 index 0000000..7e740f0 --- /dev/null +++ b/src/src/profileinputdialog.h @@ -0,0 +1,45 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef PROFILEINPUTDIALOG_H +#define PROFILEINPUTDIALOG_H + +#include + +namespace Ui +{ +class ProfileInputDialog; +} + +class ProfileInputDialog : public QDialog +{ + Q_OBJECT + +public: + explicit ProfileInputDialog(QWidget* parent = 0); + ~ProfileInputDialog(); + + QString getName() const; + bool getPreferDefaultSettings() const; + +private: + Ui::ProfileInputDialog* ui; +}; + +#endif // PROFILEINPUTDIALOG_H diff --git a/src/src/profileinputdialog.ui b/src/src/profileinputdialog.ui new file mode 100644 index 0000000..55b3b15 --- /dev/null +++ b/src/src/profileinputdialog.ui @@ -0,0 +1,87 @@ + + + ProfileInputDialog + + + + 0 + 0 + 436 + 129 + + + + Dialog + + + + + + Please enter a name for the new profile + + + + + + + + + + If checked, the new profile will use the default game INI settings. + + + If checked, the new profile will use the default game INI settings instead of the "global" settings. Global settings are the settings you configure when running the game launcher directly, without MO. + + + Default Game INI Settings + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + ProfileInputDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + ProfileInputDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/src/profilesdialog.cpp b/src/src/profilesdialog.cpp new file mode 100644 index 0000000..827eccd --- /dev/null +++ b/src/src/profilesdialog.cpp @@ -0,0 +1,426 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "profilesdialog.h" +#include "ui_profilesdialog.h" + +#include "bsainvalidation.h" +#include "filesystemutilities.h" +#include "game_features.h" +#include "iplugingame.h" +#include "localsavegames.h" +#include "organizercore.h" +#include "profile.h" +#include "profileinputdialog.h" +#include "report.h" +#include "settings.h" +#include "shared/appconfig.h" +#include "transfersavesdialog.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#endif + +#include + +using namespace MOBase; +using namespace MOShared; + +Q_DECLARE_METATYPE(Profile::Ptr) + +ProfilesDialog::ProfilesDialog(const QString& profileName, OrganizerCore& organizer, + QWidget* parent) + : TutorableDialog("Profiles", parent), ui(new Ui::ProfilesDialog), + m_GameFeatures(organizer.gameFeatures()), m_FailState(false), + m_Game(organizer.managedGame()), m_ActiveProfileName("") +{ + ui->setupUi(this); + + QDir profilesDir(Settings::instance().paths().profiles()); + profilesDir.setFilter(QDir::AllDirs | QDir::NoDotAndDotDot); + + QDirIterator profileIter(profilesDir); + + while (profileIter.hasNext()) { + profileIter.next(); + QListWidgetItem* item = addItem(profileIter.filePath()); + if (profileName == profileIter.fileName()) { + ui->profilesList->setCurrentItem(item); + m_ActiveProfileName = profileName; + } + } + + auto invalidation = m_GameFeatures.gameFeature(); + if (invalidation == nullptr) { + ui->invalidationBox->setToolTip( + tr("Archive invalidation isn't required for this game.")); + ui->invalidationBox->setEnabled(false); + } + + if (!m_GameFeatures.gameFeature()) { + ui->localSavesBox->setToolTip( + tr("This game does not support profile-specific game saves.")); + ui->localSavesBox->setEnabled(false); + } + + connect(this, &ProfilesDialog::profileCreated, &organizer, + &OrganizerCore::profileCreated); + connect(this, &ProfilesDialog::profileRenamed, &organizer, + &OrganizerCore::profileRenamed); + connect(this, &ProfilesDialog::profileRemoved, &organizer, + &OrganizerCore::profileRemoved); +} + +ProfilesDialog::~ProfilesDialog() +{ + delete ui; +} + +int ProfilesDialog::exec() +{ + GeometrySaver gs(Settings::instance(), this); + return QDialog::exec(); +} + +void ProfilesDialog::showEvent(QShowEvent* event) +{ + TutorableDialog::showEvent(event); + + if (ui->profilesList->count() == 0) { + QPoint pos = ui->profilesList->mapToGlobal(QPoint(0, 0)); + pos.rx() += ui->profilesList->width() / 2; + pos.ry() += (ui->profilesList->height() / 2) - 20; + QWhatsThis::showText( + pos, + QObject::tr( + "Before you can use ModOrganizer, you need to create at least one profile. " + "ATTENTION: Run the game at least once before creating a profile!"), + ui->profilesList); + } +} + +void ProfilesDialog::on_close_clicked() +{ + close(); +} + +void ProfilesDialog::on_select_clicked() +{ + const Profile::Ptr currentProfile = + ui->profilesList->currentItem()->data(Qt::UserRole).value(); + + if (!currentProfile) { + return; + } + + m_Selected = currentProfile->name(); + close(); +} + +std::optional ProfilesDialog::selectedProfile() const +{ + return m_Selected; +} + +QListWidgetItem* ProfilesDialog::addItem(const QString& name) +{ + QDir profileDir(name); + QListWidgetItem* newItem = + new QListWidgetItem(profileDir.dirName(), ui->profilesList); + try { + newItem->setData(Qt::UserRole, QVariant::fromValue(Profile::Ptr(new Profile( + profileDir, m_Game, m_GameFeatures)))); + m_FailState = false; + } catch (const std::exception& e) { + reportError(tr("failed to create profile: %1").arg(e.what())); + } + return newItem; +} + +void ProfilesDialog::createProfile(const QString& name, bool useDefaultSettings) +{ + try { + auto profile = + Profile::Ptr(new Profile(name, m_Game, m_GameFeatures, useDefaultSettings)); + QListWidgetItem* newItem = new QListWidgetItem(name, ui->profilesList); + newItem->setData(Qt::UserRole, QVariant::fromValue(profile)); + ui->profilesList->addItem(newItem); + m_FailState = false; + ui->profilesList->setCurrentItem(newItem); + emit profileCreated(profile.get()); + } catch (const std::exception&) { + m_FailState = true; + throw; + } +} + +void ProfilesDialog::createProfile(const QString& name, const Profile& reference) +{ + try { + auto profile = Profile::Ptr(Profile::createPtrFrom(name, reference, m_Game)); + QListWidgetItem* newItem = new QListWidgetItem(name, ui->profilesList); + newItem->setData(Qt::UserRole, QVariant::fromValue(profile)); + ui->profilesList->addItem(newItem); + m_FailState = false; + ui->profilesList->setCurrentItem(newItem); + emit profileCreated(profile.get()); + } catch (const std::exception&) { + m_FailState = true; + throw; + } +} + +void ProfilesDialog::on_addProfileButton_clicked() +{ + ProfileInputDialog dialog(this); + bool okClicked = dialog.exec(); + QString name = dialog.getName(); + + if (okClicked && (name.size() > 0)) { + try { + createProfile(name, dialog.getPreferDefaultSettings()); + } catch (const std::exception& e) { + reportError(tr("failed to create profile: %1").arg(e.what())); + } + } +} + +void ProfilesDialog::on_copyProfileButton_clicked() +{ + bool okClicked; + QString name = QInputDialog::getText(this, tr("Name"), + tr("Please enter a name for the new profile"), + QLineEdit::Normal, QString(), &okClicked); + if (okClicked) { + if (fixDirectoryName(name)) { + try { + const Profile::Ptr currentProfile = + ui->profilesList->currentItem()->data(Qt::UserRole).value(); + createProfile(name, *currentProfile); + } catch (const std::exception& e) { + reportError(tr("failed to copy profile: %1").arg(e.what())); + } + } else { + QMessageBox::warning(this, tr("Invalid name"), tr("Invalid profile name")); + } + } +} + +void ProfilesDialog::on_removeProfileButton_clicked() +{ + Profile::Ptr profileToDelete = + ui->profilesList->currentItem()->data(Qt::UserRole).value(); + if (profileToDelete->name() == m_ActiveProfileName) { + QMessageBox::warning(this, tr("Deleting active profile"), + tr("Unable to delete active profile. Please change to a " + "different profile first.")); + return; + } + + QMessageBox confirmBox(QMessageBox::Question, tr("Confirm"), + tr("Are you sure you want to remove this profile (including " + "profile-specific save games, if any)?"), + QMessageBox::Yes | QMessageBox::No, this); + + if (confirmBox.exec() == QMessageBox::Yes) { + QString profilePath; + if (profileToDelete.get() == nullptr) { + profilePath = Settings::instance().paths().profiles() + "/" + + ui->profilesList->currentItem()->text(); + if (QMessageBox::question( + this, tr("Profile broken"), + tr("This profile you're about to delete seems to be broken or the path " + "is invalid. " + "I'm about to delete the following folder: \"%1\". Proceed?") + .arg(profilePath), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::No) { + return; + } + } else { + // on destruction, the profile object would write the profile.ini file again, so + // we have to get rid of the it before deleting the directory + profilePath = profileToDelete->absolutePath(); + } + QListWidgetItem* item = ui->profilesList->takeItem(ui->profilesList->currentRow()); + if (item != nullptr) { + delete item; + } + if (!shellDelete(QStringList(profilePath))) { + log::warn("Failed to shell-delete \"{}\" (errorcode {}), trying regular delete", + profilePath, ::GetLastError()); + if (!removeDir(profilePath)) { + log::warn("regular delete failed too"); + } + } + + emit profileRemoved(profileToDelete->name()); + } +} + +void ProfilesDialog::on_renameButton_clicked() +{ + Profile::Ptr currentProfile = + ui->profilesList->currentItem()->data(Qt::UserRole).value(); + + if (currentProfile->name() == m_ActiveProfileName) { + QMessageBox::warning(this, tr("Renaming active profile"), + tr("The active profile cannot be renamed. Please change to a " + "different profile first.")); + return; + } + + bool valid = false; + QString name; + + while (!valid) { + bool ok = false; + name = QInputDialog::getText(this, tr("Rename Profile"), tr("New Name"), + QLineEdit::Normal, currentProfile->name(), &ok); + valid = fixDirectoryName(name); + if (!ok) { + return; + } + } + + ui->profilesList->currentItem()->setText(name); + + QString oldName = currentProfile->name(); + currentProfile->rename(name); + + emit profileRenamed(currentProfile.get(), oldName, name); +} + +void ProfilesDialog::on_invalidationBox_stateChanged(int state) +{ + QListWidgetItem* currentItem = ui->profilesList->currentItem(); + if (currentItem == nullptr) { + return; + } + if (!ui->invalidationBox->isEnabled()) { + return; + } + try { + QVariant currentProfileVariant = currentItem->data(Qt::UserRole); + if (!currentProfileVariant.isValid() || currentProfileVariant.isNull()) { + return; + } + const Profile::Ptr currentProfile = + currentItem->data(Qt::UserRole).value(); + if (state == Qt::Unchecked) { + currentProfile->deactivateInvalidation(); + } else { + currentProfile->activateInvalidation(); + } + } catch (const std::exception& e) { + reportError(tr("failed to change archive invalidation state: %1").arg(e.what())); + } +} + +void ProfilesDialog::on_profilesList_currentItemChanged(QListWidgetItem* current, + QListWidgetItem*) +{ + if (current != nullptr) { + if (!current->data(Qt::UserRole).isValid()) + return; + const Profile::Ptr currentProfile = + current->data(Qt::UserRole).value(); + + try { + bool invalidationSupported = false; + ui->invalidationBox->blockSignals(true); + ui->invalidationBox->setChecked( + currentProfile->invalidationActive(&invalidationSupported)); + ui->invalidationBox->setEnabled(invalidationSupported); + ui->invalidationBox->blockSignals(false); + + bool localSaves = currentProfile->localSavesEnabled(); + ui->transferButton->setEnabled(localSaves); + // prevent the stateChanged-event for the saves-box from triggering, otherwise it + // may think local saves were disabled and delete the files/rename the dir + ui->localSavesBox->blockSignals(true); + ui->localSavesBox->setChecked(localSaves); + ui->localSavesBox->blockSignals(false); + + ui->copyProfileButton->setEnabled(true); + ui->removeProfileButton->setEnabled(true); + ui->renameButton->setEnabled(true); + + ui->localIniFilesBox->blockSignals(true); + ui->localIniFilesBox->setChecked(currentProfile->localSettingsEnabled()); + ui->localIniFilesBox->blockSignals(false); + } catch (const std::exception& E) { + reportError( + tr("failed to determine if invalidation is active: %1").arg(E.what())); + ui->copyProfileButton->setEnabled(false); + ui->removeProfileButton->setEnabled(false); + ui->renameButton->setEnabled(false); + ui->invalidationBox->setChecked(false); + } + } else { + ui->invalidationBox->setChecked(false); + ui->copyProfileButton->setEnabled(false); + ui->removeProfileButton->setEnabled(false); + ui->renameButton->setEnabled(false); + } +} + +void ProfilesDialog::on_profilesList_itemActivated(QListWidgetItem* item) +{ + on_select_clicked(); +} + +void ProfilesDialog::on_localSavesBox_stateChanged(int state) +{ + Profile::Ptr currentProfile = + ui->profilesList->currentItem()->data(Qt::UserRole).value(); + + if (currentProfile->enableLocalSaves(state == Qt::Checked)) { + ui->transferButton->setEnabled(state == Qt::Checked); + } else { + // revert checkbox-state + ui->localSavesBox->setChecked(state != Qt::Checked); + } +} + +void ProfilesDialog::on_transferButton_clicked() +{ + const Profile::Ptr currentProfile = + ui->profilesList->currentItem()->data(Qt::UserRole).value(); + TransferSavesDialog transferDialog(*currentProfile, m_Game, this); + transferDialog.exec(); +} + +void ProfilesDialog::on_localIniFilesBox_stateChanged(int state) +{ + Profile::Ptr currentProfile = + ui->profilesList->currentItem()->data(Qt::UserRole).value(); + + if (!currentProfile->enableLocalSettings(state == Qt::Checked)) { + // revert checkbox-state + ui->localIniFilesBox->setChecked(state != Qt::Checked); + } +} diff --git a/src/src/profilesdialog.h b/src/src/profilesdialog.h new file mode 100644 index 0000000..a6cf081 --- /dev/null +++ b/src/src/profilesdialog.h @@ -0,0 +1,140 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef PROFILESDIALOG_H +#define PROFILESDIALOG_H + +#include + +#include "tutorabledialog.h" + +class QListWidget; +class QListWidgetItem; +class QString; + +namespace Ui +{ +class ProfilesDialog; +} + +namespace MOBase +{ +class IPluginGame; +} + +class GameFeatures; +class Profile; +class OrganizerCore; + +/** + * @brief Dialog that can be used to create/delete/modify profiles + **/ +class ProfilesDialog : public MOBase::TutorableDialog +{ + Q_OBJECT + +public: + /** + * @brief constructor + * + * @param profileName currently enabled profile + * @param organizer + * @param parent parent widget + **/ + explicit ProfilesDialog(const QString& profileName, OrganizerCore& organizer, + QWidget* parent = 0); + ~ProfilesDialog(); + + // also saves and restores geometry + // + int exec() override; + + /** + * @return true if creation of a new profile failed + * @todo the notion of a fail state makes little sense in the current dialog + **/ + bool failed() const { return m_FailState; } + + // if the dialog was closed with the 'select' button, returns the name of the + // selected profile; if the dialog was closed with 'cancel', returns empty + // + std::optional selectedProfile() const; + +signals: + + /** + * @brief Signal emitted when a profile is created. + */ + void profileCreated(Profile* profile); + + /** + * @brief Signal emitted when a profile is renamed. + */ + void profileRenamed(Profile* profile, QString const& oldName, QString const& newName); + + /** + * @brief Signal emitted when a profile has been removed. + */ + void profileRemoved(QString const& profileName); + +protected: + virtual void showEvent(QShowEvent* event); + +private slots: + void on_localIniFilesBox_stateChanged(int state); + +private: + QListWidgetItem* addItem(const QString& name); + void createProfile(const QString& name, bool useDefaultSettings); + void createProfile(const QString& name, const Profile& reference); + +private slots: + + void on_close_clicked(); + void on_select_clicked(); + + void on_addProfileButton_clicked(); + + void on_invalidationBox_stateChanged(int arg1); + + void on_copyProfileButton_clicked(); + + void on_profilesList_currentItemChanged(QListWidgetItem* current, + QListWidgetItem* previous); + void on_profilesList_itemActivated(QListWidgetItem* item); + + void on_removeProfileButton_clicked(); + + void on_localSavesBox_stateChanged(int arg1); + + void on_transferButton_clicked(); + + void on_renameButton_clicked(); + +private: + Ui::ProfilesDialog* ui; + GameFeatures& m_GameFeatures; + QListWidget* m_ProfilesList; + bool m_FailState; + MOBase::IPluginGame const* m_Game; + QString m_ActiveProfileName; + std::optional m_Selected; +}; + +#endif // PROFILESDIALOG_H diff --git a/src/src/profilesdialog.ui b/src/src/profilesdialog.ui new file mode 100644 index 0000000..44ab0ee --- /dev/null +++ b/src/src/profilesdialog.ui @@ -0,0 +1,239 @@ + + + ProfilesDialog + + + + 0 + 0 + 482 + 332 + + + + Profiles + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + List of Profiles + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">This is the list of profiles. Each profile contains its own list and installation order of enabled mods (from a shared pool), its own list and load order of enabled plugins (esps/esms), a copy of the games ini-file and an optional savegame filter.</span></p></body></html> + + + + + + + <html><head/><body><p>If checked, save games are stored locally to this profile and will not appear when starting with a different profile.</p></body></html> + + + If checked, save games are local to this profile and will not appear when starting with a different profile. + + + Use profile-specific Save Games + + + + + + + <html><head/><body><p>If checked MO2 will use his own profile-specific game INI files, so that the &quot;Global&quot; ones in MyGames can be left vanilla. This different set of INI files is then offered to the game instead of the default one.</p></body></html> + + + <html><head/><body><p>If checked, MO2 will use a local set of game INI files (configuration and settings files), different from the default ones found in MyGames. This way changes to the INI settings will only affect this profile and the Global INI files can remain vanilla. MO2 will then show the profile INI files to the game instead of the Global ones.</p></body></html> + + + Use profile-specific Game INI Files + + + false + + + + + + + This ensures data files from mods are actually used. You want to enable this unless you use a different tool for Archive Invalidation. + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The games Oblivion, Fallout 3 and Fallout NV contain a bug which prevents texture and mesh replacers (that is: all modifications to meshes and textures already in game) from working.</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The Mod Organizer uses a workaround called &quot;BSA redirection&quot; (google is your friend) to fix this issue reliably and without further work. Simply activate and forget.</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8pt;"></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">With Skyrim this bug seems to be fixed to a degree but whether a mod becomes active still depends on file dates. Therefore, it still makes sense to activate this.</span></p></body></html> + + + Automatic Archive Invalidation + + + + + + + + + + + Create a new profile from scratch + + + Create a new profile from scratch + + + Create + + + + + + + false + + + Clone the selected profile + + + This creates a new profile with the same settings and active mods as the selected one. + + + Copy + + + + + + + false + + + Delete the selected Profile. This can not be un-done! + + + Delete the selected Profile. This can not be un-done! + + + Remove + + + + + + + false + + + Rename + + + + + + + false + + + Transfer save games to the selected profile. + + + Transfer save games to the selected profile. + + + Transfer Saves + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Select + + + + + + + Close + + + + + + + + + + + diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp new file mode 100644 index 0000000..61e11ff --- /dev/null +++ b/src/src/protonlauncher.cpp @@ -0,0 +1,514 @@ +#include "protonlauncher.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +QString compatDataPathFromPrefix(const QString& prefixPath) +{ + if (prefixPath.isEmpty()) { + return {}; + } + + QDir prefixDir(prefixPath); + if (prefixDir.dirName() == "pfx") { + if (prefixDir.cdUp()) { + return QDir::cleanPath(prefixDir.absolutePath()); + } + } + + return QDir::cleanPath(QFileInfo(prefixPath).dir().absolutePath()); +} + +QString detectSteamPath() +{ + if (char* steamPathRaw = nak_find_steam_path(); steamPathRaw != nullptr) { + const QString steamPath = QString::fromUtf8(steamPathRaw).trimmed(); + nak_string_free(steamPathRaw); + + if (!steamPath.isEmpty()) { + return steamPath; + } + } + + const QString homeSteam = QDir::home().filePath(".steam/steam"); + if (QFileInfo::exists(homeSteam)) { + return homeSteam; + } + + const QString homeRoot = QDir::home().filePath(".steam/root"); + if (QFileInfo::exists(homeRoot)) { + return homeRoot; + } + + return {}; +} + +bool startDetachedWithEnv(const QString& program, const QStringList& arguments, + const QString& workingDir, + const QProcessEnvironment& environment, qint64& pid) +{ + QProcess process; + process.setProgram(program); + process.setArguments(arguments); + + if (!workingDir.isEmpty()) { + process.setWorkingDirectory(workingDir); + } + + process.setProcessEnvironment(environment); + return process.startDetached(&pid); +} + +void wrapProgram(const QStringList& wrapperCommands, const QString& program, + const QStringList& arguments, QString& wrappedProgram, + QStringList& wrappedArguments) +{ + if (wrapperCommands.isEmpty()) { + wrappedProgram = program; + wrappedArguments = arguments; + return; + } + + wrappedProgram = wrapperCommands.first(); + wrappedArguments.clear(); + + if (wrapperCommands.size() > 1) { + wrappedArguments.append(wrapperCommands.mid(1)); + } + + wrappedArguments.append(program); + wrappedArguments.append(arguments); +} + +void maybeWrapWithSteamRun(bool useSteamRun, QString& program, QStringList& arguments) +{ + if (!useSteamRun) { + return; + } + + QStringList wrappedArgs; + wrappedArgs.append(program); + wrappedArgs.append(arguments); + program = QStringLiteral("steam-run"); + arguments = wrappedArgs; +} + +bool isFlatpak() +{ + return QFileInfo::exists(QStringLiteral("/.flatpak-info")); +} + +// In Flatpak, Wine/Proton binaries can't execute inside the sandbox (they need +// the Steam Runtime's linker and 32-bit libs). Wrap them with flatpak-spawn +// --host so they run on the host system instead. +// +// flatpak-spawn --host runs via the Flatpak portal D-Bus interface, which does +// NOT reliably forward the caller's process environment. We must pass any +// custom env vars explicitly with --env= flags. +void maybeWrapForFlatpak(QString& program, QStringList& arguments, + const QProcessEnvironment& env) +{ + if (!isFlatpak()) { + return; + } + + QStringList wrappedArgs; + wrappedArgs.append(QStringLiteral("--host")); + + // Pass every env var that differs from the inherited system environment. + const QProcessEnvironment sysEnv = QProcessEnvironment::systemEnvironment(); + for (const QString& key : env.keys()) { + const QString val = env.value(key); + if (val != sysEnv.value(key)) { + wrappedArgs.append(QStringLiteral("--env=%1=%2").arg(key, val)); + } + } + + wrappedArgs.append(program); + wrappedArgs.append(arguments); + program = QStringLiteral("flatpak-spawn"); + arguments = wrappedArgs; +} + +bool isValidEnvKey(const QString& key) +{ + if (key.isEmpty()) { + return false; + } + + const QChar first = key.front(); + if (!(first.isLetter() || first == QChar('_'))) { + return false; + } + + for (const QChar c : key) { + if (!(c.isLetterOrNumber() || c == QChar('_'))) { + return false; + } + } + + return true; +} + +bool parseEnvAssignment(const QString& token, QString& keyOut, QString& valueOut) +{ + const int eq = token.indexOf('='); + if (eq <= 0) { + return false; + } + + const QString key = token.left(eq); + if (!isValidEnvKey(key)) { + return false; + } + + keyOut = key; + valueOut = token.mid(eq + 1); + return true; +} +} // namespace + +ProtonLauncher::ProtonLauncher() + : m_steamAppId(0), m_useUmu(false), m_preferSystemUmu(false), + m_useSteamRun(false) +{} + +ProtonLauncher& ProtonLauncher::setBinary(const QString& path) +{ + m_binary = path.trimmed(); + return *this; +} + +ProtonLauncher& ProtonLauncher::setArguments(const QStringList& args) +{ + m_arguments = args; + return *this; +} + +ProtonLauncher& ProtonLauncher::setWorkingDir(const QString& dir) +{ + m_workingDir = dir.trimmed(); + return *this; +} + +ProtonLauncher& ProtonLauncher::setProtonPath(const QString& path) +{ + m_protonPath = path.trimmed(); + return *this; +} + +ProtonLauncher& ProtonLauncher::setPrefix(const QString& path) +{ + m_prefixPath = path.trimmed(); + return *this; +} + +ProtonLauncher& ProtonLauncher::setSteamAppId(uint32_t id) +{ + m_steamAppId = id; + return *this; +} + +ProtonLauncher& ProtonLauncher::setWrapper(const QString& wrapperCmd) +{ + m_wrapperCommands.clear(); + m_wrapperEnvVars.clear(); + + const QStringList parts = QProcess::splitCommand(wrapperCmd.trimmed()); + for (const QString& part : parts) { + QString key; + QString value; + if (parseEnvAssignment(part, key, value)) { + m_wrapperEnvVars.insert(key, value); + } else { + m_wrapperCommands.push_back(part); + } + } + + return *this; +} + +ProtonLauncher& ProtonLauncher::setUmu(bool useUmu) +{ + m_useUmu = useUmu; + return *this; +} + +ProtonLauncher& ProtonLauncher::setPreferSystemUmu(bool preferSystemUmu) +{ + m_preferSystemUmu = preferSystemUmu; + return *this; +} + +ProtonLauncher& ProtonLauncher::setUseSteamRun(bool useSteamRun) +{ + m_useSteamRun = useSteamRun; + return *this; +} + +ProtonLauncher& ProtonLauncher::addEnvVar(const QString& key, const QString& value) +{ + if (!key.isEmpty()) { + m_envVars.insert(key, value); + } + + return *this; +} + +std::pair ProtonLauncher::launch() const +{ + qint64 pid = -1; + + if (m_useUmu) { + if (launchWithUmu(pid)) { + return {true, pid}; + } + MOBase::log::warn("UMU launch failed, falling back to Proton"); + } + + if (!m_protonPath.isEmpty()) { + return {launchWithProton(pid), pid}; + } + + return {launchDirect(pid), pid}; +} + +bool ProtonLauncher::launchWithProton(qint64& pid) const +{ + if (m_binary.isEmpty() || m_protonPath.isEmpty()) { + return false; + } + + ensureSteamRunning(); + + QString protonScript = m_protonPath; + if (QFileInfo(protonScript).isDir()) { + protonScript = QDir(m_protonPath).filePath("proton"); + } + + const QStringList protonArgs = QStringList() << "run" << m_binary << m_arguments; + + QString program; + QStringList arguments; + wrapProgram(m_wrapperCommands, protonScript, protonArgs, program, arguments); + maybeWrapWithSteamRun(m_useSteamRun, program, arguments); + + // Build environment BEFORE flatpak wrapping (flatpak-spawn needs --env= flags). + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.remove("PYTHONHOME"); + + if (!m_prefixPath.isEmpty()) { + env.insert("WINEPREFIX", m_prefixPath); + } + + const QString compatDataPath = compatDataPathFromPrefix(m_prefixPath); + if (!compatDataPath.isEmpty()) { + env.insert("STEAM_COMPAT_DATA_PATH", compatDataPath); + } + + const QString steamPath = detectSteamPath(); + if (!steamPath.isEmpty()) { + env.insert("STEAM_COMPAT_CLIENT_INSTALL_PATH", steamPath); + } + + if (m_steamAppId != 0) { + const QString appId = QString::number(m_steamAppId); + env.insert("SteamAppId", appId); + env.insert("SteamGameId", appId); + } + + env.insert("DOTNET_ROOT", ""); + env.insert("DOTNET_MULTILEVEL_LOOKUP", "0"); + + for (auto it = m_wrapperEnvVars.cbegin(); it != m_wrapperEnvVars.cend(); ++it) { + env.insert(it.key(), it.value()); + } + + for (auto it = m_envVars.cbegin(); it != m_envVars.cend(); ++it) { + env.insert(it.key(), it.value()); + } + + maybeWrapForFlatpak(program, arguments, env); + + MOBase::log::info("Proton launch: '{}' run '{}'", protonScript, m_binary); + return startDetachedWithEnv(program, arguments, m_workingDir, env, pid); +} + +bool ProtonLauncher::launchWithUmu(qint64& pid) const +{ + if (m_binary.isEmpty()) { + return false; + } + + // Steam must be running for games with Steamworks DRM (Application Load + // Error 5:0000065434 occurs otherwise). + ensureSteamRunning(); + + // Resolve umu-run according to user preference (bundled vs system). + // In Flatpak, umu-run must run on the host (it needs Steam Runtime). + // Use the full path to our copied umu-run since the host PATH won't include it. + QString umuRun; + if (isFlatpak()) { + const QString dataDir = + QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); + const QString flatpakUmu = QDir(dataDir).filePath("fluorine/umu-run"); + if (QFileInfo::exists(flatpakUmu)) { + umuRun = flatpakUmu; + } else { + // Fall back to bare name (requires host to have umu-run in PATH) + umuRun = QStringLiteral("umu-run"); + } + } else { + const QString bundled = + QCoreApplication::applicationDirPath() + QStringLiteral("/umu-run"); + const QString system = QStandardPaths::findExecutable(QStringLiteral("umu-run")); + + if (m_preferSystemUmu) { + if (!system.isEmpty()) { + umuRun = system; + } else if (QFileInfo::exists(bundled)) { + umuRun = bundled; + MOBase::log::warn( + "System umu-run preferred but not found in PATH, falling back to bundled"); + } + } else { + if (QFileInfo::exists(bundled)) { + umuRun = bundled; + } else if (!system.isEmpty()) { + umuRun = system; + } + } + } + + if (umuRun.isEmpty()) { + MOBase::log::warn("umu-run not found (bundled or in PATH)"); + return false; + } + + const QStringList umuArgs = QStringList() << m_binary << m_arguments; + + QString program; + QStringList arguments; + wrapProgram(m_wrapperCommands, umuRun, umuArgs, program, arguments); + maybeWrapWithSteamRun(m_useSteamRun, program, arguments); + + // Build environment BEFORE flatpak wrapping (flatpak-spawn needs --env= flags). + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.remove("PYTHONHOME"); + + if (!m_prefixPath.isEmpty()) { + env.insert("WINEPREFIX", m_prefixPath); + } + + if (!m_protonPath.isEmpty()) { + env.insert("PROTONPATH", m_protonPath); + } + + // umu-run sets STEAM_COMPAT_DATA_PATH internally from WINEPREFIX, so we + // do NOT set it here. However, the game's Steamworks DRM still needs + // STEAM_COMPAT_CLIENT_INSTALL_PATH to locate the Steam client libraries. + const QString steamPath = detectSteamPath(); + if (!steamPath.isEmpty()) { + env.insert("STEAM_COMPAT_CLIENT_INSTALL_PATH", steamPath); + } + + uint32_t effectiveSteamAppId = m_steamAppId; + if (effectiveSteamAppId == 0) { + bool ok = false; + const QString inheritedSteamAppId = + env.value("SteamAPPId", env.value("SteamAppId")).trimmed(); + const uint32_t parsed = inheritedSteamAppId.toUInt(&ok); + if (ok) { + effectiveSteamAppId = parsed; + } + } + + if (effectiveSteamAppId != 0) { + // umu-run expects GAMEID in "umu-" format to extract SteamAppId. + env.insert("GAMEID", QStringLiteral("umu-") + QString::number(effectiveSteamAppId)); + env.insert("SteamAppId", QString::number(effectiveSteamAppId)); + env.insert("SteamGameId", QString::number(effectiveSteamAppId)); + } + + for (auto it = m_wrapperEnvVars.cbegin(); it != m_wrapperEnvVars.cend(); ++it) { + env.insert(it.key(), it.value()); + } + + for (auto it = m_envVars.cbegin(); it != m_envVars.cend(); ++it) { + env.insert(it.key(), it.value()); + } + + maybeWrapForFlatpak(program, arguments, env); + + MOBase::log::info("UMU launch: '{}' '{}' (game id: {}, steam: '{}')", umuRun, + m_binary, + (effectiveSteamAppId == 0 + ? QStringLiteral("") + : QStringLiteral("umu-") + + QString::number(effectiveSteamAppId)), + steamPath); + return startDetachedWithEnv(program, arguments, m_workingDir, env, pid); +} + +bool ProtonLauncher::launchDirect(qint64& pid) const +{ + if (m_binary.isEmpty()) { + return false; + } + + QString program; + QStringList arguments; + wrapProgram(m_wrapperCommands, m_binary, m_arguments, program, arguments); + maybeWrapWithSteamRun(m_useSteamRun, program, arguments); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.remove("PYTHONHOME"); + for (auto it = m_wrapperEnvVars.cbegin(); it != m_wrapperEnvVars.cend(); ++it) { + env.insert(it.key(), it.value()); + } + for (auto it = m_envVars.cbegin(); it != m_envVars.cend(); ++it) { + env.insert(it.key(), it.value()); + } + + maybeWrapForFlatpak(program, arguments, env); + + return startDetachedWithEnv(program, arguments, m_workingDir, env, pid); +} + +bool ProtonLauncher::ensureSteamRunning() +{ + QProcess pgrep; + if (isFlatpak()) { + // In Flatpak, check for Steam on the host. + pgrep.start("flatpak-spawn", {"--host", "pgrep", "-x", "steam"}); + } else { + pgrep.start("pgrep", {"-x", "steam"}); + } + if (pgrep.waitForFinished(2000) && pgrep.exitCode() == 0) { + return true; + } + + qint64 pid = -1; + if (isFlatpak()) { + if (QProcess::startDetached("flatpak-spawn", + {"--host", "steam", "-silent"}, QString(), &pid)) { + MOBase::log::warn("Steam was not running, started it on host in silent mode"); + return true; + } + } else { + if (QProcess::startDetached("steam", {"-silent"}, QString(), &pid)) { + MOBase::log::warn("Steam was not running, started it in silent mode"); + return true; + } + } + + return false; +} diff --git a/src/src/protonlauncher.h b/src/src/protonlauncher.h new file mode 100644 index 0000000..95d22d1 --- /dev/null +++ b/src/src/protonlauncher.h @@ -0,0 +1,50 @@ +#ifndef PROTONLAUNCHER_H +#define PROTONLAUNCHER_H + +#include +#include +#include +#include +#include + +class ProtonLauncher +{ +public: + ProtonLauncher(); + + ProtonLauncher& setBinary(const QString& path); + ProtonLauncher& setArguments(const QStringList& args); + ProtonLauncher& setWorkingDir(const QString& dir); + ProtonLauncher& setProtonPath(const QString& path); + ProtonLauncher& setPrefix(const QString& path); + ProtonLauncher& setSteamAppId(uint32_t id); + ProtonLauncher& setWrapper(const QString& wrapperCmd); + ProtonLauncher& setUmu(bool useUmu); + ProtonLauncher& setPreferSystemUmu(bool preferSystemUmu); + ProtonLauncher& setUseSteamRun(bool useSteamRun); + ProtonLauncher& addEnvVar(const QString& key, const QString& value); + + // Launch dispatch: UMU -> Proton -> Direct + std::pair launch() const; + +private: + bool launchWithProton(qint64& pid) const; + bool launchWithUmu(qint64& pid) const; + bool launchDirect(qint64& pid) const; + static bool ensureSteamRunning(); + + QString m_binary; + QStringList m_arguments; + QString m_workingDir; + QString m_protonPath; + QString m_prefixPath; + uint32_t m_steamAppId; + QStringList m_wrapperCommands; + bool m_useUmu; + bool m_preferSystemUmu; + bool m_useSteamRun; + QMap m_envVars; + QMap m_wrapperEnvVars; +}; + +#endif // PROTONLAUNCHER_H diff --git a/src/src/proxyutils.h b/src/src/proxyutils.h new file mode 100644 index 0000000..a4a5090 --- /dev/null +++ b/src/src/proxyutils.h @@ -0,0 +1,50 @@ +#ifndef PROXYUTILS_H +#define PROXYUTILS_H + +#include + +#include "organizerproxy.h" + +namespace MOShared +{ + +template +auto callIfPluginActive(OrganizerProxy* proxy, Fn&& callback, T defaultReturn = T{}) +{ + return [fn = std::forward(callback), proxy, defaultReturn](auto&&... args) { + if (proxy->isPluginEnabled(proxy->plugin())) { + return fn(std::forward(args)...); + } else { + if constexpr (!std::is_same_v< + std::invoke_result_t, decltype(args)...>, + void>) { + return defaultReturn; + } + } + }; +} + +// We need to connect to the organizer. +template +auto callSignalIfPluginActive(OrganizerProxy* proxy, const Signal& signal, + T defaultReturn = T{}) +{ + return callIfPluginActive( + proxy, + [&signal](auto&&... args) { + return signal(std::forward(args)...); + }, + defaultReturn); +} + +template +auto callSignalAlways(const Signal& signal) +{ + return [&signal](auto&&... args) { + return signal(std::forward(args)...); + }; +} + +} // namespace MOShared + +#endif diff --git a/src/src/qdirfiletree.cpp b/src/src/qdirfiletree.cpp new file mode 100644 index 0000000..72224fd --- /dev/null +++ b/src/src/qdirfiletree.cpp @@ -0,0 +1,115 @@ +#include "qdirfiletree.h" + +#include + +using namespace MOBase; + +class QDirFileTreeImpl : public QDirFileTree +{ +public: + QDirFileTreeImpl(std::shared_ptr parent, QDir dir) + : FileTreeEntry(parent, dir.dirName()), QDirFileTree(), qDir(dir) + {} + +protected: + /** + * No mutable operations allowed. + */ + bool beforeReplace(IFileTree const* dstTree, FileTreeEntry const* destination, + FileTreeEntry const* source) override + { + return false; + } + bool beforeInsert(IFileTree const* entry, FileTreeEntry const* name) override + { + return false; + } + bool beforeRemove(IFileTree const* entry, FileTreeEntry const* name) override + { + return false; + } + std::shared_ptr makeFile(std::shared_ptr parent, + QString name) const override + { + return nullptr; + } + std::shared_ptr makeDirectory(std::shared_ptr parent, + QString name) const override + { + return nullptr; + } + + bool doPopulate(std::shared_ptr parent, + std::vector>& entries) const override + { + auto infoList = qDir.entryInfoList(qDir.filter() | QDir::NoDotAndDotDot, + QDir::Name | QDir::DirsFirst | QDir::IgnoreCase); + for (auto& info : infoList) { + if (info.isDir()) { + entries.push_back( + std::make_shared(parent, QDir(info.absoluteFilePath()))); + } else { + entries.push_back(createFileEntry(parent, info.fileName())); + } + } + + // Vector is already sorted: + return true; + } + + std::shared_ptr doClone() const override + { + return std::make_shared(nullptr, qDir); + } + +protected: + QDir qDir; +}; + +// subclass of QDirFileTreeImpl that ignores meta.ini +// +// only used for the root folder, subdirectories are actually QDirFileTreeImpl +class QDirRootFileTreeImpl : public QDirFileTreeImpl +{ +public: + QDirRootFileTreeImpl(QDir dir) + : FileTreeEntry(nullptr, dir.dirName()), QDirFileTreeImpl(nullptr, dir) + {} + +protected: + bool doPopulate(std::shared_ptr parent, + std::vector>& entries) const override + { + auto infoList = qDir.entryInfoList(qDir.filter() | QDir::NoDotAndDotDot, + QDir::Name | QDir::DirsFirst | QDir::IgnoreCase); + for (auto& info : infoList) { + if (info.isDir()) { + entries.push_back( + std::make_shared(parent, QDir(info.absoluteFilePath()))); + } else if (info.fileName().compare("meta.ini", Qt::CaseInsensitive) != 0) { + entries.push_back(createFileEntry(parent, info.fileName())); + } + } + + // Vector is already sorted: + return true; + } + + std::shared_ptr doClone() const override + { + return std::make_shared(qDir); + } +}; + +/** + * + */ +std::shared_ptr QDirFileTree::makeTree(QDir directory, + bool ignoreRootMeta) +{ + if (ignoreRootMeta) { + return std::make_shared(directory); + } else { + return std::make_shared(nullptr, directory); + } +} diff --git a/src/src/qdirfiletree.h b/src/src/qdirfiletree.h new file mode 100644 index 0000000..d97a1bf --- /dev/null +++ b/src/src/qdirfiletree.h @@ -0,0 +1,60 @@ +/* +Copyright (C) MO2 Team. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef ARCHIVEFILENETRY_H +#define ARCHIVEFILENTRY_H + +#include + +#include "ifiletree.h" + +/** + * @brief Class that expose a directory on the drive, using QDir, as a + * `MOBase::IFileTree`. + * + * The tree is lazily populated: each subtree is only populated (from the disk) when + * needed, as specified by IFileTree. + * + * This class does not expose mutable operations, so any mutable operations will + * fail. + */ +class QDirFileTree : public MOBase::IFileTree +{ +public: + /** + * @brief Create a new file tree representing the given directory. + * + * @param directory Directory to represent. + * @param ignoreRootMeta If true, the meta.ini file in the root folder will + * be ignored. + * + * @return a file tree representing the given directory. + */ + static std::shared_ptr makeTree(QDir directory, + bool ignoreRootMeta = true); + +protected: + using IFileTree::IFileTree; + + virtual bool + doPopulate(std::shared_ptr parent, + std::vector>& entries) const = 0; +}; + +#endif diff --git a/src/src/qt.conf b/src/src/qt.conf new file mode 100644 index 0000000..f834a22 --- /dev/null +++ b/src/src/qt.conf @@ -0,0 +1,3 @@ +[Paths] +Prefix=. +Plugins=dlls diff --git a/src/src/qtgroupingproxy.cpp b/src/src/qtgroupingproxy.cpp new file mode 100644 index 0000000..1e866ca --- /dev/null +++ b/src/src/qtgroupingproxy.cpp @@ -0,0 +1,948 @@ +/**************************************************************************************** + * Copyright (c) 2007-2011 Bart Cerneels * + * * + * This program is free software; you can redistribute it and/or modify it under * the + *terms of the GNU General Public License as published by the Free Software * + * Foundation; either version 2 of the License, or (at your option) any later * version. + ** + * * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY * + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * + * PARTICULAR PURPOSE. See the GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License along with * this + *program. If not, see . * + ****************************************************************************************/ + +// Modifications 2013-03-27 to 2013-03-29 by Sebastian Herbord + +#include "qtgroupingproxy.h" +#include + +#include +#include +#include + +using namespace MOBase; + +/*! + \class QtGroupingProxy + \brief The QtGroupingProxy class will group source model rows by adding a new top + tree-level. The source model can be flat or tree organized, but only the original top + level rows are used for determining the grouping. \ingroup model-view +*/ + +QtGroupingProxy::QtGroupingProxy(QModelIndex rootNode, int groupedColumn, + int groupedRole, unsigned int flags, int aggregateRole) + : QAbstractProxyModel(), m_rootNode(rootNode), m_groupedColumn(0), + m_groupedRole(groupedRole), m_aggregateRole(aggregateRole), m_flags(flags) +{ + if (groupedColumn != -1) { + setGroupedColumn(groupedColumn); + } +} + +QtGroupingProxy::~QtGroupingProxy() {} + +void QtGroupingProxy::setSourceModel(QAbstractItemModel* model) +{ + if (sourceModel()) { + disconnect(sourceModel(), nullptr, this, nullptr); + } + + QAbstractProxyModel::setSourceModel(model); + + if (sourceModel()) { + // signal proxies + connect(sourceModel(), SIGNAL(rowsInserted(const QModelIndex&, int, int)), + SLOT(modelRowsInserted(const QModelIndex&, int, int))); + connect(sourceModel(), SIGNAL(rowsAboutToBeInserted(const QModelIndex&, int, int)), + SLOT(modelRowsAboutToBeInserted(const QModelIndex&, int, int))); + connect(sourceModel(), SIGNAL(rowsRemoved(const QModelIndex&, int, int)), + SLOT(modelRowsRemoved(const QModelIndex&, int, int))); + connect(sourceModel(), SIGNAL(rowsAboutToBeRemoved(const QModelIndex&, int, int)), + SLOT(modelRowsAboutToBeRemoved(QModelIndex, int, int))); + connect(sourceModel(), SIGNAL(layoutChanged()), SLOT(buildTree())); + connect(sourceModel(), SIGNAL(dataChanged(QModelIndex, QModelIndex)), + SLOT(modelDataChanged(QModelIndex, QModelIndex))); + connect(sourceModel(), SIGNAL(modelReset()), this, SLOT(resetModel())); + + buildTree(); + } +} + +void QtGroupingProxy::setGroupedColumn(int groupedColumn) +{ + m_groupedColumn = groupedColumn; + buildTree(); +} + +/** Maps to what groups the source row belongs by returning the data of those groups. + * + * @returns a list of data for the rows the argument belongs to. In common cases this + * list will contain only one entry. An empty list means that the source item will be + * placed in the root of this proxyModel. There is no support for hiding source items. + * + * Group data can be pre-loaded in the return value so it's added to the cache + * maintained by this class. This is required if you want to have data that is not + * present in the source model. + */ +QList QtGroupingProxy::belongsTo(const QModelIndex& idx) +{ + QList rowDataList; + + // get all the data for this index from the model + ItemData itemData = sourceModel()->itemData(idx); + if (m_groupedRole != Qt::DisplayRole) { + itemData[Qt::DisplayRole] = itemData[m_groupedRole]; + } + + // invalid value in grouped role -> ungrouped + if (!itemData[Qt::DisplayRole].isValid()) { + return rowDataList; + } + + QMapIterator i(itemData); + while (i.hasNext()) { + i.next(); + int role = i.key(); + QVariant variant = i.value(); + + if (variant.type() == QVariant::List) { + // a list of variants get's expanded to multiple rows + QVariantList list = variant.toList(); + for (int i = 0; i < list.length(); i++) { + // take an existing row data or create a new one + RowData rowData = (rowDataList.count() > i) ? rowDataList.takeAt(i) : RowData(); + + // we only gather data for the first column + ItemData indexData = rowData.contains(0) ? rowData.take(0) : ItemData(); + indexData.insert(role, list.value(i)); + rowData.insert(0, indexData); + // for the grouped column the data should not be gathered from the children + // this will allow filtering on the content of this column with a + // QSortFilterProxyModel + rowData.insert(m_groupedColumn, indexData); + rowDataList.insert(i, rowData); + } + break; + } else if (!variant.isNull()) { + // it's just a normal item. Copy all the data and break this loop. + RowData rowData; + rowData.insert(0, itemData); + rowDataList << rowData; + break; + } + } + + return rowDataList; +} + +/* m_groupMap layout + * key : index of the group in m_groupMaps + * value : a QList of the original rows in sourceModel() for the children of this group + * + * key = -1 contains a QList of the non-grouped indexes + * + * TODO: sub-groups + */ +void QtGroupingProxy::buildTree() +{ + if (!sourceModel()) + return; + beginResetModel(); + + m_groupMap.clear(); + // don't clear the data maps since most of it will probably be needed again. + m_parentCreateList.clear(); + + int max = sourceModel()->rowCount(m_rootNode); + + // WARNING: these have to be added in order because the addToGroups function is + // optimized for modelRowsInserted(). Failure to do so will result in wrong data shown + // in the view at best. + for (int row = 0; row < max; row++) { + QModelIndex idx = sourceModel()->index(row, m_groupedColumn, m_rootNode); + addSourceRow(idx); + } + // dumpGroups(); + + if (m_flags & FLAG_NOSINGLE) { + // awkward: flatten single-item groups as a post-processing steps. + + int currentKey = 0; + quint32 quint32max = std::numeric_limits::max(); + std::vector rmgroups; + + QMap> temp; + + for (auto iter = m_groupMap.begin(); iter != m_groupMap.end(); ++iter) { + if ((iter.key() == quint32max) || (iter->count() < 2)) { + temp[quint32max].append(iter.value()); + if (iter.key() != quint32max) { + rmgroups.push_back(iter.key()); + } + } else { + temp[currentKey++] = *iter; + } + } + m_groupMap = temp; + + // second loop is necessary because qt containers can't be iterated from end to + // front and removing by index from begin to end is ugly + std::sort(rmgroups.begin(), rmgroups.end(), [](int lhs, int rhs) { + return rhs < lhs; + }); + for (auto iter = rmgroups.begin(); iter != rmgroups.end(); ++iter) { + m_groupMaps.removeAt(*iter); + } + } + + endResetModel(); +} + +QList QtGroupingProxy::addSourceRow(const QModelIndex& idx) +{ + QList updatedGroups; + QList groupData = belongsTo(idx); + + // an empty list here means it's supposed to go in root. + if (groupData.isEmpty()) { + updatedGroups << -1; + if (!m_groupMap.keys().contains(std::numeric_limits::max())) + m_groupMap.insert(std::numeric_limits::max(), + QList()); // add an empty placeholder + } + + // an item can be in multiple groups + foreach (RowData data, groupData) { + int updatedGroup = -1; + if (!data.isEmpty()) { + foreach (const RowData& cachedData, m_groupMaps) { + // when this matches the index belongs to an existing group + if (data[0][Qt::DisplayRole] == cachedData[0][Qt::DisplayRole]) { + data = cachedData; + break; + } + } + + updatedGroup = m_groupMaps.indexOf(data); + //-1 means not found + if (updatedGroup == -1) { + // new groups are added to the end of the existing list + m_groupMaps << data; + updatedGroup = m_groupMaps.count() - 1; + } + + if (!m_groupMap.keys().contains(updatedGroup)) + m_groupMap.insert(updatedGroup, QList()); // add an empty placeholder + } + + if (!updatedGroups.contains(updatedGroup)) + updatedGroups << updatedGroup; + } + + // update m_groupMap to the new source-model layout (one row added) + QMutableMapIterator> i(m_groupMap); + while (i.hasNext()) { + i.next(); + QList& groupList = i.value(); + int insertedProxyRow = groupList.count(); + for (; insertedProxyRow > 0; insertedProxyRow--) { + int& rowValue = groupList[insertedProxyRow - 1]; + if (idx.row() <= rowValue) { + // increment the rows that come after the new row since they moved one place up. + rowValue++; + } else { + break; + } + } + + if (updatedGroups.contains(i.key())) { + // the row needs to be added to this group + groupList.insert(insertedProxyRow, idx.row()); + } + } + + return updatedGroups; +} + +/** Each ModelIndex has in it's internalId a position in the parentCreateList. + * struct ParentCreate are the instructions to recreate the parent index. + * It contains the proxy row number of the parent and the postion in this list of the + * grandfather. This function creates the ParentCreate structs and saves them in a list. + */ +int QtGroupingProxy::indexOfParentCreate(const QModelIndex& parent) const +{ + if (!parent.isValid()) + return -1; + + struct ParentCreate pc; + for (int i = 0; i < m_parentCreateList.size(); i++) { + pc = m_parentCreateList[i]; + if (pc.parentCreateIndex == parent.internalId() && pc.row == parent.row()) + return i; + } + // there is no parentCreate yet for this index, so let's create one. + pc.parentCreateIndex = parent.internalId(); + pc.row = parent.row(); + m_parentCreateList << pc; + + return m_parentCreateList.size() - 1; +} + +QModelIndex QtGroupingProxy::index(int row, int column, const QModelIndex& parent) const +{ + if (!hasIndex(row, column, parent)) { + return QModelIndex(); + } + + if (parent.column() > 0) { + return QModelIndex(); + } + + /* We save the instructions to make the parent of the index in a struct. + * The place of the struct in the list is stored in the internalId + */ + int parentCreateIndex = indexOfParentCreate(parent); + + return createIndex(row, column, parentCreateIndex); +} + +QModelIndex QtGroupingProxy::parent(const QModelIndex& index) const +{ + if (!index.isValid()) + return QModelIndex(); + + int parentCreateIndex = index.internalId(); + if (parentCreateIndex == -1 || parentCreateIndex >= m_parentCreateList.count()) + return QModelIndex(); + + struct ParentCreate pc = m_parentCreateList[parentCreateIndex]; + + // only items at column 0 have children + return createIndex(pc.row, 0, pc.parentCreateIndex); +} + +int QtGroupingProxy::rowCount(const QModelIndex& index) const +{ + if (!index.isValid()) { + // the number of top level groups + the number of non-grouped items + int rows = m_groupMaps.count() + + m_groupMap.value(std::numeric_limits::max()).count(); + return rows; + } + + // TODO:group in group support. + if (isGroup(index)) { + qint64 groupIndex = index.row(); + int rows = m_groupMap.value(groupIndex).count(); + return rows; + } else { + QModelIndex originalIndex = mapToSource(index); + int rowCount = sourceModel()->rowCount(originalIndex); + return rowCount; + } +} + +int QtGroupingProxy::columnCount(const QModelIndex& index) const +{ + if (!index.isValid()) + return sourceModel()->columnCount(m_rootNode); + + if (index.column() != 0) + return 0; + + return sourceModel()->columnCount(mapToSource(index)); +} + +static bool variantLess(const QVariant& LHS, const QVariant& RHS) +{ + if ((LHS.type() == RHS.type()) && + ((LHS.type() == QVariant::Int) || (LHS.type() == QVariant::UInt))) { + return LHS.toInt() < RHS.toInt(); + } + + // this should always work (comparing empty strings in the worst case) but + // the results may be wrong + return LHS.toString() < RHS.toString(); +} + +static QVariant variantMax(const QVariantList& variants) +{ + QVariant result = variants.first(); + foreach (const QVariant& iter, variants) { + if (variantLess(result, iter)) { + result = iter; + } + } + return result; +} + +static QVariant variantMin(const QVariantList& variants) +{ + QVariant result = variants.first(); + foreach (const QVariant& iter, variants) { + if (variantLess(iter, result)) { + result = iter; + } + } + return result; +} + +QVariant QtGroupingProxy::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + int row = index.row(); + int column = index.column(); + if (isGroup(index)) { + if ((role != Qt::DisplayRole) && (role != Qt::EditRole)) { + switch (role) { + case Qt::ForegroundRole: { + return QBrush(Qt::gray); + } break; + case Qt::FontRole: { + QFont font(m_groupMaps[row][column].value(Qt::FontRole).value()); + font.setItalic(true); + return font; + } break; + case Qt::TextAlignmentRole: { + return Qt::AlignHCenter; + } break; + case Qt::UserRole: { + return m_groupMaps[row][column].value(Qt::DisplayRole).toString(); + } break; + case Qt::CheckStateRole: { + if (column != 0) + return QVariant(); + int childCount = m_groupMap.value(row).count(); + int checked = 0; + QModelIndex parentIndex = this->index(row, 0, index.parent()); + for (int childRow = 0; childRow < childCount; ++childRow) { + QModelIndex childIndex = this->index(childRow, 0, parentIndex); + QVariant data = mapToSource(childIndex).data(Qt::CheckStateRole); + if (data.toInt() == 2) + ++checked; + } + if (checked == childCount) + return Qt::Checked; + else if (checked == 0) + return Qt::Unchecked; + else + return Qt::PartiallyChecked; + } break; + default: { + QModelIndex parentIndex = this->index(row, 0, index.parent()); + if (m_groupMap.value(row).count() > 0) { + return this->index(0, column, parentIndex).data(role); + } else { + return QVariant(); + } + // return m_groupMaps[row][column].value( role ); + } break; + } + } + + // use cached or precalculated data + if (m_groupMaps[row][column].contains(Qt::DisplayRole)) { + if ((m_flags & FLAG_NOGROUPNAME) != 0) { + QModelIndex parentIndex = this->index(row, 0, index.parent()); + QModelIndex childIndex = this->index(0, column, parentIndex); + return childIndex.data(role).toString(); + } else { + return m_groupMaps[row][column].value(role).toString(); + } + } + + // for column 0 we gather data from the grouped column instead + if (column == 0) + column = m_groupedColumn; + + // map all data from children to columns of group to allow grouping one level up + QVariantList variantsOfChildren; + int childCount = m_groupMap.value(row).count(); + if (childCount == 0) + return QVariant(); + + int function = AGGR_NONE; + if (m_aggregateRole >= Qt::UserRole) { + QModelIndex parentIndex = this->index(row, 0, index.parent()); + QModelIndex childIndex = this->index(0, column, parentIndex); + function = mapToSource(childIndex).data(m_aggregateRole).toInt(); + } + + // Need a parentIndex with column == 0 because only those have children. + QModelIndex parentIndex = this->index(row, 0, index.parent()); + for (int childRow = 0; childRow < childCount; childRow++) { + QModelIndex childIndex = this->index(childRow, column, parentIndex); + QVariant data = mapToSource(childIndex).data(role); + + if (data.isValid() && !variantsOfChildren.contains(data)) + variantsOfChildren << data; + } + + // saving in cache + ItemData roleMap = m_groupMaps[row].value(column); + foreach (const QVariant& variant, variantsOfChildren) { + if (roleMap[role] != variant) { + roleMap.insert(role, variantsOfChildren); + } + } + + if (variantsOfChildren.count() == 0) + return QVariant(); + + // only one unique variant? No need to return a list + switch (function) { + case AGGR_EMPTY: + return QVariant(); + case AGGR_FIRST: + return variantsOfChildren.first(); + case AGGR_MAX: + return variantMax(variantsOfChildren); + case AGGR_MIN: + return variantMin(variantsOfChildren); + default: { + if (variantsOfChildren.count() == 1) + return variantsOfChildren.first(); + + return variantsOfChildren; + } break; + } + } + + return mapToSource(index).data(role); +} + +bool QtGroupingProxy::setData(const QModelIndex& idx, const QVariant& value, int role) +{ + if (!idx.isValid()) + return false; + + // no need to set data to exactly the same value + if (idx.data(role) == value) + return false; + + if (isGroup(idx)) { + ItemData columnData = m_groupMaps[idx.row()][idx.column()]; + + columnData.insert(role, value); + // QItemDelegate will always use Qt::EditRole + if (role == Qt::EditRole) + columnData.insert(Qt::DisplayRole, value); + + // and make sure it's stored in the map + m_groupMaps[idx.row()].insert(idx.column(), columnData); + + int columnToChange = idx.column() ? idx.column() : m_groupedColumn; + foreach (int originalRow, m_groupMap.value(idx.row())) { + QModelIndex childIdx = + sourceModel()->index(originalRow, columnToChange, m_rootNode); + if (childIdx.isValid()) + sourceModel()->setData(childIdx, value, role); + } + // TODO: we might need to reload the data from the children at this point + + emit dataChanged(idx, idx); + return true; + } + + return sourceModel()->setData(mapToSource(idx), value, role); +} + +bool QtGroupingProxy::isGroup(const QModelIndex& index) const +{ + int parentCreateIndex = index.internalId(); + if (parentCreateIndex == -1 && index.row() < m_groupMaps.count()) + return true; + return false; +} + +QModelIndex QtGroupingProxy::mapToSource(const QModelIndex& index) const +{ + if (!index.isValid()) { + return m_rootNode; + } + + if (isGroup(index)) { + return m_rootNode; + } + + QModelIndex proxyParent = index.parent(); + QModelIndex originalParent = mapToSource(proxyParent); + + int originalRow = index.row(); + if (originalParent == m_rootNode) { + int indexInGroup = index.row(); + if (!proxyParent.isValid()) + indexInGroup -= m_groupMaps.count(); + + QList childRows = m_groupMap.value(proxyParent.row()); + if (childRows.isEmpty() || indexInGroup >= childRows.count() || indexInGroup < 0) + return QModelIndex(); + + originalRow = childRows.at(indexInGroup); + } + return sourceModel()->index(originalRow, index.column(), originalParent); +} + +QModelIndexList QtGroupingProxy::mapToSource(const QModelIndexList& list) const +{ + QModelIndexList originalList; + foreach (const QModelIndex& index, list) { + QModelIndex originalIndex = mapToSource(index); + if (originalIndex.isValid()) + originalList << originalIndex; + } + return originalList; +} + +QModelIndex QtGroupingProxy::mapFromSource(const QModelIndex& idx) const +{ + if (!idx.isValid()) + return QModelIndex(); + + QModelIndex proxyParent; + QModelIndex sourceParent = idx.parent(); + + int proxyRow = idx.row(); + int sourceRow = idx.row(); + + if (sourceParent.isValid() && (sourceParent != m_rootNode)) { + // idx is a child of one of the items in the source model + proxyParent = mapFromSource(sourceParent); + } else { + // idx is an item in the top level of the source model (child of the rootnode) + int groupRow = -1; + QMapIterator> iterator(m_groupMap); + while (iterator.hasNext()) { + iterator.next(); + if (iterator.value().contains(sourceRow)) { + groupRow = iterator.key(); + break; + } + } + + if (groupRow != -1) // it's in a group, let's find the correct row. + { + proxyParent = this->index(groupRow, 0, QModelIndex()); + proxyRow = m_groupMap.value(groupRow).indexOf(sourceRow); + } else { + proxyParent = QModelIndex(); + // if the proxy item is not in a group it will be below the groups. + int groupLength = m_groupMaps.count(); + int i = m_groupMap.value(std::numeric_limits::max()).indexOf(sourceRow); + + proxyRow = groupLength + i; + } + } + + return this->index(proxyRow, idx.column(), proxyParent); +} + +Qt::ItemFlags QtGroupingProxy::flags(const QModelIndex& idx) const +{ + if (!idx.isValid()) { + Qt::ItemFlags rootFlags = sourceModel()->flags(m_rootNode); + if (rootFlags.testFlag(Qt::ItemIsDropEnabled)) + return Qt::ItemFlags(Qt::ItemIsDropEnabled); + + return Qt::ItemFlags(0); + } + + // only if the grouped column has the editable flag set allow the + // actions leading to setData on the source (edit & drop) + if (isGroup(idx)) { + // dumpGroups(); + Qt::ItemFlags defaultFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); + // Qt::ItemFlags defaultFlags(Qt::ItemIsEnabled); + bool groupIsEditable = true; + + if (idx.column() == 0) { + bool checkable = true; + foreach (int originalRow, m_groupMap.value(idx.row())) { + QModelIndex originalIdx = + sourceModel()->index(originalRow, 0, m_rootNode.parent()); + if ((originalIdx.flags() & Qt::ItemIsUserCheckable) == 0) { + checkable = false; + } + } + + if (checkable) { + defaultFlags |= Qt::ItemIsUserCheckable; + } + } + + // it's possible to have empty groups + if (m_groupMap.value(idx.row()).count() == 0) { + // check the flags of this column with the root node + QModelIndex originalRootNode = + sourceModel()->index(m_rootNode.row(), m_groupedColumn, m_rootNode.parent()); + groupIsEditable = originalRootNode.flags().testFlag(Qt::ItemIsEditable); + } else { + foreach (int originalRow, m_groupMap.value(idx.row())) { + QModelIndex originalIdx = + sourceModel()->index(originalRow, m_groupedColumn, m_rootNode); + + groupIsEditable = + groupIsEditable ? originalIdx.flags().testFlag(Qt::ItemIsEditable) : false; + if (!groupIsEditable) // all children need to have an editable grouped column + break; + } + } + if (groupIsEditable) + return (defaultFlags | Qt::ItemIsEditable | Qt::ItemIsDropEnabled); + return defaultFlags; + } + + QModelIndex originalIdx = mapToSource(idx); + Qt::ItemFlags originalItemFlags = sourceModel()->flags(originalIdx); + + // check the source model to see if the grouped column is editable; + QModelIndex groupedColumnIndex = + sourceModel()->index(originalIdx.row(), m_groupedColumn, originalIdx.parent()); + bool groupIsEditable = + sourceModel()->flags(groupedColumnIndex).testFlag(Qt::ItemIsEditable); + + if (groupIsEditable) + return originalItemFlags | Qt::ItemIsDragEnabled; + return originalItemFlags; +} + +QVariant QtGroupingProxy::headerData(int section, Qt::Orientation orientation, + int role) const +{ + return sourceModel()->headerData(section, orientation, role); +} + +bool QtGroupingProxy::canFetchMore(const QModelIndex& parent) const +{ + if (!parent.isValid()) + return false; + + if (isGroup(parent)) + return false; + + return sourceModel()->canFetchMore(mapToSource(parent)); +} + +void QtGroupingProxy::fetchMore(const QModelIndex& parent) +{ + if (!parent.isValid()) + return; + + if (isGroup(parent)) + return; + + return sourceModel()->fetchMore(mapToSource(parent)); +} + +QModelIndex QtGroupingProxy::addEmptyGroup(const RowData& data) +{ + int newRow = m_groupMaps.count(); + beginInsertRows(QModelIndex(), newRow, newRow); + m_groupMaps << data; + endInsertRows(); + return index(newRow, 0, QModelIndex()); +} + +bool QtGroupingProxy::removeGroup(const QModelIndex& idx) +{ + beginRemoveRows(idx.parent(), idx.row(), idx.row()); + m_groupMap.remove(idx.row()); + m_groupMaps.removeAt(idx.row()); + m_parentCreateList.removeAt(idx.internalId()); + endRemoveRows(); + + // TODO: only true if all data could be unset. + return true; +} + +bool QtGroupingProxy::hasChildren(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return true; + } + + if (isGroup(parent)) { + return !m_groupMap.value(parent.row()).isEmpty(); + } + + return sourceModel()->hasChildren(mapToSource(parent)); +} + +bool QtGroupingProxy::dropMimeData(const QMimeData* data, Qt::DropAction action, + int row, int column, const QModelIndex& parent) +{ + QModelIndex idx = index(row, column, parent); + if (isGroup(idx)) { + QList childRows = m_groupMap.value(idx.row()); + int max = *std::max_element(childRows.begin(), childRows.end()); + + QModelIndex newIdx = mapToSource(index(max, column, idx)); + return sourceModel()->dropMimeData(data, action, max, column, newIdx); + } else { + if (row == -1) { + return sourceModel()->dropMimeData(data, action, -1, -1, mapToSource(parent)); + } else { + QModelIndex idx = mapToSource(index(row, column, parent)); + return sourceModel()->dropMimeData(data, action, idx.row(), idx.column(), + idx.parent()); + } + } +} + +void QtGroupingProxy::modelRowsAboutToBeInserted(const QModelIndex& parent, int start, + int end) +{ + if (parent != m_rootNode) { + // an item will be added to an original index, remap and pass it on + QModelIndex proxyParent = mapFromSource(parent); + beginInsertRows(proxyParent, start, end); + } +} + +void QtGroupingProxy::modelRowsInserted(const QModelIndex& parent, int start, int end) +{ + if (parent == m_rootNode) { + // top level of the model changed, these new rows need to be put in groups + for (int modelRow = start; modelRow <= end; modelRow++) { + addSourceRow(sourceModel()->index(modelRow, m_groupedColumn, m_rootNode)); + } + } else { + // an item was added to an original index, remap and pass it on + QModelIndex proxyParent = mapFromSource(parent); + + QString s; + QDebug debug(&s); + debug << proxyParent; + log::debug("{}", s); + + // beginInsertRows had to be called in modelRowsAboutToBeInserted() + endInsertRows(); + } +} + +void QtGroupingProxy::modelRowsAboutToBeRemoved(const QModelIndex& parent, int start, + int end) +{ + if (parent == m_rootNode) { + QMap>::const_iterator i; + // HACK, we are going to call beginRemoveRows() multiple times without + // endRemoveRows() if a source index is in multiple groups. + // This can be a problem for some views/proxies, but Q*Views can handle it. + // TODO: investigate a queue for applying proxy model changes in the correct order + for (i = m_groupMap.constBegin(); i != m_groupMap.constEnd(); ++i) { + int groupIndex = i.key(); + const QList& groupList = i.value(); + QModelIndex proxyParent = index(groupIndex, 0); + foreach (int originalRow, groupList) { + if (originalRow >= start && originalRow <= end) { + int proxyRow = groupList.indexOf(originalRow); + if (groupIndex == -1) // adjust for non-grouped (root level) original items + proxyRow += m_groupMaps.count(); + // TODO: optimize for continues original rows in the same group + beginRemoveRows(proxyParent, proxyRow, proxyRow); + } + } + } + } else { + // child item(s) of an original item will be removed, remap and pass it on + QModelIndex proxyParent = mapFromSource(parent); + beginRemoveRows(proxyParent, start, end); + } +} + +void QtGroupingProxy::modelRowsRemoved(const QModelIndex& parent, int start, int end) +{ + if (parent == m_rootNode) { + // TODO: can be optimised by iterating over m_groupMap and checking start <= r < end + + // rather than increasing i we change the stored sourceRows in-place and reuse + // argument start X-times (where X = end - start). + for (int i = start; i <= end; i++) { + // HACK: we are going to iterate the hash in reverse so calls to endRemoveRows() + // are matched up with the beginRemoveRows() in modelRowsAboutToBeRemoved() + // NOTE: easier to do reverse with java style iterator + QMutableMapIterator> iter(m_groupMap); + iter.toBack(); + while (iter.hasPrevious()) { + iter.previous(); + int groupIndex = iter.key(); + // has to be a modifiable reference for remove and replace operations + QList& groupList = iter.value(); + int rowIndex = groupList.indexOf(start); + if (rowIndex != -1) { + QModelIndex proxyParent = index(groupIndex, 0); + groupList.removeAt(rowIndex); + } + // Now decrement all source rows that are after the removed row + for (int j = 0; j < groupList.count(); j++) { + int sourceRow = groupList.at(j); + if (sourceRow > start) + groupList.replace(j, sourceRow - 1); + } + if (rowIndex != -1) + endRemoveRows(); // end remove operation only after group was updated. + } + } + + return; + } + + // beginRemoveRows had to be called in modelRowsAboutToBeRemoved(); + endRemoveRows(); +} + +void QtGroupingProxy::resetModel() +{ + buildTree(); +} + +void QtGroupingProxy::modelDataChanged(const QModelIndex& topLeft, + const QModelIndex& bottomRight) +{ + // TODO: need to look in the groupedColumn and see if it changed and changed grouping + // accordingly + QModelIndex proxyTopLeft = mapFromSource(topLeft); + if (!proxyTopLeft.isValid()) + return; + + if (topLeft == bottomRight) { + emit dataChanged(proxyTopLeft, proxyTopLeft); + } else { + QModelIndex proxyBottomRight = mapFromSource(bottomRight); + emit dataChanged(proxyTopLeft, proxyBottomRight); + } +} + +bool QtGroupingProxy::isAGroupSelected(const QModelIndexList& list) const +{ + foreach (const QModelIndex& index, list) { + if (isGroup(index)) + return true; + } + return false; +} + +void QtGroupingProxy::dumpGroups() const +{ + QString s; + QDebug debug(&s); + + debug << "m_groupMap:\n"; + for (int groupIndex = -1; groupIndex < m_groupMap.keys().count() - 1; groupIndex++) { + debug << groupIndex << " : " << m_groupMap.value(groupIndex) << "\n"; + } + + debug << "m_groupMaps:\n"; + for (int groupIndex = 0; groupIndex < m_groupMaps.count(); groupIndex++) { + debug << m_groupMaps[groupIndex] << ": " << m_groupMap.value(groupIndex) << "\n"; + } + + debug << m_groupMap.value(std::numeric_limits::max()); + + log::debug("{}", s); +} diff --git a/src/src/qtgroupingproxy.h b/src/src/qtgroupingproxy.h new file mode 100644 index 0000000..1ada689 --- /dev/null +++ b/src/src/qtgroupingproxy.h @@ -0,0 +1,153 @@ +/**************************************************************************************** + * Copyright (c) 2007-2010 Bart Cerneels * + * * + * This program is free software; you can redistribute it and/or modify it under * the + *terms of the GNU General Public License as published by the Free Software * + * Foundation; either version 2 of the License, or (at your option) any later * version. + ** + * * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY * + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * + * PARTICULAR PURPOSE. See the GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License along with * this + *program. If not, see . * + ****************************************************************************************/ + +// Modifications 2013-03-27 to 2013-03-28 by Sebastian Herbord + +#ifndef GROUPINGPROXY_H +#define GROUPINGPROXY_H + +#include +#include +#include +#include +#include +#include + +typedef QMap ItemData; +typedef QMap RowData; + +class QtGroupingProxy : public QAbstractProxyModel +{ + Q_OBJECT + +public: + static const unsigned int FLAG_NOSINGLE = 1; + static const unsigned int FLAG_NOGROUPNAME = 2; + + enum EAggregateFunction + { + AGGR_NONE, // no aggregation, return child elements as list + AGGR_EMPTY, // display nothing + AGGR_FIRST, // return value of the topmost item + AGGR_MAX, // return maximum value + AGGR_MIN // return minimum value + }; + +public: + explicit QtGroupingProxy(QModelIndex rootNode = QModelIndex(), int groupedColumn = -1, + int groupedRole = Qt::DisplayRole, unsigned int flags = 0, + int aggregateRole = Qt::DisplayRole); + ~QtGroupingProxy(); + + void setSourceModel(QAbstractItemModel* model) override; + void setGroupedColumn(int groupedColumn); + + /* QAbstractProxyModel methods */ + virtual QModelIndex index(int, int c = 0, + const QModelIndex& parent = QModelIndex()) const; + virtual Qt::ItemFlags flags(const QModelIndex& idx) const; + virtual QModelIndex parent(const QModelIndex& idx) const; + virtual int rowCount(const QModelIndex& idx = QModelIndex()) const; + virtual int columnCount(const QModelIndex& idx) const; + virtual QModelIndex mapToSource(const QModelIndex& idx) const; + virtual QModelIndexList mapToSource(const QModelIndexList& list) const; + virtual QModelIndex mapFromSource(const QModelIndex& idx) const; + virtual QVariant data(const QModelIndex& idx, int role) const; + virtual bool setData(const QModelIndex& index, const QVariant& value, + int role = Qt::EditRole); + virtual QVariant headerData(int section, Qt::Orientation orientation, int role) const; + virtual bool canFetchMore(const QModelIndex& parent) const; + virtual void fetchMore(const QModelIndex& parent); + virtual bool hasChildren(const QModelIndex& parent = QModelIndex()) const; + virtual bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, + int column, const QModelIndex& parent); + + /* QtGroupingProxy methods */ + virtual QModelIndex addEmptyGroup(const RowData& data); + virtual bool removeGroup(const QModelIndex& idx); + +protected slots: + virtual void buildTree(); + +private slots: + void modelDataChanged(const QModelIndex&, const QModelIndex&); + void modelRowsAboutToBeInserted(const QModelIndex&, int, int); + void modelRowsInserted(const QModelIndex&, int, int); + void modelRowsAboutToBeRemoved(const QModelIndex&, int, int); + void modelRowsRemoved(const QModelIndex&, int, int); + void resetModel(); + +protected: + /** Maps an item to a group. + * The return value is a list because an item can put in multiple groups. + * Inside the list is a 2 dimensional map. + * Mapped to column-number is another map of role-number to QVariant. + * This data prepolulates the group-data cache. The rest is gathered on demand + * from the children of the group. + */ + virtual QList belongsTo(const QModelIndex& idx); + + /** + * calls belongsTo(), checks cached data and adds the index to existing or new groups. + * @returns the groups this index was added to where -1 means it was added to the + * root. + */ + QList addSourceRow(const QModelIndex& idx); + + bool isGroup(const QModelIndex& index) const; + bool isAGroupSelected(const QModelIndexList& list) const; + + /** Maintains the group -> sourcemodel row mapping + * The reason a QList is use instead of a QMultiHash is that the values have to + * be reordered when rows are inserted or removed. + * TODO:use some auto-incrementing container class (steveire's?) for the list + */ + QMap> m_groupMap; + /** The data cache of the groups. + * This can be pre-loaded with data in belongsTo() + */ + QList m_groupMaps; + + /** "instuctions" how to create an item in the tree. + * This is used by parent( QModelIndex ) + */ + struct ParentCreate + { + int parentCreateIndex; + int row; + }; + mutable QList m_parentCreateList; + /** @returns index of the "instructions" to recreate the parent. Will create new if it + * doesn't exist yet. + */ + int indexOfParentCreate(const QModelIndex& parent) const; + + QModelIndexList m_selectedGroups; + + QModelIndex m_rootNode; + int m_groupedColumn; + + /* debug function */ + void dumpGroups() const; + +private: + unsigned int m_flags; + int m_groupedRole; + + int m_aggregateRole; +}; + +#endif // GROUPINGPROXY_H diff --git a/src/src/queryoverwritedialog.cpp b/src/src/queryoverwritedialog.cpp new file mode 100644 index 0000000..8454f58 --- /dev/null +++ b/src/src/queryoverwritedialog.cpp @@ -0,0 +1,65 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "queryoverwritedialog.h" +#include "ui_queryoverwritedialog.h" + +#include + +QueryOverwriteDialog::QueryOverwriteDialog(QWidget* parent, Backup b) + : QDialog(parent), ui(new Ui::QueryOverwriteDialog), m_Action(ACT_NONE) +{ + ui->setupUi(this); + ui->backupBox->setChecked(b == BACKUP_YES); + QIcon icon = QApplication::style()->standardIcon(QStyle::SP_MessageBoxQuestion); + ui->iconLabel->setPixmap(icon.pixmap(128)); +} + +QueryOverwriteDialog::~QueryOverwriteDialog() +{ + delete ui; +} + +bool QueryOverwriteDialog::backup() const +{ + return ui->backupBox->isChecked(); +} + +void QueryOverwriteDialog::on_mergeBtn_clicked() +{ + this->m_Action = ACT_MERGE; + this->accept(); +} + +void QueryOverwriteDialog::on_replaceBtn_clicked() +{ + this->m_Action = ACT_REPLACE; + this->accept(); +} + +void QueryOverwriteDialog::on_renameBtn_clicked() +{ + this->m_Action = ACT_RENAME; + this->accept(); +} + +void QueryOverwriteDialog::on_cancelBtn_clicked() +{ + this->reject(); +} diff --git a/src/src/queryoverwritedialog.h b/src/src/queryoverwritedialog.h new file mode 100644 index 0000000..6a88a5b --- /dev/null +++ b/src/src/queryoverwritedialog.h @@ -0,0 +1,64 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef QUERYOVERWRITEDIALOG_H +#define QUERYOVERWRITEDIALOG_H + +#include + +namespace Ui +{ +class QueryOverwriteDialog; +} + +class QueryOverwriteDialog : public QDialog +{ + Q_OBJECT +public: + enum Action + { + ACT_NONE, + ACT_MERGE, + ACT_REPLACE, + ACT_RENAME + }; + + enum Backup + { + BACKUP_NO, + BACKUP_YES + }; + +public: + QueryOverwriteDialog(QWidget* parent, Backup b); + ~QueryOverwriteDialog(); + bool backup() const; + Action action() const { return m_Action; } +private slots: + void on_mergeBtn_clicked(); + void on_replaceBtn_clicked(); + void on_renameBtn_clicked(); + void on_cancelBtn_clicked(); + +private: + Ui::QueryOverwriteDialog* ui; + Action m_Action; +}; + +#endif // QUERYOVERWRITEDIALOG_H diff --git a/src/src/queryoverwritedialog.ui b/src/src/queryoverwritedialog.ui new file mode 100644 index 0000000..85778cf --- /dev/null +++ b/src/src/queryoverwritedialog.ui @@ -0,0 +1,141 @@ + + + QueryOverwriteDialog + + + + 0 + 0 + 423 + 153 + + + + Mod Exists + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + QFrame::NoFrame + + + QFrame::Plain + + + Qt::ScrollBarAlwaysOff + + + Qt::ScrollBarAlwaysOff + + + QAbstractScrollArea::AdjustToContents + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">This mod seems to be installed already, what would you like to do?</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /><span style=" font-weight:600;">Merge:</span> Add files from this archive overwriting existing ones.</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-weight:600;">Replace:</span> Completely replace the existing mod (old files are deleted).</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-weight:600;">Rename:</span> Install this as a separate mod with a new name<span style=" font-style:italic;"> (recommended)</span>.</p></body></html> + + + + + + + + + + 7 + + + 7 + + + 7 + + + 7 + + + + + Keep Backup + + + + + + + Merge + + + + + + + Replace + + + + + + + Rename + + + true + + + + + + + Cancel + + + + + + + + + + diff --git a/src/src/resources.qrc b/src/src/resources.qrc new file mode 100644 index 0000000..eaf6abe --- /dev/null +++ b/src/src/resources.qrc @@ -0,0 +1,117 @@ + + + resources/save.svg + resources/word-wrap.svg + resources/help-browser.png + resources/list-add.png + resources/document-save.png + resources/edit-find-replace.png + resources/go-jump.png + resources/media-playback-start.png + resources/process-stop.png + resources/system-search.png + resources/view-refresh.png + resources/system-installer.png + resources/start-here.png + resources/list-remove.png + resources/document-properties.png + resources/go-up.png + resources/go-down.png + resources/switch-instance-icon.png + resources/contact-new.png + resources/preferences-system.png + resources/application-x-executable.png + resources/dialog-information.png + resources/emblem-readonly.png + resources/go-next_16.png + resources/go-previous_16.png + resources/view-refresh_16.png + resources/software-update-available.png + resources/emblem-important.png + resources/check.png + resources/dialog-warning.png + resources/symbol-backup.png + resources/applications-accessories.png + resources/emblem-unreadable.png + resources/log-debug.png + resources/icons8-checked-checkbox.png + resources/icons8-unchecked-checkbox.png + resources/icons8-indeterminate-checkbox.png + resources/internet-web-browser.png + resources/system-software-update.png + resources/help-browser_32.png + resources/system-installer.png + resources/function.png + resources/plugins.png + resources/edit-clear.png + resources/dynamic-blue-right.png + resources/icon-favorite.png + resources/emblem-favorite.png + resources/error.png + resources/show.png + splash.png + resources/conflict-mixed.png + resources/conflict-overwrite.png + resources/conflict-overwritten.png + resources/conflict-redundant.png + resources/conflict-mixed-blue.png + resources/conflict-overwrite-blue.png + resources/red-archive-conflict-loser.png + resources/conflict-hidden.png + resources/accessories-text-editor.png + resources/x-office-calendar.png + resources/dialog-warning_16.png + resources/mail-attachment.png + resources/document-save_32.png + resources/edit-undo.png + resources/arrange-boxes.png + resources/badge_1.png + resources/badge_2.png + resources/badge_3.png + resources/badge_4.png + resources/badge_5.png + resources/badge_6.png + resources/badge_7.png + resources/badge_8.png + resources/badge_9.png + resources/badge_more.png + resources/status_active.png + resources/status_awaiting.png + resources/status_inactive.png + resources/mo_icon.png + resources/package.png + resources/switch-instance-icon.png + resources/open-Folder-Icon.png + resources/multiply-red.png + resources/archive-conflict-loser.png + resources/archive-conflict-mixed.png + resources/archive-conflict-neutral.png + resources/archive-conflict-winner.png + resources/game-warning.png + resources/game-warning-16.png + resources/tracked.png + + + resources/contents/jigsaw-piece.png + resources/contents/hand-of-god.png + resources/contents/empty-chessboard.png + resources/contents/double-quaver.png + resources/contents/lyre.png + resources/contents/usable.png + resources/contents/checkbox-tree.png + resources/contents/tinker.png + resources/contents/breastplate.png + resources/contents/conversation.png + resources/contents/locked-chest.png + resources/contents/config.png + resources/contents/feather-and-scroll.png + resources/contents/xedit.png + resources/contents/facegen.png + resources/contents/f4-nuclear.png + resources/contents/media.png + resources/contents/mesh-box.png + + + qt.conf + + diff --git a/src/src/resources/accessories-text-editor.png b/src/src/resources/accessories-text-editor.png new file mode 100644 index 0000000..188e1c1 Binary files /dev/null and b/src/src/resources/accessories-text-editor.png differ diff --git a/src/src/resources/application-x-executable.png b/src/src/resources/application-x-executable.png new file mode 100644 index 0000000..8a150b8 Binary files /dev/null and b/src/src/resources/application-x-executable.png differ diff --git a/src/src/resources/applications-accessories.png b/src/src/resources/applications-accessories.png new file mode 100644 index 0000000..fd43de3 Binary files /dev/null and b/src/src/resources/applications-accessories.png differ diff --git a/src/src/resources/archive-conflict-loser.png b/src/src/resources/archive-conflict-loser.png new file mode 100644 index 0000000..561e8be Binary files /dev/null and b/src/src/resources/archive-conflict-loser.png differ diff --git a/src/src/resources/archive-conflict-mixed.png b/src/src/resources/archive-conflict-mixed.png new file mode 100644 index 0000000..df20532 Binary files /dev/null and b/src/src/resources/archive-conflict-mixed.png differ diff --git a/src/src/resources/archive-conflict-neutral.png b/src/src/resources/archive-conflict-neutral.png new file mode 100644 index 0000000..5bedd9c Binary files /dev/null and b/src/src/resources/archive-conflict-neutral.png differ diff --git a/src/src/resources/archive-conflict-winner.png b/src/src/resources/archive-conflict-winner.png new file mode 100644 index 0000000..5cc568c Binary files /dev/null and b/src/src/resources/archive-conflict-winner.png differ diff --git a/src/src/resources/arrange-boxes.png b/src/src/resources/arrange-boxes.png new file mode 100644 index 0000000..b1ab67c Binary files /dev/null and b/src/src/resources/arrange-boxes.png differ diff --git a/src/src/resources/badge_1.png b/src/src/resources/badge_1.png new file mode 100644 index 0000000..5a525b5 Binary files /dev/null and b/src/src/resources/badge_1.png differ diff --git a/src/src/resources/badge_2.png b/src/src/resources/badge_2.png new file mode 100644 index 0000000..93ac67e Binary files /dev/null and b/src/src/resources/badge_2.png differ diff --git a/src/src/resources/badge_3.png b/src/src/resources/badge_3.png new file mode 100644 index 0000000..f21a7be Binary files /dev/null and b/src/src/resources/badge_3.png differ diff --git a/src/src/resources/badge_4.png b/src/src/resources/badge_4.png new file mode 100644 index 0000000..becfaf4 Binary files /dev/null and b/src/src/resources/badge_4.png differ diff --git a/src/src/resources/badge_5.png b/src/src/resources/badge_5.png new file mode 100644 index 0000000..08ef8b1 Binary files /dev/null and b/src/src/resources/badge_5.png differ diff --git a/src/src/resources/badge_6.png b/src/src/resources/badge_6.png new file mode 100644 index 0000000..1a08d78 Binary files /dev/null and b/src/src/resources/badge_6.png differ diff --git a/src/src/resources/badge_7.png b/src/src/resources/badge_7.png new file mode 100644 index 0000000..c173a34 Binary files /dev/null and b/src/src/resources/badge_7.png differ diff --git a/src/src/resources/badge_8.png b/src/src/resources/badge_8.png new file mode 100644 index 0000000..32c7b67 Binary files /dev/null and b/src/src/resources/badge_8.png differ diff --git a/src/src/resources/badge_9.png b/src/src/resources/badge_9.png new file mode 100644 index 0000000..bf5f2cc Binary files /dev/null and b/src/src/resources/badge_9.png differ diff --git a/src/src/resources/badge_more.png b/src/src/resources/badge_more.png new file mode 100644 index 0000000..e0f27f2 Binary files /dev/null and b/src/src/resources/badge_more.png differ diff --git a/src/src/resources/branch-closed.png b/src/src/resources/branch-closed.png new file mode 100644 index 0000000..f4fe633 Binary files /dev/null and b/src/src/resources/branch-closed.png differ diff --git a/src/src/resources/branch-open.png b/src/src/resources/branch-open.png new file mode 100644 index 0000000..457dcc0 Binary files /dev/null and b/src/src/resources/branch-open.png differ diff --git a/src/src/resources/check.png b/src/src/resources/check.png new file mode 100644 index 0000000..5df4475 Binary files /dev/null and b/src/src/resources/check.png differ diff --git a/src/src/resources/combobox-down.png b/src/src/resources/combobox-down.png new file mode 100644 index 0000000..b152788 Binary files /dev/null and b/src/src/resources/combobox-down.png differ diff --git a/src/src/resources/conflict-hidden.png b/src/src/resources/conflict-hidden.png new file mode 100644 index 0000000..84a5da9 Binary files /dev/null and b/src/src/resources/conflict-hidden.png differ diff --git a/src/src/resources/conflict-mixed-blue.png b/src/src/resources/conflict-mixed-blue.png new file mode 100644 index 0000000..dc0c33f Binary files /dev/null and b/src/src/resources/conflict-mixed-blue.png differ diff --git a/src/src/resources/conflict-mixed.png b/src/src/resources/conflict-mixed.png new file mode 100644 index 0000000..900062c Binary files /dev/null and b/src/src/resources/conflict-mixed.png differ diff --git a/src/src/resources/conflict-overwrite-blue.png b/src/src/resources/conflict-overwrite-blue.png new file mode 100644 index 0000000..129a066 Binary files /dev/null and b/src/src/resources/conflict-overwrite-blue.png differ diff --git a/src/src/resources/conflict-overwrite.png b/src/src/resources/conflict-overwrite.png new file mode 100644 index 0000000..b69cd4d Binary files /dev/null and b/src/src/resources/conflict-overwrite.png differ diff --git a/src/src/resources/conflict-overwritten-blue.png b/src/src/resources/conflict-overwritten-blue.png new file mode 100644 index 0000000..856aca1 Binary files /dev/null and b/src/src/resources/conflict-overwritten-blue.png differ diff --git a/src/src/resources/conflict-overwritten.png b/src/src/resources/conflict-overwritten.png new file mode 100644 index 0000000..386b4b7 Binary files /dev/null and b/src/src/resources/conflict-overwritten.png differ diff --git a/src/src/resources/conflict-redundant.png b/src/src/resources/conflict-redundant.png new file mode 100644 index 0000000..06cd823 Binary files /dev/null and b/src/src/resources/conflict-redundant.png differ diff --git a/src/src/resources/contact-new.png b/src/src/resources/contact-new.png new file mode 100644 index 0000000..8b10c1e Binary files /dev/null and b/src/src/resources/contact-new.png differ diff --git a/src/src/resources/contents/breastplate.png b/src/src/resources/contents/breastplate.png new file mode 100644 index 0000000..f8c787e Binary files /dev/null and b/src/src/resources/contents/breastplate.png differ diff --git a/src/src/resources/contents/checkbox-tree.png b/src/src/resources/contents/checkbox-tree.png new file mode 100644 index 0000000..f4a6323 Binary files /dev/null and b/src/src/resources/contents/checkbox-tree.png differ diff --git a/src/src/resources/contents/config.png b/src/src/resources/contents/config.png new file mode 100644 index 0000000..3aaa2e5 Binary files /dev/null and b/src/src/resources/contents/config.png differ diff --git a/src/src/resources/contents/conversation.png b/src/src/resources/contents/conversation.png new file mode 100644 index 0000000..76286e3 Binary files /dev/null and b/src/src/resources/contents/conversation.png differ diff --git a/src/src/resources/contents/double-quaver.png b/src/src/resources/contents/double-quaver.png new file mode 100644 index 0000000..21dcf4c Binary files /dev/null and b/src/src/resources/contents/double-quaver.png differ diff --git a/src/src/resources/contents/empty-chessboard.png b/src/src/resources/contents/empty-chessboard.png new file mode 100644 index 0000000..fccb3aa Binary files /dev/null and b/src/src/resources/contents/empty-chessboard.png differ diff --git a/src/src/resources/contents/f4-nuclear.png b/src/src/resources/contents/f4-nuclear.png new file mode 100644 index 0000000..c9d3c2e Binary files /dev/null and b/src/src/resources/contents/f4-nuclear.png differ diff --git a/src/src/resources/contents/facegen.png b/src/src/resources/contents/facegen.png new file mode 100644 index 0000000..8f5eeac Binary files /dev/null and b/src/src/resources/contents/facegen.png differ diff --git a/src/src/resources/contents/feather-and-scroll.png b/src/src/resources/contents/feather-and-scroll.png new file mode 100644 index 0000000..f82694c Binary files /dev/null and b/src/src/resources/contents/feather-and-scroll.png differ diff --git a/src/src/resources/contents/hand-of-god.png b/src/src/resources/contents/hand-of-god.png new file mode 100644 index 0000000..7cbd010 Binary files /dev/null and b/src/src/resources/contents/hand-of-god.png differ diff --git a/src/src/resources/contents/jigsaw-piece.png b/src/src/resources/contents/jigsaw-piece.png new file mode 100644 index 0000000..4949be4 Binary files /dev/null and b/src/src/resources/contents/jigsaw-piece.png differ diff --git a/src/src/resources/contents/locked-chest.png b/src/src/resources/contents/locked-chest.png new file mode 100644 index 0000000..b49e116 Binary files /dev/null and b/src/src/resources/contents/locked-chest.png differ diff --git a/src/src/resources/contents/lyre.png b/src/src/resources/contents/lyre.png new file mode 100644 index 0000000..3adff24 Binary files /dev/null and b/src/src/resources/contents/lyre.png differ diff --git a/src/src/resources/contents/media.png b/src/src/resources/contents/media.png new file mode 100644 index 0000000..b3791f8 Binary files /dev/null and b/src/src/resources/contents/media.png differ diff --git a/src/src/resources/contents/mesh-box.png b/src/src/resources/contents/mesh-box.png new file mode 100644 index 0000000..13ff85d Binary files /dev/null and b/src/src/resources/contents/mesh-box.png differ diff --git a/src/src/resources/contents/tinker.png b/src/src/resources/contents/tinker.png new file mode 100644 index 0000000..dceb136 Binary files /dev/null and b/src/src/resources/contents/tinker.png differ diff --git a/src/src/resources/contents/usable.png b/src/src/resources/contents/usable.png new file mode 100644 index 0000000..c705997 Binary files /dev/null and b/src/src/resources/contents/usable.png differ diff --git a/src/src/resources/contents/xedit.png b/src/src/resources/contents/xedit.png new file mode 100644 index 0000000..49453d7 Binary files /dev/null and b/src/src/resources/contents/xedit.png differ diff --git a/src/src/resources/dialog-information.png b/src/src/resources/dialog-information.png new file mode 100644 index 0000000..07cf010 Binary files /dev/null and b/src/src/resources/dialog-information.png differ diff --git a/src/src/resources/dialog-warning.png b/src/src/resources/dialog-warning.png new file mode 100644 index 0000000..7233d45 Binary files /dev/null and b/src/src/resources/dialog-warning.png differ diff --git a/src/src/resources/dialog-warning_16.png b/src/src/resources/dialog-warning_16.png new file mode 100644 index 0000000..a9e4ff3 Binary files /dev/null and b/src/src/resources/dialog-warning_16.png differ diff --git a/src/src/resources/dialog-warning_16.png (warning_16) b/src/src/resources/dialog-warning_16.png (warning_16) new file mode 100644 index 0000000..a9e4ff3 Binary files /dev/null and b/src/src/resources/dialog-warning_16.png (warning_16) differ diff --git a/src/src/resources/document-properties.png b/src/src/resources/document-properties.png new file mode 100644 index 0000000..a5ad728 Binary files /dev/null and b/src/src/resources/document-properties.png differ diff --git a/src/src/resources/document-save.png b/src/src/resources/document-save.png new file mode 100644 index 0000000..18e1f97 Binary files /dev/null and b/src/src/resources/document-save.png differ diff --git a/src/src/resources/document-save_32.png b/src/src/resources/document-save_32.png new file mode 100644 index 0000000..db5c52b Binary files /dev/null and b/src/src/resources/document-save_32.png differ diff --git a/src/src/resources/dynamic-blue-right.png b/src/src/resources/dynamic-blue-right.png new file mode 100644 index 0000000..cb08cae Binary files /dev/null and b/src/src/resources/dynamic-blue-right.png differ diff --git a/src/src/resources/edit-clear.png b/src/src/resources/edit-clear.png new file mode 100644 index 0000000..e6c8e8b Binary files /dev/null and b/src/src/resources/edit-clear.png differ diff --git a/src/src/resources/edit-find-replace.png b/src/src/resources/edit-find-replace.png new file mode 100644 index 0000000..de3a75f Binary files /dev/null and b/src/src/resources/edit-find-replace.png differ diff --git a/src/src/resources/edit-undo.png b/src/src/resources/edit-undo.png new file mode 100644 index 0000000..61b2ce9 Binary files /dev/null and b/src/src/resources/edit-undo.png differ diff --git a/src/src/resources/emblem-favorite - 64.png b/src/src/resources/emblem-favorite - 64.png new file mode 100644 index 0000000..97b507e Binary files /dev/null and b/src/src/resources/emblem-favorite - 64.png differ diff --git a/src/src/resources/emblem-favorite.png b/src/src/resources/emblem-favorite.png new file mode 100644 index 0000000..6f003c5 Binary files /dev/null and b/src/src/resources/emblem-favorite.png differ diff --git a/src/src/resources/emblem-important.png b/src/src/resources/emblem-important.png new file mode 100644 index 0000000..81e9ed2 Binary files /dev/null and b/src/src/resources/emblem-important.png differ diff --git a/src/src/resources/emblem-readonly.png b/src/src/resources/emblem-readonly.png new file mode 100644 index 0000000..0466619 Binary files /dev/null and b/src/src/resources/emblem-readonly.png differ diff --git a/src/src/resources/emblem-redundant.png b/src/src/resources/emblem-redundant.png new file mode 100644 index 0000000..048942d Binary files /dev/null and b/src/src/resources/emblem-redundant.png differ diff --git a/src/src/resources/emblem-unreadable.png b/src/src/resources/emblem-unreadable.png new file mode 100644 index 0000000..5c08b05 Binary files /dev/null and b/src/src/resources/emblem-unreadable.png differ diff --git a/src/src/resources/error.png b/src/src/resources/error.png new file mode 100644 index 0000000..b1c4764 Binary files /dev/null and b/src/src/resources/error.png differ diff --git a/src/src/resources/function.png b/src/src/resources/function.png new file mode 100644 index 0000000..0a7eba5 Binary files /dev/null and b/src/src/resources/function.png differ diff --git a/src/src/resources/game-warning-16.png b/src/src/resources/game-warning-16.png new file mode 100644 index 0000000..0c421b1 Binary files /dev/null and b/src/src/resources/game-warning-16.png differ diff --git a/src/src/resources/game-warning.png b/src/src/resources/game-warning.png new file mode 100644 index 0000000..d54a5a3 Binary files /dev/null and b/src/src/resources/game-warning.png differ diff --git a/src/src/resources/go-down.png b/src/src/resources/go-down.png new file mode 100644 index 0000000..bf0ce4f Binary files /dev/null and b/src/src/resources/go-down.png differ diff --git a/src/src/resources/go-jump.png b/src/src/resources/go-jump.png new file mode 100644 index 0000000..373dce9 Binary files /dev/null and b/src/src/resources/go-jump.png differ diff --git a/src/src/resources/go-next_16.png b/src/src/resources/go-next_16.png new file mode 100644 index 0000000..58742d3 Binary files /dev/null and b/src/src/resources/go-next_16.png differ diff --git a/src/src/resources/go-previous_16.png b/src/src/resources/go-previous_16.png new file mode 100644 index 0000000..b4b22d0 Binary files /dev/null and b/src/src/resources/go-previous_16.png differ diff --git a/src/src/resources/go-up.png b/src/src/resources/go-up.png new file mode 100644 index 0000000..a4b4e02 Binary files /dev/null and b/src/src/resources/go-up.png differ diff --git a/src/src/resources/help-browser.png b/src/src/resources/help-browser.png new file mode 100644 index 0000000..c67c7a6 Binary files /dev/null and b/src/src/resources/help-browser.png differ diff --git a/src/src/resources/help-browser_32.png b/src/src/resources/help-browser_32.png new file mode 100644 index 0000000..d60425f Binary files /dev/null and b/src/src/resources/help-browser_32.png differ diff --git a/src/src/resources/icon-favorite.png b/src/src/resources/icon-favorite.png new file mode 100644 index 0000000..c22b26b Binary files /dev/null and b/src/src/resources/icon-favorite.png differ diff --git a/src/src/resources/icons8-checked-checkbox.png b/src/src/resources/icons8-checked-checkbox.png new file mode 100644 index 0000000..1a36d1f Binary files /dev/null and b/src/src/resources/icons8-checked-checkbox.png differ diff --git a/src/src/resources/icons8-indeterminate-checkbox.png b/src/src/resources/icons8-indeterminate-checkbox.png new file mode 100644 index 0000000..4f386c4 Binary files /dev/null and b/src/src/resources/icons8-indeterminate-checkbox.png differ diff --git a/src/src/resources/icons8-unchecked-checkbox.png b/src/src/resources/icons8-unchecked-checkbox.png new file mode 100644 index 0000000..c56a74e Binary files /dev/null and b/src/src/resources/icons8-unchecked-checkbox.png differ diff --git a/src/src/resources/internet-web-browser.png b/src/src/resources/internet-web-browser.png new file mode 100644 index 0000000..10d2ed4 Binary files /dev/null and b/src/src/resources/internet-web-browser.png differ diff --git a/src/src/resources/list-add.png b/src/src/resources/list-add.png new file mode 100644 index 0000000..306d3d8 Binary files /dev/null and b/src/src/resources/list-add.png differ diff --git a/src/src/resources/list-remove.png b/src/src/resources/list-remove.png new file mode 100644 index 0000000..45e5c2a Binary files /dev/null and b/src/src/resources/list-remove.png differ diff --git a/src/src/resources/log-debug.png b/src/src/resources/log-debug.png new file mode 100644 index 0000000..05ba742 Binary files /dev/null and b/src/src/resources/log-debug.png differ diff --git a/src/src/resources/mail-attachment.png b/src/src/resources/mail-attachment.png new file mode 100644 index 0000000..529bb7f Binary files /dev/null and b/src/src/resources/mail-attachment.png differ diff --git a/src/src/resources/markdown.html b/src/src/resources/markdown.html new file mode 100644 index 0000000..8d6d949 --- /dev/null +++ b/src/src/resources/markdown.html @@ -0,0 +1,292 @@ + + + + + + + + + +
        + + + diff --git a/src/src/resources/media-playback-start.png b/src/src/resources/media-playback-start.png new file mode 100644 index 0000000..10102d8 Binary files /dev/null and b/src/src/resources/media-playback-start.png differ diff --git a/src/src/resources/mo_icon.png b/src/src/resources/mo_icon.png new file mode 100644 index 0000000..c926d72 Binary files /dev/null and b/src/src/resources/mo_icon.png differ diff --git a/src/src/resources/multiply-red.png b/src/src/resources/multiply-red.png new file mode 100644 index 0000000..f35adc7 Binary files /dev/null and b/src/src/resources/multiply-red.png differ diff --git a/src/src/resources/open-Folder-Icon.png b/src/src/resources/open-Folder-Icon.png new file mode 100644 index 0000000..6218ed3 Binary files /dev/null and b/src/src/resources/open-Folder-Icon.png differ diff --git a/src/src/resources/package.png b/src/src/resources/package.png new file mode 100644 index 0000000..4b55b50 Binary files /dev/null and b/src/src/resources/package.png differ diff --git a/src/src/resources/plugins.png b/src/src/resources/plugins.png new file mode 100644 index 0000000..8426df9 Binary files /dev/null and b/src/src/resources/plugins.png differ diff --git a/src/src/resources/preferences-system.png b/src/src/resources/preferences-system.png new file mode 100644 index 0000000..6e52db7 Binary files /dev/null and b/src/src/resources/preferences-system.png differ diff --git a/src/src/resources/process-stop.png b/src/src/resources/process-stop.png new file mode 100644 index 0000000..b68290b Binary files /dev/null and b/src/src/resources/process-stop.png differ diff --git a/src/src/resources/red-archive-conflict-loser.png b/src/src/resources/red-archive-conflict-loser.png new file mode 100644 index 0000000..18284ee Binary files /dev/null and b/src/src/resources/red-archive-conflict-loser.png differ diff --git a/src/src/resources/save.svg b/src/src/resources/save.svg new file mode 100644 index 0000000..452db49 --- /dev/null +++ b/src/src/resources/save.svg @@ -0,0 +1 @@ + diff --git a/src/src/resources/show.png b/src/src/resources/show.png new file mode 100644 index 0000000..ac8b68e Binary files /dev/null and b/src/src/resources/show.png differ diff --git a/src/src/resources/software-update-available.png b/src/src/resources/software-update-available.png new file mode 100644 index 0000000..ab8d494 Binary files /dev/null and b/src/src/resources/software-update-available.png differ diff --git a/src/src/resources/start-here.png b/src/src/resources/start-here.png new file mode 100644 index 0000000..1e54d01 Binary files /dev/null and b/src/src/resources/start-here.png differ diff --git a/src/src/resources/status_active.png b/src/src/resources/status_active.png new file mode 100644 index 0000000..62fd50d Binary files /dev/null and b/src/src/resources/status_active.png differ diff --git a/src/src/resources/status_awaiting.png b/src/src/resources/status_awaiting.png new file mode 100644 index 0000000..4818431 Binary files /dev/null and b/src/src/resources/status_awaiting.png differ diff --git a/src/src/resources/status_inactive.png b/src/src/resources/status_inactive.png new file mode 100644 index 0000000..711c4b5 Binary files /dev/null and b/src/src/resources/status_inactive.png differ diff --git a/src/src/resources/switch-instance-icon.png b/src/src/resources/switch-instance-icon.png new file mode 100644 index 0000000..cd509d3 Binary files /dev/null and b/src/src/resources/switch-instance-icon.png differ diff --git a/src/src/resources/symbol-backup.png b/src/src/resources/symbol-backup.png new file mode 100644 index 0000000..f1d7a19 Binary files /dev/null and b/src/src/resources/symbol-backup.png differ diff --git a/src/src/resources/system-installer.png b/src/src/resources/system-installer.png new file mode 100644 index 0000000..c26576e Binary files /dev/null and b/src/src/resources/system-installer.png differ diff --git a/src/src/resources/system-search.png b/src/src/resources/system-search.png new file mode 100644 index 0000000..4e522b2 Binary files /dev/null and b/src/src/resources/system-search.png differ diff --git a/src/src/resources/system-software-update.png b/src/src/resources/system-software-update.png new file mode 100644 index 0000000..470b5d4 Binary files /dev/null and b/src/src/resources/system-software-update.png differ diff --git a/src/src/resources/tracked.png b/src/src/resources/tracked.png new file mode 100644 index 0000000..6aab302 Binary files /dev/null and b/src/src/resources/tracked.png differ diff --git a/src/src/resources/view-refresh.png b/src/src/resources/view-refresh.png new file mode 100644 index 0000000..cab4d02 Binary files /dev/null and b/src/src/resources/view-refresh.png differ diff --git a/src/src/resources/view-refresh_16.png b/src/src/resources/view-refresh_16.png new file mode 100644 index 0000000..3fd71d6 Binary files /dev/null and b/src/src/resources/view-refresh_16.png differ diff --git a/src/src/resources/word-wrap.svg b/src/src/resources/word-wrap.svg new file mode 100644 index 0000000..21a6125 --- /dev/null +++ b/src/src/resources/word-wrap.svg @@ -0,0 +1 @@ + diff --git a/src/src/resources/x-office-calendar.png b/src/src/resources/x-office-calendar.png new file mode 100644 index 0000000..f6978d7 Binary files /dev/null and b/src/src/resources/x-office-calendar.png differ diff --git a/src/src/sanitychecks.cpp b/src/src/sanitychecks.cpp new file mode 100644 index 0000000..d6b285e --- /dev/null +++ b/src/src/sanitychecks.cpp @@ -0,0 +1,467 @@ +#include "sanitychecks.h" +#include "env.h" +#include "envmodule.h" +#include "settings.h" +#include +#include +#include + +namespace sanity +{ + +using namespace MOBase; + +#ifdef _WIN32 + +enum class SecurityZone +{ + NoZone = -1, + MyComputer = 0, + Intranet = 1, + Trusted = 2, + Internet = 3, + Untrusted = 4, +}; + +QString toCodeName(SecurityZone z) +{ + switch (z) { + case SecurityZone::NoZone: + return "NoZone"; + case SecurityZone::MyComputer: + return "MyComputer"; + case SecurityZone::Intranet: + return "Intranet"; + case SecurityZone::Trusted: + return "Trusted"; + case SecurityZone::Internet: + return "Internet"; + case SecurityZone::Untrusted: + return "Untrusted"; + default: + return "Unknown zone"; + } +} + +QString toString(SecurityZone z) +{ + return QString("%1 (%2)").arg(toCodeName(z)).arg(static_cast(z)); +} + +// whether the given zone is considered blocked +// +bool isZoneBlocked(SecurityZone z) +{ + switch (z) { + case SecurityZone::Internet: + case SecurityZone::Untrusted: + return true; + + case SecurityZone::NoZone: + case SecurityZone::MyComputer: + case SecurityZone::Intranet: + case SecurityZone::Trusted: + default: + return false; + } +} + +// whether the given file is blocked +// +bool isFileBlocked(const QFileInfo& fi) +{ + // name of the alternate data stream containing the zone identifier ini + const QString ads = "Zone.Identifier"; + + // key in the ini + const auto key = "ZoneTransfer/ZoneId"; + + // the path to the ADS is always `filename:Zone.Identifier` + const auto path = fi.absoluteFilePath(); + const auto adsPath = path + ":" + ads; + + QFile f(adsPath); + if (!f.exists()) { + // no ADS for this file + return false; + } + + log::debug("'{}' has an ADS for {}", path, adsPath); + + const QSettings qs(adsPath, QSettings::IniFormat); + + // looking for key + if (!qs.contains(key)) { + log::debug("'{}': key '{}' not found", adsPath, key); + return false; + } + + // getting value + const auto v = qs.value(key); + if (v.isNull()) { + log::debug("'{}': key '{}' is null", adsPath, key); + return false; + } + + // should be an int + bool ok = false; + const auto z = static_cast(v.toInt(&ok)); + + if (!ok) { + log::debug("'{}': key '{}' is not an int (value is '{}')", adsPath, key, v); + return false; + } + + if (!isZoneBlocked(z)) { + // that zone is not a blocked zone + log::debug("'{}': zone id is {}, which is fine", adsPath, toString(z)); + return false; + } + + // file is blocked + log::warn("{}", QObject::tr("'%1': file is blocked (%2)").arg(path).arg(toString(z))); + + return true; +} + +int checkBlockedFiles(const QDir& dir) +{ + // executables file types + const QStringList FileTypes = {"*.dll", "*.exe"}; + + if (!dir.exists()) { + // shouldn't happen + log::error("while checking for blocked files, directory '{}' not found", + dir.absolutePath()); + + return 1; + } + + const auto files = dir.entryInfoList(FileTypes, QDir::Files); + if (files.empty()) { + // shouldn't happen + log::error("while checking for blocked files, directory '{}' is empty", + dir.absolutePath()); + + return 1; + } + + int n = 0; + + // checking each file in this directory + for (auto&& fi : files) { + if (isFileBlocked(fi)) { + ++n; + } + } + + return n; +} + +int checkBlocked() +{ + // directories that contain executables; these need to be explicit because + // portable instances might add billions of files in MO's directory + const QString dirs[] = {".", "/dlls", "/loot", "/NCC", "/platforms", "/plugins"}; + + log::debug(" . blocked files"); + const QString appDir = QCoreApplication::applicationDirPath(); + + int n = 0; + + for (const auto& d : dirs) { + const auto path = QDir(appDir + "/" + d).canonicalPath(); + n += checkBlockedFiles(path); + } + + return n; +} + +int checkMissingFiles() +{ + // files that are likely to be eaten + static const QStringList files( + {"helper.exe", "nxmhandler.exe", "usvfs_proxy_x64.exe", "usvfs_proxy_x86.exe", + "usvfs_x64.dll", "usvfs_x86.dll", "loot/loot.dll", "loot/lootcli.exe"}); + + log::debug(" . missing files"); + const auto dir = QCoreApplication::applicationDirPath(); + + int n = 0; + + for (const auto& name : files) { + const QFileInfo file(dir + "/" + name); + + if (!file.exists()) { + log::warn("{}", QObject::tr( + "'%1' seems to be missing, an antivirus may have deleted it") + .arg(file.absoluteFilePath())); + + ++n; + } + } + + return n; +} + +int checkBadOSDs(const env::Module& m) +{ + // these dlls seem to interfere mostly with dialogs, like the mod info + // dialog: they render some dialogs fully white and make it impossible to + // interact with them + // + // the dlls is usually loaded on startup, but there has been some reports + // where they got loaded later, so this is also called every time a new module + // is loaded into this process + + const std::string nahimic = "Nahimic (also known as SonicSuite, SonicRadar, " + "SteelSeries, A-Volute, etc.)"; + + auto p = [](std::string re, std::string s) { + return std::make_pair(std::regex(re, std::regex::icase), s); + }; + + static const std::vector> list = { + p("nahimic(.*)osd\\.dll", nahimic), + p("cassini(.*)osd\\.dll", nahimic), + p(".+devprops.*.dll", nahimic), + p("ss2osd\\.dll", nahimic), + p("RTSSHooks64\\.dll", "RivaTuner Statistics Server"), + p("SSAudioOSD\\.dll", "SteelSeries Audio"), + p("specialk64\\.dll", "SpecialK"), + p("corsairosdhook\\.x64\\.dll", "Corsair Utility Engine"), + p("gtii-osd64-vk\\.dll", "ASUS GPU Tweak 2"), + p("easyhook64\\.dll", "Razer Cortex"), + p("k_fps64\\.dll", "Razer Cortex"), + p("fw1fontwrapper\\.dll", "Gigabyte 3D OSD"), + p("gfxhook64\\.dll", "Gigabyte 3D OSD")}; + + const QFileInfo file(m.path()); + int n = 0; + + for (auto&& p : list) { + std::smatch m; + const auto filename = file.fileName().toStdString(); + + if (std::regex_match(filename, m, p.first)) { + log::warn("{}", QObject::tr( + "%1 is loaded.\nThis program is known to cause issues with " + "Mod Organizer, such as freezing or blank windows. Consider " + "uninstalling it.") + .arg(QString::fromStdString(p.second))); + + log::warn("{}", file.absoluteFilePath()); + ++n; + } + } + + return n; +} + +int checkUsvfsIncompatibilites(const env::Module& m) +{ + // these dlls seems to interfere with usvfs + + static const std::map names = { + {"mactype64.dll", "Mactype"}, {"epclient64.dll", "Citrix ICA Client"}}; + + const QFileInfo file(m.path()); + int n = 0; + + for (auto&& p : names) { + if (file.fileName().compare(p.first, Qt::CaseInsensitive) == 0) { + log::warn( + "{}", + QObject::tr("%1 is loaded. This program is known to cause issues with " + "Mod Organizer and its virtual filesystem, such script extenders " + "or others programs refusing to run. Consider uninstalling it.") + .arg(p.second)); + + log::warn("{}", file.absoluteFilePath()); + + ++n; + } + } + + return n; +} + +int checkIncompatibleModule(const env::Module& m) +{ + int n = 0; + + n += checkBadOSDs(m); + n += checkUsvfsIncompatibilites(m); + + return n; +} + +int checkIncompatibilities(const env::Environment& e) +{ + log::debug(" . incompatibilities"); + + int n = 0; + + for (auto&& m : e.loadedModules()) { + n += checkIncompatibleModule(m); + } + + return n; +} + +std::vector> getSystemDirectories() +{ + // folder ids and display names for logging + const std::vector> systemFolderIDs = { + {FOLDERID_ProgramFiles, "in Program Files"}, + {FOLDERID_ProgramFilesX86, "in Program Files"}, + {FOLDERID_Desktop, "on the desktop"}, + {FOLDERID_OneDrive, "in OneDrive"}, + {FOLDERID_Documents, "in Documents"}, + {FOLDERID_Downloads, "in Downloads"}}; + + std::vector> systemDirs; + + for (auto&& p : systemFolderIDs) { + const auto dir = MOBase::getOptionalKnownFolder(p.first); + + if (!dir.isEmpty()) { + auto path = QDir::toNativeSeparators(dir).toLower(); + if (!path.endsWith("\\")) { + path += "\\"; + } + + systemDirs.push_back({path, p.second}); + } + } + + return systemDirs; +} + +int checkProtected(const QDir& d, const QString& what) +{ + static const auto systemDirs = getSystemDirectories(); + + const auto path = QDir::toNativeSeparators(d.absolutePath()).toLower(); + + log::debug(" . {}: {}", what, path); + + for (auto&& sd : systemDirs) { + if (path.startsWith(sd.first)) { + log::warn("{} is {}; this may cause issues because it's a special " + "system folder", + what, sd.second); + + log::debug("path '{}' starts with '{}'", path, sd.first); + + return 1; + } + } + + return 0; +} + +int checkMicrosoftStore(const QDir& gameDir) +{ + const QStringList pathsToCheck = { + "/ModifiableWindowsApps/", + "/WindowsApps/", + }; + for (auto badPath : pathsToCheck) { + if (gameDir.path().contains(badPath)) { + log::error("This game is not supported by Mod Organizer."); + log::error("Games installed through the Microsoft Store will not work properly."); + return 1; + } + } + + return 0; +} + +#else // Linux + +int checkMissingFiles() +{ + log::debug(" . checking Linux dependencies"); + int n = 0; + + // Check for FUSE (skip in Flatpak — the VFS helper runs on the host) + if (!QFileInfo::exists("/.flatpak-info") && + !QFileInfo::exists("/usr/lib/libfuse3.so") && + !QFileInfo::exists("/usr/lib64/libfuse3.so") && + !QFileInfo::exists("/usr/lib/x86_64-linux-gnu/libfuse3.so")) { + log::warn("libfuse3 not found - FUSE VFS will not work"); + ++n; + } + + return n; +} + +int checkIncompatibleModule(const env::Module& /*m*/) +{ + // No known incompatible modules on Linux + return 0; +} + +int checkProtected(const QDir& d, const QString& what) +{ + const auto path = d.absolutePath(); + log::debug(" . {}: {}", what, path); + + // Check if running from system directories + if (path.startsWith("/root") || path.startsWith("/usr") || path.startsWith("/bin")) { + log::warn("{} is in a system directory; this may cause permission issues", what); + return 1; + } + + return 0; +} + +#endif // _WIN32 + +int checkPaths(IPluginGame& game, const Settings& s) +{ + log::debug("checking paths"); + + int n = 0; + + n += checkProtected(game.gameDirectory(), "the game"); +#ifdef _WIN32 + n += checkMicrosoftStore(game.gameDirectory()); +#endif + n += checkProtected(QApplication::applicationDirPath(), "Mod Organizer"); + + if (checkProtected(s.paths().base(), "the instance base directory")) { + ++n; + } else { + n += checkProtected(s.paths().downloads(), "the downloads directory"); + n += checkProtected(s.paths().mods(), "the mods directory"); + n += checkProtected(s.paths().cache(), "the cache directory"); + n += checkProtected(s.paths().profiles(), "the profiles directory"); + n += checkProtected(s.paths().overwrite(), "the overwrite directory"); + } + + return n; +} + +void checkEnvironment(const env::Environment& e) +{ + log::debug("running sanity checks..."); + + int n = 0; + +#ifdef _WIN32 + n += checkBlocked(); +#endif + n += checkMissingFiles(); +#ifdef _WIN32 + n += checkIncompatibilities(e); +#else + Q_UNUSED(e); +#endif + + log::debug("sanity checks done, {}", + (n > 0 ? "problems were found" : "everything looks okay")); +} + +} // namespace sanity diff --git a/src/src/sanitychecks.h b/src/src/sanitychecks.h new file mode 100644 index 0000000..27f6d6e --- /dev/null +++ b/src/src/sanitychecks.h @@ -0,0 +1,26 @@ +#ifndef MODORGANIZER_SANITYCHECKS_INCLUDED +#define MODORGANIZER_SANITYCHECKS_INCLUDED + +namespace env +{ +class Environment; +class Module; +} // namespace env + +namespace MOBase +{ +class IPluginGame; +} + +class Settings; + +namespace sanity +{ + +void checkEnvironment(const env::Environment& env); +int checkIncompatibleModule(const env::Module& m); +int checkPaths(MOBase::IPluginGame& game, const Settings& s); + +} // namespace sanity + +#endif // MODORGANIZER_SANITYCHECKS_INCLUDED diff --git a/src/src/savestab.cpp b/src/src/savestab.cpp new file mode 100644 index 0000000..2f921e5 --- /dev/null +++ b/src/src/savestab.cpp @@ -0,0 +1,393 @@ +#include "savestab.h" +#include "activatemodsdialog.h" +#include "organizercore.h" +#include "ui_mainwindow.h" +#include +#include +#include +#include +#include + +using namespace MOBase; + +namespace +{ +bool g_disableSaveTooltipsAfterOOM = false; + +QString sanitizeText(QString in, int maxLen = 200) +{ + for (int i = 0; i < in.size(); ++i) { + const QChar c = in.at(i); + // Replace control chars except common whitespace. + if (c.unicode() < 0x20 && c != QChar('\n') && c != QChar('\r') && c != QChar('\t')) { + in[i] = QChar('?'); + } + } + + if (in.size() > maxLen) { + in.truncate(maxLen); + in += "..."; + } + + return in; +} + +bool isLikelyCorruptSaveText(QString const& in) +{ + if (in.trimmed().isEmpty()) { + return true; + } + + int suspicious = 0; + for (const QChar c : in) { + if (c.unicode() == 0xFFFD || c.isNull() || + (!c.isPrint() && !c.isSpace())) { + ++suspicious; + } + } + + return suspicious > (in.size() / 8); +} +} // namespace + +SavesTab::SavesTab(QWidget* window, OrganizerCore& core, Ui::MainWindow* mwui) + : m_window(window), m_core(core), m_CurrentSaveView(nullptr), + ui{mwui->tabWidget, mwui->savesTab, mwui->savegameList} +{ + m_SavesWatcherTimer.setSingleShot(true); + m_SavesWatcherTimer.setInterval(500); + + ui.list->installEventFilter(this); + ui.list->setMouseTracking(true); + + connect(&m_SavesWatcher, &QFileSystemWatcher::directoryChanged, [&] { + m_SavesWatcherTimer.start(); + }); + + connect(&m_SavesWatcherTimer, &QTimer::timeout, [&] { + refreshSavesIfOpen(); + }); + + connect(ui.list, &QWidget::customContextMenuRequested, [&](auto pos) { + onContextMenu(pos); + }); + + connect(ui.list, &QTreeWidget::itemEntered, [&](auto* item) { + saveSelectionChanged(item); + }); +} + +bool SavesTab::eventFilter(QObject* object, QEvent* e) +{ + if (object == ui.list) { + if (e->type() == QEvent::Leave || e->type() == QEvent::WindowDeactivate) { + hideSaveGameInfo(); + } else if (e->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(e); + if (keyEvent->key() == Qt::Key_Delete) { + deleteSavegame(); + } + } + } + + return false; +} + +void SavesTab::displaySaveGameInfo(QTreeWidgetItem* newItem) +{ + if (g_disableSaveTooltipsAfterOOM) { + return; + } + + // don't display the widget if the main window doesn't have focus + // + // this goes against the standard behaviour for tooltips, which are displayed + // on hover regardless of focus, but this widget is so large and busy that + // it's probably better this way + if (!m_window->isActiveWindow()) { + return; + } + + if (m_CurrentSaveView == nullptr) { + auto info = m_core.gameFeatures().gameFeature(); + + if (info != nullptr) { + m_CurrentSaveView = info->getSaveGameWidget(m_window); + } + + if (m_CurrentSaveView == nullptr) { + return; + } + } + + try { + m_CurrentSaveView->setSave(*m_SaveGames[ui.list->indexOfTopLevelItem(newItem)]); + } catch (const std::bad_alloc&) { + g_disableSaveTooltipsAfterOOM = true; + log::error("insufficient memory while rendering save tooltip for '{}'", + sanitizeText(newItem ? newItem->text(0) : QString())); + reportError(QObject::tr("Save tooltip rendering was disabled for this session due " + "to low memory while parsing save metadata.")); + hideSaveGameInfo(); + return; + } catch (const std::exception& e) { + log::error("failed to render save tooltip: {}", e.what()); + hideSaveGameInfo(); + return; + } + + QWindow* window = m_CurrentSaveView->window()->windowHandle(); + QRect screenRect; + if (window == nullptr) + screenRect = QGuiApplication::primaryScreen()->geometry(); + else + screenRect = window->screen()->geometry(); + + QPoint pos = QCursor::pos(); + if (pos.x() + m_CurrentSaveView->width() > screenRect.right()) { + pos.rx() -= (m_CurrentSaveView->width() + 2); + } else { + pos.rx() += 5; + } + + if (pos.y() + m_CurrentSaveView->height() > screenRect.bottom()) { + pos.ry() -= (m_CurrentSaveView->height() + 10); + } else { + pos.ry() += 20; + } + m_CurrentSaveView->move(pos); + + m_CurrentSaveView->show(); + m_CurrentSaveView->setProperty("displayItem", + QVariant::fromValue(static_cast(newItem))); +} + +void SavesTab::saveSelectionChanged(QTreeWidgetItem* newItem) +{ + if (newItem == nullptr) { + hideSaveGameInfo(); + } else if (m_CurrentSaveView == nullptr || + newItem != m_CurrentSaveView->property("displayItem").value()) { + displaySaveGameInfo(newItem); + } +} + +void SavesTab::hideSaveGameInfo() +{ + if (m_CurrentSaveView != nullptr) { + m_CurrentSaveView->deleteLater(); + m_CurrentSaveView = nullptr; + } +} + +void SavesTab::refreshSavesIfOpen() +{ + if (ui.mainTabs->currentWidget() == ui.tab) { + refreshSaveList(); + } +} + +QDir SavesTab::currentSavesDir() const +{ + // TODO: This code should probably be handled by the game plugins + QDir savesDir; + if (m_core.currentProfile()->localSavesEnabled()) { + savesDir.setPath(m_core.currentProfile()->savePath()); + } else { + auto iniFiles = m_core.managedGame()->iniFiles(); + + if (iniFiles.isEmpty() || + m_core.gameFeatures().gameFeature() == nullptr) { + return m_core.managedGame()->savesDirectory(); + } + + QString iniPath = m_core.currentProfile()->absoluteIniFilePath(iniFiles[0]); + +#ifdef _WIN32 + wchar_t path[MAX_PATH]; + if (::GetPrivateProfileStringW(L"General", L"SLocalSavePath", L"", path, MAX_PATH, + iniPath.toStdWString().c_str())) { + savesDir.setPath(m_core.managedGame()->documentsDirectory().absoluteFilePath( + QString::fromWCharArray(path))); + } else { + savesDir = m_core.managedGame()->savesDirectory(); + } +#else + // On Linux, use QSettings to read the INI file + QSettings ini(iniPath, QSettings::IniFormat); + QString savePath = ini.value("General/SLocalSavePath").toString(); + if (!savePath.isEmpty()) { + savesDir.setPath(m_core.managedGame()->documentsDirectory().absoluteFilePath(savePath)); + } else { + savesDir = m_core.managedGame()->savesDirectory(); + } +#endif + } + + return savesDir; +} + +void SavesTab::startMonitorSaves() +{ + stopMonitorSaves(); + + QDir savesDir = currentSavesDir(); + + m_SavesWatcher.addPath(savesDir.absolutePath()); +} + +void SavesTab::stopMonitorSaves() +{ + if (m_SavesWatcher.directories().length() > 0) { + m_SavesWatcher.removePaths(m_SavesWatcher.directories()); + } +} + +void SavesTab::refreshSaveList() +{ + TimeThis tt("MainWindow::refreshSaveList()"); + + startMonitorSaves(); // re-starts monitoring + + try { + QDir savesDir = currentSavesDir(); + MOBase::log::debug("reading save games from {}", savesDir.absolutePath()); + m_SaveGames = m_core.managedGame()->listSaves(savesDir); + std::sort(m_SaveGames.begin(), m_SaveGames.end(), + [](auto const& lhs, auto const& rhs) { + return lhs->getCreationTime() > rhs->getCreationTime(); + }); + + ui.list->clear(); + for (auto& save : m_SaveGames) { + auto relpath = savesDir.relativeFilePath(save->getFilepath()); + const auto rawName = save->getName(); + auto display = sanitizeText(rawName, 300); + if (display.trimmed().isEmpty() || isLikelyCorruptSaveText(rawName)) { + display = sanitizeText(QFileInfo(save->getFilepath()).completeBaseName(), 300); + } + ui.list->addTopLevelItem(new QTreeWidgetItem(ui.list, {display, relpath})); + } + } catch (std::exception& e) { + // listSaves() can throw + log::error("{}", e.what()); + } +} + +void SavesTab::deleteSavegame() +{ + auto info = m_core.gameFeatures().gameFeature(); + + QString savesMsgLabel; + QStringList deleteFiles; + + int count = 0; + + for (const QModelIndex& idx : ui.list->selectionModel()->selectedRows()) { + + auto& saveGame = m_SaveGames[idx.row()]; + + if (count < 10) { + savesMsgLabel += + "
      • " + QFileInfo(saveGame->getFilepath()).completeBaseName() + "
      • "; + } + ++count; + + deleteFiles += saveGame->allFiles(); + } + + if (count > 10) { + savesMsgLabel += "
      • ... " + tr("%1 more").arg(count - 10) + "
      • "; + } + + if (QMessageBox::question( + m_window, tr("Confirm"), + tr("Are you sure you want to remove the following %n save(s)?
        " + "
          %1

        " + "Removed saves will be sent to the Recycle Bin.", + "", count) + .arg(savesMsgLabel), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + shellDelete(deleteFiles, true); // recycle bin delete. + refreshSaveList(); + } +} + +void SavesTab::onContextMenu(const QPoint& pos) +{ + QItemSelectionModel* selection = ui.list->selectionModel(); + + if (!selection->hasSelection()) { + return; + } + + QMenu menu; + + auto info = m_core.gameFeatures().gameFeature(); + if (info != nullptr) { + QAction* action = menu.addAction(tr("Fix enabled mods...")); + action->setEnabled(false); + if (selection->selectedRows().count() == 1) { + auto& save = m_SaveGames[selection->selectedRows()[0].row()]; + SaveGameInfo::MissingAssets missing = info->getMissingAssets(*save); + if (missing.size() != 0) { + connect(action, &QAction::triggered, this, [this, missing] { + fixMods(missing); + }); + action->setEnabled(true); + } + } + } + + QString deleteMenuLabel = + tr("Delete %n save(s)", "", selection->selectedRows().count()); + menu.addAction(deleteMenuLabel, [&] { + deleteSavegame(); + }); + + menu.addAction(tr("Open in Explorer..."), [&] { + openInExplorer(); + }); + + menu.exec(ui.list->viewport()->mapToGlobal(pos)); +} + +void SavesTab::fixMods(SaveGameInfo::MissingAssets const& missingAssets) +{ + ActivateModsDialog dialog(missingAssets, m_window); + if (dialog.exec() == QDialog::Accepted) { + // activate the required mods, then enable all esps + std::set modsToActivate = dialog.getModsToActivate(); + for (std::set::iterator iter = modsToActivate.begin(); + iter != modsToActivate.end(); ++iter) { + if ((*iter != "") && (*iter != "")) { + unsigned int modIndex = ModInfo::getIndex(*iter); + m_core.currentProfile()->setModEnabled(modIndex, true); + } + } + + m_core.currentProfile()->writeModlist(); + m_core.refreshLists(); + + std::set espsToActivate = dialog.getESPsToActivate(); + for (std::set::iterator iter = espsToActivate.begin(); + iter != espsToActivate.end(); ++iter) { + m_core.pluginList()->enableESP(*iter); + } + + m_core.saveCurrentLists(); + } +} + +void SavesTab::openInExplorer() +{ + auto info = m_core.gameFeatures().gameFeature(); + + const auto sel = ui.list->selectionModel()->selectedRows(); + if (sel.empty()) { + return; + } + + auto& saveGame = m_SaveGames[sel[0].row()]; + shell::Explore(saveGame->getFilepath()); +} diff --git a/src/src/savestab.h b/src/src/savestab.h new file mode 100644 index 0000000..aada32b --- /dev/null +++ b/src/src/savestab.h @@ -0,0 +1,66 @@ +#ifndef MODORGANIZER_SAVESTAB_INCLUDED +#define MODORGANIZER_SAVESTAB_INCLUDED + +#include "savegameinfo.h" +#include + +namespace Ui +{ +class MainWindow; +} + +namespace MOBase +{ +class ISaveGame; +class ISaveGameInfoWidget; +} // namespace MOBase + +class MainWindow; +class OrganizerCore; + +class SavesTab : public QObject +{ + Q_OBJECT; + +public: + SavesTab(QWidget* window, OrganizerCore& core, Ui::MainWindow* ui); + + void refreshSaveList(); + void displaySaveGameInfo(QTreeWidgetItem* newItem); + + QDir currentSavesDir() const; + + void startMonitorSaves(); + void stopMonitorSaves(); + void hideSaveGameInfo(); + +protected: + bool eventFilter(QObject* object, QEvent* e) override; + +private: + struct SavesTabUi + { + QTabWidget* mainTabs; + QWidget* tab; + QTreeWidget* list; + }; + + QWidget* m_window; + OrganizerCore& m_core; + SavesTabUi ui; + MOBase::FilterWidget m_filter; + std::vector> m_SaveGames; + MOBase::ISaveGameInfoWidget* m_CurrentSaveView; + + QTimer m_SavesWatcherTimer; + QFileSystemWatcher m_SavesWatcher; + + void onContextMenu(const QPoint& pos); + void deleteSavegame(); + void saveSelectionChanged(QTreeWidgetItem* newItem); + void fixMods(MOBase::SaveGameInfo::MissingAssets const& missingAssets); + void refreshSavesIfOpen(); + void openInExplorer(); +}; + +#endif // MODORGANIZER_SAVESTAB_INCLUDED diff --git a/src/src/savetextasdialog.cpp b/src/src/savetextasdialog.cpp new file mode 100644 index 0000000..d981980 --- /dev/null +++ b/src/src/savetextasdialog.cpp @@ -0,0 +1,49 @@ +#include "savetextasdialog.h" +#include "ui_savetextasdialog.h" +#include +#include +#include + +using MOBase::reportError; + +SaveTextAsDialog::SaveTextAsDialog(QWidget* parent) + : QDialog(parent), ui(new Ui::SaveTextAsDialog) +{ + ui->setupUi(this); +} + +SaveTextAsDialog::~SaveTextAsDialog() +{ + delete ui; +} + +void SaveTextAsDialog::setText(const QString& text) +{ + ui->textEdit->setPlainText(text); +} + +void SaveTextAsDialog::on_closeBtn_clicked() +{ + this->close(); +} + +void SaveTextAsDialog::on_clipboardBtn_clicked() +{ + QClipboard* clipboard = QApplication::clipboard(); + clipboard->setText(ui->textEdit->toPlainText()); +} + +void SaveTextAsDialog::on_saveAsBtn_clicked() +{ + QString fileName = QFileDialog::getSaveFileName(this, tr("Save CSV"), QString(), + tr("Text Files") + " (*.txt *.csv)"); + if (!fileName.isEmpty()) { + QFile file(fileName); + if (!file.open(QIODevice::WriteOnly)) { + reportError(tr("failed to open \"%1\" for writing").arg(fileName)); + return; + } + + file.write(ui->textEdit->toPlainText().toUtf8()); + } +} diff --git a/src/src/savetextasdialog.h b/src/src/savetextasdialog.h new file mode 100644 index 0000000..839722c --- /dev/null +++ b/src/src/savetextasdialog.h @@ -0,0 +1,32 @@ +#ifndef SAVETEXTASDIALOG_H +#define SAVETEXTASDIALOG_H + +#include + +namespace Ui +{ +class SaveTextAsDialog; +} + +class SaveTextAsDialog : public QDialog +{ + Q_OBJECT + +public: + explicit SaveTextAsDialog(QWidget* parent = 0); + ~SaveTextAsDialog(); + + void setText(const QString& text); + +private slots: + void on_closeBtn_clicked(); + + void on_clipboardBtn_clicked(); + + void on_saveAsBtn_clicked(); + +private: + Ui::SaveTextAsDialog* ui; +}; + +#endif // SAVETEXTASDIALOG_H diff --git a/src/src/savetextasdialog.ui b/src/src/savetextasdialog.ui new file mode 100644 index 0000000..e483431 --- /dev/null +++ b/src/src/savetextasdialog.ui @@ -0,0 +1,69 @@ + + + SaveTextAsDialog + + + + 0 + 0 + 631 + 578 + + + + Dialog + + + + + + QTextEdit::NoWrap + + + false + + + + + + + + + Copy To Clipboard + + + + + + + Save As... + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Close + + + + + + + + + + diff --git a/src/src/selectiondialog.cpp b/src/src/selectiondialog.cpp new file mode 100644 index 0000000..75c491f --- /dev/null +++ b/src/src/selectiondialog.cpp @@ -0,0 +1,116 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "selectiondialog.h" +#include "ui_selectiondialog.h" + +#include + +SelectionDialog::SelectionDialog(const QString& description, QWidget* parent, + const QSize& iconSize) + : QDialog(parent), ui(new Ui::SelectionDialog), m_Choice(nullptr), + m_ValidateByData(false), m_IconSize(iconSize) +{ + ui->setupUi(this); + + ui->descriptionLabel->setText(description); +} + +SelectionDialog::~SelectionDialog() +{ + delete ui; +} + +void SelectionDialog::addChoice(const QString& buttonText, const QString& description, + const QVariant& data) +{ + QAbstractButton* button = + new QCommandLinkButton(buttonText, description, ui->buttonBox); + if (m_IconSize.isValid()) { + button->setIconSize(m_IconSize); + } + button->setProperty("data", data); + ui->buttonBox->addButton(button, QDialogButtonBox::AcceptRole); + if (data.isValid()) + m_ValidateByData = true; +} + +void SelectionDialog::addChoice(const QIcon& icon, const QString& buttonText, + const QString& description, const QVariant& data) +{ + QAbstractButton* button = + new QCommandLinkButton(buttonText, description, ui->buttonBox); + if (m_IconSize.isValid()) { + button->setIconSize(m_IconSize); + } + button->setIcon(icon); + button->setProperty("data", data); + ui->buttonBox->addButton(button, QDialogButtonBox::AcceptRole); + if (data.isValid()) + m_ValidateByData = true; +} + +int SelectionDialog::numChoices() const +{ + return ui->buttonBox->findChildren(QString()).count(); +} + +QVariant SelectionDialog::getChoiceData() +{ + return m_Choice->property("data"); +} + +QString SelectionDialog::getChoiceString() +{ + if ((m_Choice == nullptr) || + (m_ValidateByData && !m_Choice->property("data").isValid())) { + return QString(); + } else { + return m_Choice->text(); + } +} + +QString SelectionDialog::getChoiceDescription() +{ + if (m_Choice == nullptr) + return QString(); + else + return m_Choice->accessibleDescription(); +} + +void SelectionDialog::disableCancel() +{ + ui->cancelButton->setEnabled(false); + ui->cancelButton->setHidden(true); +} + +void SelectionDialog::on_buttonBox_clicked(QAbstractButton* button) +{ + m_Choice = button; + if (!m_ValidateByData || m_Choice->property("data").isValid()) { + this->accept(); + } else { + this->reject(); + } +} + +void SelectionDialog::on_cancelButton_clicked() +{ + this->reject(); +} diff --git a/src/src/selectiondialog.h b/src/src/selectiondialog.h new file mode 100644 index 0000000..31e35e6 --- /dev/null +++ b/src/src/selectiondialog.h @@ -0,0 +1,77 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef SELECTIONDIALOG_H +#define SELECTIONDIALOG_H + +#include +#include + +namespace Ui +{ +class SelectionDialog; +} + +class SelectionDialog : public QDialog +{ + Q_OBJECT + +public: + explicit SelectionDialog(const QString& description, QWidget* parent = 0, + const QSize& iconSize = QSize()); + + ~SelectionDialog(); + + /** + * @brief add a choice to the dialog + * @param buttonText the text to be displayed on the button + * @param description the description that shows up under in small letters inside the + * button + * @param data data to be stored with the button. Please note that as soon as one + * choice has data associated with it (non-invalid QVariant) all buttons that contain + * no data will be treated as "cancel" buttons + */ + void addChoice(const QString& buttonText, const QString& description, + const QVariant& data); + + void addChoice(const QIcon& icon, const QString& buttonText, + const QString& description, const QVariant& data); + + int numChoices() const; + + QVariant getChoiceData(); + QString getChoiceString(); + QString getChoiceDescription(); + + void disableCancel(); + +private slots: + + void on_buttonBox_clicked(QAbstractButton* button); + + void on_cancelButton_clicked(); + +private: + Ui::SelectionDialog* ui; + QAbstractButton* m_Choice; + bool m_ValidateByData; + QSize m_IconSize; +}; + +#endif // SELECTIONDIALOG_H diff --git a/src/src/selectiondialog.ui b/src/src/selectiondialog.ui new file mode 100644 index 0000000..77cfa44 --- /dev/null +++ b/src/src/selectiondialog.ui @@ -0,0 +1,85 @@ + + + SelectionDialog + + + + 0 + 0 + 527 + 327 + + + + Select + + + + 20 + + + + + Placeholder + + + true + + + + + + + Qt::ScrollBarAlwaysOff + + + true + + + + + 0 + 0 + 503 + 219 + + + + + 0 + + + + + + 0 + 0 + + + + Qt::Vertical + + + QDialogButtonBox::NoButton + + + false + + + + + + + + + + + Cancel + + + + + + + + diff --git a/src/src/selfupdater.cpp b/src/src/selfupdater.cpp new file mode 100644 index 0000000..9d20421 --- /dev/null +++ b/src/src/selfupdater.cpp @@ -0,0 +1,351 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "selfupdater.h" + +#include "bbcode.h" +#include "downloadmanager.h" +#include "iplugingame.h" +#include "messagedialog.h" +#include "nexusinterface.h" +#include "nxmaccessmanager.h" +#include "organizercore.h" +#include "plugincontainer.h" +#include "settings.h" +#include "shared/util.h" +#include "updatedialog.h" +#include "utility.h" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#ifdef _WIN32 +#include //for VS_FIXEDFILEINFO, GetLastError +#endif + +#include +#include +#include //for size_t +#include + +using namespace MOBase; +using namespace MOShared; + +SelfUpdater::SelfUpdater(NexusInterface* nexusInterface) + : m_Parent(nullptr), m_MOVersion(createVersionInfo()), m_Interface(nexusInterface), + m_Reply(nullptr), m_Attempts(3) +{} + +SelfUpdater::~SelfUpdater() {} + +void SelfUpdater::setUserInterface(QWidget* widget) +{ + m_Parent = widget; +} + +void SelfUpdater::setPluginContainer(PluginContainer* pluginContainer) +{ + m_Interface->setPluginContainer(pluginContainer); +} + +void SelfUpdater::testForUpdate(const Settings& settings) +{ + if (settings.network().offlineMode()) { + log::debug("not checking for updates, in offline mode"); + return; + } + + if (!settings.checkForUpdates()) { + log::debug("not checking for updates, disabled"); + return; + } + + // TODO: if prereleases are disabled we could just request the latest release + // directly + try { + m_GitHub.releases( + GitHub::Repository("Modorganizer2", "modorganizer"), + [this](const QJsonArray& releases) { + if (releases.isEmpty()) { + // error message already logged + return; + } + + // We store all releases: + CandidatesMap mreleases; + for (const QJsonValue& releaseVal : releases) { + QJsonObject release = releaseVal.toObject(); + if (!release["draft"].toBool() && (Settings::instance().usePrereleases() || + !release["prerelease"].toBool())) { + auto version = Version::parse(release["tag_name"].toString(), + Version::ParseMode::MO2); + mreleases[version] = release; + } + } + + if (!mreleases.empty()) { + auto lastKey = mreleases.begin()->first; + if (lastKey > this->m_MOVersion) { + + // Fill m_UpdateCandidates with version strictly greater than the + // current version: + m_UpdateCandidates.clear(); + for (auto p : mreleases) { + if (p.first > this->m_MOVersion) { + m_UpdateCandidates.insert(p); + } + } + log::info("update available: {} -> {}", this->m_MOVersion, lastKey); + emit updateAvailable(); + } else if (lastKey < this->m_MOVersion) { + // this could happen if the user switches from using prereleases to + // stable builds. Should we downgrade? + log::debug("This version is newer than the latest released one: {} -> {}", + this->m_MOVersion, lastKey); + } + } + }); + } + // Catch all is bad by design, should be improved + catch (...) { + log::debug("Unable to connect to github.com to check version"); + } +} + +void SelfUpdater::startUpdate() +{ + // the button can't be pressed if there isn't an update candidate + Q_ASSERT(!m_UpdateCandidates.empty()); + + auto latestRelease = m_UpdateCandidates.begin()->second; + + UpdateDialog dialog(m_Parent); + dialog.setVersions(MOShared::createVersionInfo().string(), + latestRelease["tag_name"].toString()); + + // We concatenate release details. We only include pre-release if those are + // the latest release: + QString details; + bool includePreRelease = true; + for (auto& p : m_UpdateCandidates) { + auto& release = p.second; + + // Ignore details for pre-release after a release has been found: + if (release["prerelease"].toBool() && !includePreRelease) { + continue; + } + + // Stop including pre-release as soon as we find a non-prerelease: + if (!release["prerelease"].toBool()) { + includePreRelease = false; + } + + details += "\n## " + release["name"].toString() + "\n---\n"; + details += release["body"].toString(); + } + + // Need to call setDetailedText to create the QTextEdit and then be able to retrieve + // it: + dialog.setChangeLogs(details); + + int res = dialog.exec(); + + if (dialog.result() == QDialog::Accepted) { + bool found = false; + for (const QJsonValue& assetVal : latestRelease["assets"].toArray()) { + QJsonObject asset = assetVal.toObject(); + if (asset["content_type"].toString() == "application/x-msdownload") { + openOutputFile(asset["name"].toString()); + download(asset["browser_download_url"].toString()); + found = true; + break; + } + } + if (!found) { + QMessageBox::warning( + m_Parent, tr("Download failed"), + tr("Failed to find correct download, please try again later.")); + } + } +} + +void SelfUpdater::showProgress() +{ + if (m_Progress == nullptr) { + m_Progress = new QProgressDialog(m_Parent, Qt::Dialog); + connect(m_Progress, SIGNAL(canceled()), this, SLOT(downloadCancel())); + } + m_Progress->setModal(true); + m_Progress->show(); + m_Progress->setValue(0); + m_Progress->setWindowTitle(tr("Update")); + m_Progress->setLabelText(tr("Download in progress")); +} + +void SelfUpdater::closeProgress() +{ + if (m_Progress != nullptr) { + m_Progress->hide(); + m_Progress->deleteLater(); + m_Progress = nullptr; + } +} + +void SelfUpdater::openOutputFile(const QString& fileName) +{ + QString outputPath = + QDir::fromNativeSeparators(qApp->property("dataPath").toString()) + "/" + + fileName; + log::debug("downloading to {}", outputPath); + m_UpdateFile.setFileName(outputPath); + m_UpdateFile.open(QIODevice::WriteOnly); +} + +void SelfUpdater::download(const QString& downloadLink) +{ + QNetworkAccessManager* accessManager = m_Interface->getAccessManager(); + QUrl dlUrl(downloadLink); + QNetworkRequest request(dlUrl); + m_Canceled = false; + m_Reply = accessManager->get(request); + showProgress(); + + connect(m_Reply, SIGNAL(downloadProgress(qint64, qint64)), this, + SLOT(downloadProgress(qint64, qint64))); + connect(m_Reply, SIGNAL(finished()), this, SLOT(downloadFinished())); + connect(m_Reply, SIGNAL(readyRead()), this, SLOT(downloadReadyRead())); +} + +void SelfUpdater::downloadProgress(qint64 bytesReceived, qint64 bytesTotal) +{ + if (m_Reply != nullptr) { + if (m_Canceled) { + m_Reply->abort(); + } else { + if (bytesTotal != 0) { + if (m_Progress != nullptr) { + m_Progress->setValue((bytesReceived * 100) / bytesTotal); + } + } + } + } +} + +void SelfUpdater::downloadReadyRead() +{ + if (m_Reply != nullptr) { + m_UpdateFile.write(m_Reply->readAll()); + } +} + +void SelfUpdater::downloadFinished() +{ + int error = QNetworkReply::NoError; + + if (m_Reply != nullptr) { + if (m_Reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 302) { + QUrl url = + m_Reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl(); + m_UpdateFile.reset(); + download(url.toString()); + return; + } + m_UpdateFile.write(m_Reply->readAll()); + + error = m_Reply->error(); + + if (m_Reply->header(QNetworkRequest::ContentTypeHeader) + .toString() + .startsWith("text", Qt::CaseInsensitive)) { + m_Canceled = true; + } + + closeProgress(); + + m_Reply->close(); + m_Reply->deleteLater(); + m_Reply = nullptr; + } + + m_UpdateFile.close(); + + if ((m_UpdateFile.size() == 0) || (error != QNetworkReply::NoError) || m_Canceled) { + if (!m_Canceled) { + reportError(tr("Download failed: %1").arg(error)); + } + m_UpdateFile.remove(); + return; + } + + log::debug("download: {}", m_UpdateFile.fileName()); + + try { + installUpdate(); + } catch (const std::exception& e) { + reportError(tr("Failed to install update: %1").arg(e.what())); + } +} + +void SelfUpdater::downloadCancel() +{ + m_Canceled = true; +} + +void SelfUpdater::installUpdate() +{ + const QString parameters = "/DIR=\"" + qApp->applicationDirPath() + "\" "; + const auto r = shell::Execute(m_UpdateFile.fileName(), parameters); + + if (r.success()) { + QCoreApplication::quit(); + } else { + reportError( + tr("Failed to start %1: %2").arg(m_UpdateFile.fileName()).arg(r.toString())); + } + + m_UpdateFile.remove(); +} + +void SelfUpdater::report7ZipError(QString const& errorMessage) +{ + QMessageBox::critical(m_Parent, tr("Error"), errorMessage); +} diff --git a/src/src/selfupdater.h b/src/src/selfupdater.h new file mode 100644 index 0000000..cad48b8 --- /dev/null +++ b/src/src/selfupdater.h @@ -0,0 +1,154 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef SELFUPDATER_H +#define SELFUPDATER_H + +#include + +class Archive; +class NexusInterface; +class PluginContainer; +namespace MOBase +{ +class IPluginGame; +} + +#include +#include +#include +#include +#include //for qint64 + +class QNetworkReply; +class QProgressDialog; +class Settings; + +#include + +#include "github.h" + +/** + * @brief manages updates for Mod Organizer itself + * This class is used to update the Mod Organizer + * The process looks like this: + * 1. call testForUpdate() to determine is available + * 2. if the updateAvailable() signal is received, allow the user to start the update + * 3. if the user start the update, call startUpdate() + * 4. startUpdate() will first query a list of files, try to determine if there is an + * incremental update. If not, the user will have to confirm the download of a full + *download. Once the correct file is selected, it is downloaded. + * 5. before the downloaded file is extracted, existing files that are going to be + *replaced are moved to "update_backup" on because files that are currently open can't + *be replaced. + * 6. the update is extracted and then deleted + * 7. finally, a restart is requested via signal. + * 8. at restart, Mod Organizer will remove the update_backup directory since none of + *the files should now be open + * + * @todo use NexusBridge + **/ +class SelfUpdater : public QObject +{ + + Q_OBJECT + +public: + /** + * @brief constructor + * + * @param nexusInterface interface to query information from nexus + * @param parent parent widget + * @todo passing the nexus interface is unneccessary + **/ + explicit SelfUpdater(NexusInterface* nexusInterface); + + virtual ~SelfUpdater(); + + void setUserInterface(QWidget* widget); + + void setPluginContainer(PluginContainer* pluginContainer); + + /** + * @brief request information about the current version + **/ + void testForUpdate(const Settings& settings); + + /** + * @brief start the update process + * @note this should not be called if there is no update available + **/ + void startUpdate(); + + /** + * @return current version of Mod Organizer + **/ + MOBase::Version getVersion() const { return m_MOVersion; } + +signals: + + /** + * @brief emitted if a restart of the client is necessary to complete the update + **/ + void restart(); + + /** + * @brief emitted if an update is available + **/ + void updateAvailable(); + + /** + * @brief emitted if a message of the day was received + **/ + void motdAvailable(const QString& motd); + +private: + void openOutputFile(const QString& fileName); + void download(const QString& downloadLink); + void installUpdate(); + void report7ZipError(const QString& errorMessage); + void showProgress(); + void closeProgress(); + +private slots: + + void downloadProgress(qint64 bytesReceived, qint64 bytesTotal); + void downloadReadyRead(); + void downloadFinished(); + void downloadCancel(); + +private: + QWidget* m_Parent; + MOBase::Version m_MOVersion; + NexusInterface* m_Interface; + QFile m_UpdateFile; + QNetworkReply* m_Reply; + QProgressDialog* m_Progress{nullptr}; + bool m_Canceled; + int m_Attempts; + + GitHub m_GitHub; + + // Map from version to release, in decreasing order (first element is the latest + // release): + using CandidatesMap = std::map>; + CandidatesMap m_UpdateCandidates; +}; + +#endif // SELFUPDATER_H diff --git a/src/src/serverinfo.cpp b/src/src/serverinfo.cpp new file mode 100644 index 0000000..dfe8f36 --- /dev/null +++ b/src/src/serverinfo.cpp @@ -0,0 +1,169 @@ +#include "serverinfo.h" +#include "log.h" + +using namespace MOBase; + +const std::size_t MaxDownloadCount = 5; + +ServerInfo::ServerInfo() : ServerInfo({}, false, {}, 0, {}) {} + +ServerInfo::ServerInfo(QString name, bool premium, QDate last, int preferred, + SpeedList lastDownloads) + : m_name(std::move(name)), m_premium(premium), m_lastSeen(std::move(last)), + m_preferred(preferred), m_lastDownloads(std::move(lastDownloads)) +{ + if (m_lastDownloads.size() > MaxDownloadCount) { + m_lastDownloads.resize(MaxDownloadCount); + } +} + +const QString& ServerInfo::name() const +{ + return m_name; +} + +bool ServerInfo::isPremium() const +{ + return m_premium; +} + +void ServerInfo::setPremium(bool b) +{ + m_premium = b; +} + +const QDate& ServerInfo::lastSeen() const +{ + return m_lastSeen; +} + +void ServerInfo::updateLastSeen() +{ + m_lastSeen = QDate::currentDate(); +} + +int ServerInfo::preferred() const +{ + return m_preferred; +} + +void ServerInfo::setPreferred(int i) +{ + m_preferred = i; +} + +const ServerInfo::SpeedList& ServerInfo::lastDownloads() const +{ + return m_lastDownloads; +} + +int ServerInfo::averageSpeed() const +{ + int count = 0; + int total = 0; + + for (const auto& s : m_lastDownloads) { + if (s > 0) { + ++count; + total += s; + } + } + + if (count > 0) { + return static_cast(total) / count; + } + + return 0; +} + +void ServerInfo::addDownload(int bytesPerSecond) +{ + if (bytesPerSecond <= 0) { + log::error("trying to add download with {} B/s to server '{}'; ignoring", + bytesPerSecond, m_name); + + return; + } + + if (m_lastDownloads.size() == MaxDownloadCount) { + std::rotate(m_lastDownloads.begin(), m_lastDownloads.begin() + 1, + m_lastDownloads.end()); + + m_lastDownloads.back() = bytesPerSecond; + } else { + m_lastDownloads.push_back(bytesPerSecond); + } + + log::debug("added download at {} B/s to server '{}'", bytesPerSecond, m_name); +} + +void ServerList::add(ServerInfo s) +{ + m_servers.push_back(std::move(s)); + + std::sort(m_servers.begin(), m_servers.end(), [](auto&& a, auto&& b) { + return (a.preferred() < b.preferred()); + }); +} + +ServerList::iterator ServerList::begin() +{ + return m_servers.begin(); +} + +ServerList::const_iterator ServerList::begin() const +{ + return m_servers.begin(); +} + +ServerList::iterator ServerList::end() +{ + return m_servers.end(); +} + +ServerList::const_iterator ServerList::end() const +{ + return m_servers.end(); +} + +std::size_t ServerList::size() const +{ + return m_servers.size(); +} + +bool ServerList::empty() const +{ + return m_servers.empty(); +} + +ServerList::container ServerList::getPreferred() const +{ + container v; + + for (const auto& server : m_servers) { + if (server.preferred() > 0) { + v.push_back(server); + } + } + + return v; +} + +void ServerList::cleanup() +{ + QDate now = QDate::currentDate(); + + for (auto itor = m_servers.begin(); itor != m_servers.end();) { + const QDate lastSeen = itor->lastSeen(); + + if (lastSeen.daysTo(now) > 30) { + log::debug("removing server {} since it hasn't been available for downloads " + "in over a month", + itor->name()); + + itor = m_servers.erase(itor); + } else { + ++itor; + } + } +} diff --git a/src/src/serverinfo.h b/src/src/serverinfo.h new file mode 100644 index 0000000..df9b728 --- /dev/null +++ b/src/src/serverinfo.h @@ -0,0 +1,68 @@ +#ifndef SERVERINFO_H +#define SERVERINFO_H + +#include +#include +#include + +class ServerInfo +{ +public: + using SpeedList = std::vector; + + ServerInfo(); + ServerInfo(QString name, bool premium, QDate lastSeen, int preferred, + SpeedList lastDownloads); + + const QString& name() const; + + bool isPremium() const; + void setPremium(bool b); + + const QDate& lastSeen() const; + void updateLastSeen(); + + int preferred() const; + void setPreferred(int i); + + const SpeedList& lastDownloads() const; + int averageSpeed() const; + void addDownload(int bytesPerSecond); + +private: + QString m_name; + bool m_premium; + QDate m_lastSeen; + int m_preferred; + SpeedList m_lastDownloads; +}; + +Q_DECLARE_METATYPE(ServerInfo) + +class ServerList +{ +public: + using container = std::vector; + using iterator = container::iterator; + using const_iterator = container::const_iterator; + + void add(ServerInfo s); + + iterator begin(); + const_iterator begin() const; + iterator end(); + const_iterator end() const; + std::size_t size() const; + bool empty() const; + + container getPreferred() const; + + // removes servers that haven't been seen in a while + // + void cleanup(); + +private: + container m_servers; +}; + +#endif // SERVERINFO_H diff --git a/src/src/settings.cpp b/src/src/settings.cpp new file mode 100644 index 0000000..a2d10e4 --- /dev/null +++ b/src/src/settings.cpp @@ -0,0 +1,2584 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "settings.h" +#include "env.h" +#include "envmetrics.h" +#include "executableslist.h" +#include "instancemanager.h" +#include "modelutils.h" +#include "nxmhandler_linux.h" +#include "serverinfo.h" +#include "settingsutilities.h" +#include "shared/appconfig.h" +#include +#include +#include + +using namespace MOBase; +using namespace MOShared; + +namespace +{ +bool usesBaseDirVariable(const QString& path) +{ + return path.contains(PathSettings::BaseDirVariable, Qt::CaseInsensitive); +} + +QString loadStoredPath(const QString& path) +{ + QString value = QDir::fromNativeSeparators(path); + if (!usesBaseDirVariable(value)) { + value = MOBase::normalizePathForHost(value); + } + return value; +} + +QString storePathForIni(const QString& path) +{ + if (path.isEmpty()) { + return path; + } + + if (usesBaseDirVariable(path)) { + return QDir::fromNativeSeparators(path); + } + + return MOBase::normalizePathForWine(path); +} +} // namespace + +EndorsementState endorsementStateFromString(const QString& s) +{ + if (s == "Endorsed") { + return EndorsementState::Accepted; + } else if (s == "Abstained") { + return EndorsementState::Refused; + } else { + return EndorsementState::NoDecision; + } +} + +QString toString(EndorsementState s) +{ + switch (s) { + case EndorsementState::Accepted: + return "Endorsed"; + + case EndorsementState::Refused: + return "Abstained"; + + case EndorsementState::NoDecision: // fall-through + default: + return {}; + } +} + +Settings* Settings::s_Instance = nullptr; + +Settings::Settings(const QString& path, bool globalInstance) + : m_Settings(path, QSettings::IniFormat), m_Game(m_Settings), + m_Geometry(m_Settings), m_Widgets(m_Settings, globalInstance), + m_Colors(m_Settings), m_Plugins(m_Settings), m_Paths(m_Settings), + m_Network(m_Settings, globalInstance), m_Nexus(*this, m_Settings), + m_Steam(*this, m_Settings), m_Interface(m_Settings), m_Diagnostics(m_Settings) +{ + if (globalInstance) { + if (s_Instance != nullptr) { + throw std::runtime_error("second instance of \"Settings\" created"); + } else { + s_Instance = this; + } + } +} + +Settings::~Settings() +{ + if (s_Instance == this) { + MOBase::QuestionBoxMemory::setCallbacks({}, {}, {}); + s_Instance = nullptr; + } +} + +Settings& Settings::instance() +{ + if (s_Instance == nullptr) { + throw std::runtime_error("no instance of \"Settings\""); + } + + return *s_Instance; +} + +Settings* Settings::maybeInstance() +{ + return s_Instance; +} + +void Settings::processUpdates(const QVersionNumber& currentVersion, + const QVersionNumber& lastVersion) +{ + if (firstStart()) { + set(m_Settings, "General", "version", currentVersion.toString()); + return; + } + + if (currentVersion == lastVersion) { + return; + } + + log::info("updating from {} to {}", lastVersion.toString(), + currentVersion.toString()); + + auto version = [&](const QVersionNumber& v, auto&& f) { + if (lastVersion < v) { + log::debug("processing updates for {}", v.toString()); + f(); + } + }; + + version({2, 2, 0}, [&] { + remove(m_Settings, "Settings", "steam_password"); + remove(m_Settings, "Settings", "nexus_username"); + remove(m_Settings, "Settings", "nexus_password"); + remove(m_Settings, "Settings", "nexus_login"); + remove(m_Settings, "Settings", "nexus_api_key"); + remove(m_Settings, "Settings", "ask_for_nexuspw"); + remove(m_Settings, "Settings", "nmm_version"); + + removeSection(m_Settings, "Servers"); + }); + + version({2, 2, 1}, [&] { + remove(m_Settings, "General", "mod_info_tabs"); + remove(m_Settings, "General", "mod_info_conflict_expanders"); + remove(m_Settings, "General", "mod_info_conflicts"); + remove(m_Settings, "General", "mod_info_advanced_conflicts"); + remove(m_Settings, "General", "mod_info_conflicts_overwrite"); + remove(m_Settings, "General", "mod_info_conflicts_noconflict"); + remove(m_Settings, "General", "mod_info_conflicts_overwritten"); + }); + + version({2, 2, 2}, [&] { + // log splitter is gone, it's a dock now + remove(m_Settings, "General", "log_split"); + + // moved to widgets + remove(m_Settings, "General", "mod_info_conflicts_tab"); + remove(m_Settings, "General", "mod_info_conflicts_general_expanders"); + remove(m_Settings, "General", "mod_info_conflicts_general_overwrite"); + remove(m_Settings, "General", "mod_info_conflicts_general_noconflict"); + remove(m_Settings, "General", "mod_info_conflicts_general_overwritten"); + remove(m_Settings, "General", "mod_info_conflicts_advanced_list"); + remove(m_Settings, "General", "mod_info_conflicts_advanced_options"); + remove(m_Settings, "General", "mod_info_tab_order"); + remove(m_Settings, "General", "mod_info_dialog_images_show_dds"); + + // moved to geometry + remove(m_Settings, "General", "window_geometry"); + remove(m_Settings, "General", "window_state"); + remove(m_Settings, "General", "toolbar_size"); + remove(m_Settings, "General", "toolbar_button_style"); + remove(m_Settings, "General", "menubar_visible"); + remove(m_Settings, "General", "statusbar_visible"); + remove(m_Settings, "General", "window_split"); + remove(m_Settings, "General", "window_monitor"); + remove(m_Settings, "General", "browser_geometry"); + remove(m_Settings, "General", "filters_visible"); + + // this was supposed to have been removed above when updating from 2.2.0, + // but it wasn't in Settings, it was in General + remove(m_Settings, "General", "ask_for_nexuspw"); + + m_Network.updateFromOldMap(); + }); + + version({2, 4, 0}, [&] { + // removed + remove(m_Settings, "Settings", "hide_unchecked_plugins"); + remove(m_Settings, "Settings", "load_mechanism"); + }); + + // save version in all case + set(m_Settings, "General", "version", currentVersion.toString()); + + log::debug("updating done"); +} + +QString Settings::filename() const +{ + return m_Settings.fileName(); +} + +bool Settings::checkForUpdates() const +{ + return get(m_Settings, "Settings", "check_for_updates", true); +} + +void Settings::setCheckForUpdates(bool b) +{ + set(m_Settings, "Settings", "check_for_updates", b); +} + +bool Settings::usePrereleases() const +{ + return get(m_Settings, "Settings", "use_prereleases", false); +} + +void Settings::setUsePrereleases(bool b) +{ + set(m_Settings, "Settings", "use_prereleases", b); +} + +bool Settings::profileLocalInis() const +{ + return get(m_Settings, "Settings", "profile_local_inis", true); +} + +void Settings::setProfileLocalInis(bool b) +{ + set(m_Settings, "Settings", "profile_local_inis", b); +} + +bool Settings::profileLocalSaves() const +{ + return get(m_Settings, "Settings", "profile_local_saves", false); +} + +void Settings::setProfileLocalSaves(bool b) +{ + set(m_Settings, "Settings", "profile_local_saves", b); +} + +bool Settings::profileArchiveInvalidation() const +{ + return get(m_Settings, "Settings", "profile_archive_invalidation", false); +} + +void Settings::setProfileArchiveInvalidation(bool b) +{ + set(m_Settings, "Settings", "profile_archive_invalidation", b); +} + +bool Settings::useSplash() const +{ + return get(m_Settings, "Settings", "use_splash", true); +} + +void Settings::setUseSplash(bool b) +{ + set(m_Settings, "Settings", "use_splash", b); +} + +std::size_t Settings::refreshThreadCount() const +{ + return get(m_Settings, "Settings", "refresh_thread_count", 10); +} + +void Settings::setRefreshThreadCount(std::size_t n) const +{ + return set(m_Settings, "Settings", "refresh_thread_count", n); +} + +std::optional Settings::version() const +{ + if (auto v = getOptional(m_Settings, "General", "version")) { + return QVersionNumber::fromString(*v).normalized(); + } + + return {}; +} + +bool Settings::firstStart() const +{ + return get(m_Settings, "General", "first_start", true); +} + +void Settings::setFirstStart(bool b) +{ + set(m_Settings, "General", "first_start", b); +} + +QString Settings::executablesBlacklist() const +{ + static const QString def = (QStringList() << "Chrome.exe" + << "Firefox.exe" + << "TSVNCache.exe" + << "TGitCache.exe" + << "Steam.exe" + << "GameOverlayUI.exe" + << "Discord.exe" + << "GalaxyClient.exe" + << "Spotify.exe" + << "Brave.exe") + .join(";"); + + return get(m_Settings, "Settings", "executable_blacklist", def); +} + +bool Settings::isExecutableBlacklisted(const QString& s) const +{ + for (auto exec : executablesBlacklist().split(";")) { + if (exec.compare(s, Qt::CaseInsensitive) == 0) { + return true; + } + } + + return false; +} + +void Settings::setExecutablesBlacklist(const QString& s) +{ + set(m_Settings, "Settings", "executable_blacklist", s); +} + +QStringList Settings::skipFileSuffixes() const +{ + static const QStringList def = QStringList() << ".mohidden"; + + auto setting = get(m_Settings, "Settings", "skip_file_suffixes", def); + + return setting; +} + +void Settings::setSkipFileSuffixes(const QStringList& s) +{ + set(m_Settings, "Settings", "skip_file_suffixes", s); +} + +QStringList Settings::skipDirectories() const +{ + static const QStringList def = QStringList() << ".git"; + + auto setting = get(m_Settings, "Settings", "skip_directories", def); + + return setting; +} + +void Settings::setSkipDirectories(const QStringList& s) +{ + set(m_Settings, "Settings", "skip_directories", s); +} + +void Settings::setMotdHash(uint hash) +{ + set(m_Settings, "General", "motd_hash", hash); +} + +unsigned int Settings::motdHash() const +{ + return get(m_Settings, "General", "motd_hash", 0); +} + +bool Settings::archiveParsing() const +{ + return get(m_Settings, "Settings", "archive_parsing_experimental", false); +} + +void Settings::setArchiveParsing(bool b) +{ + set(m_Settings, "Settings", "archive_parsing_experimental", b); +} + +std::vector> Settings::executables() const +{ + ScopedReadArray sra(m_Settings, "customExecutables"); + std::vector> v; + + sra.for_each([&] { + std::map map; + + for (auto&& key : sra.keys()) { + map[key] = sra.get(key); + } + if (map.contains("binary")) { + map["binary"] = loadStoredPath(map["binary"].toString()); + } + if (map.contains("workingDirectory")) { + map["workingDirectory"] = + loadStoredPath(map["workingDirectory"].toString()); + } + + v.push_back(map); + }); + + return v; +} + +void Settings::setExecutables(const std::vector>& v) +{ + const auto current = executables(); + + if (current == v) { + // no change + return; + } + + if (current.size() > v.size()) { + // Qt can't remove array elements, the section must be cleared + removeSection(m_Settings, "customExecutables"); + } + + ScopedWriteArray swa(m_Settings, "customExecutables", v.size()); + + for (const auto& map : v) { + swa.next(); + + for (auto&& p : map) { + if ((p.first == "binary") || (p.first == "workingDirectory")) { + swa.set(p.first, storePathForIni(p.second.toString())); + } else { + swa.set(p.first, p.second); + } + } + } +} + +bool Settings::keepBackupOnInstall() const +{ + return get(m_Settings, "General", "backup_install", false); +} + +void Settings::setKeepBackupOnInstall(bool b) +{ + set(m_Settings, "General", "backup_install", b); +} + +GameSettings& Settings::game() +{ + return m_Game; +} + +const GameSettings& Settings::game() const +{ + return m_Game; +} + +GeometrySettings& Settings::geometry() +{ + return m_Geometry; +} + +const GeometrySettings& Settings::geometry() const +{ + return m_Geometry; +} + +WidgetSettings& Settings::widgets() +{ + return m_Widgets; +} + +const WidgetSettings& Settings::widgets() const +{ + return m_Widgets; +} + +ColorSettings& Settings::colors() +{ + return m_Colors; +} + +const ColorSettings& Settings::colors() const +{ + return m_Colors; +} + +PluginSettings& Settings::plugins() +{ + return m_Plugins; +} + +const PluginSettings& Settings::plugins() const +{ + return m_Plugins; +} + +PathSettings& Settings::paths() +{ + return m_Paths; +} + +const PathSettings& Settings::paths() const +{ + return m_Paths; +} + +NetworkSettings& Settings::network() +{ + return m_Network; +} + +const NetworkSettings& Settings::network() const +{ + return m_Network; +} + +NexusSettings& Settings::nexus() +{ + return m_Nexus; +} + +const NexusSettings& Settings::nexus() const +{ + return m_Nexus; +} + +SteamSettings& Settings::steam() +{ + return m_Steam; +} + +const SteamSettings& Settings::steam() const +{ + return m_Steam; +} + +InterfaceSettings& Settings::interface() +{ + return m_Interface; +} + +const InterfaceSettings& Settings::interface() const +{ + return m_Interface; +} + +DiagnosticsSettings& Settings::diagnostics() +{ + return m_Diagnostics; +} + +const DiagnosticsSettings& Settings::diagnostics() const +{ + return m_Diagnostics; +} + +QSettings::Status Settings::sync() const +{ + m_Settings.sync(); + + const auto s = m_Settings.status(); + + // there's a bug in Qt at least until 5.15.0 where a utf-8 bom in the ini is + // handled correctly but still sets FormatError + // + // see qsettings.cpp, in QConfFileSettingsPrivate::readIniFile(), there's a + // specific check for utf-8, which adjusts `dataPos` so it's skipped, but + // the FLUSH_CURRENT_SECTION() macro uses `currentSectionStart`, and that one + // isn't adjusted when changing `dataPos` on the first line and so stays 0 + // + // this puts the bom in `unparsedIniSections` and eventually sets FormatError + // somewhere + // + // + // the other problem is that the status is never reset, not even when calling + // sync(), so the FormatError that's returned here is actually from reading + // the ini, not writing it + // + // + // since it's impossible to get a FormatError on write, it's considered to + // be a NoError here + + if (s == QSettings::FormatError) { + return QSettings::NoError; + } else { + return s; + } +} + +QSettings::Status Settings::iniStatus() const +{ + return m_Settings.status(); +} + +void Settings::dump() const +{ + static const QStringList ignore({"username", "password", "nexus_api_key", + "nexus_username", "nexus_password", + "steam_username"}); + + log::debug("settings:"); + + { + ScopedGroup sg(m_Settings, "Settings"); + + for (auto k : m_Settings.allKeys()) { + if (ignore.contains(k, Qt::CaseInsensitive)) { + continue; + } + + log::debug(" . {}={}", k, m_Settings.value(k).toString()); + } + } + + m_Network.dump(); + m_Nexus.dump(); +} + +void Settings::managedGameChanged(IPluginGame const* gamePlugin) +{ + m_Game.setPlugin(gamePlugin); +} + +GameSettings::GameSettings(QSettings& settings) + : m_Settings(settings), m_GamePlugin(nullptr) +{} + +const MOBase::IPluginGame* GameSettings::plugin() +{ + return m_GamePlugin; +} + +void GameSettings::setPlugin(const MOBase::IPluginGame* gamePlugin) +{ + m_GamePlugin = gamePlugin; +} + +bool GameSettings::forceEnableCoreFiles() const +{ + return get(m_Settings, "Settings", "force_enable_core_files", true); +} + +void GameSettings::setForceEnableCoreFiles(bool b) +{ + set(m_Settings, "Settings", "force_enable_core_files", b); +} + +std::optional GameSettings::directory() const +{ + if (auto v = getOptional(m_Settings, "General", "gamePath")) { + return loadStoredPath(QString::fromUtf8(*v)); + } + + return {}; +} + +void GameSettings::setDirectory(const QString& path) +{ + set(m_Settings, "General", "gamePath", storePathForIni(path).toUtf8()); +} + +std::optional GameSettings::name() const +{ + return getOptional(m_Settings, "General", "gameName"); +} + +void GameSettings::setName(const QString& name) +{ + set(m_Settings, "General", "gameName", name); +} + +std::optional GameSettings::edition() const +{ + return getOptional(m_Settings, "General", "game_edition"); +} + +void GameSettings::setEdition(const QString& name) +{ + set(m_Settings, "General", "game_edition", name); +} + +std::optional GameSettings::selectedProfileName() const +{ + if (auto v = getOptional(m_Settings, "General", "selected_profile")) { + return QString::fromUtf8(*v); + } + + return {}; +} + +void GameSettings::setSelectedProfileName(const QString& name) +{ + set(m_Settings, "General", "selected_profile", name.toUtf8()); +} + +GeometrySettings::GeometrySettings(QSettings& s) : m_Settings(s), m_Reset(false) {} + +void GeometrySettings::requestReset() +{ + m_Reset = true; +} + +void GeometrySettings::resetIfNeeded() +{ + if (!m_Reset) { + return; + } + + removeSection(m_Settings, "Geometry"); +} + +void GeometrySettings::saveGeometry(const QMainWindow* w) +{ + saveWindowGeometry(w); +} + +bool GeometrySettings::restoreGeometry(QMainWindow* w) const +{ + return restoreWindowGeometry(w); +} + +void GeometrySettings::saveGeometry(const QDialog* d) +{ + saveWindowGeometry(d); +} + +bool GeometrySettings::restoreGeometry(QDialog* d) const +{ + const auto r = restoreWindowGeometry(d); + + if (centerDialogs()) { + centerOnParent(d); + } + + return r; +} + +void GeometrySettings::saveWindowGeometry(const QWidget* w) +{ + set(m_Settings, "Geometry", geoSettingName(w), w->saveGeometry()); +} + +bool GeometrySettings::restoreWindowGeometry(QWidget* w) const +{ + if (auto v = getOptional(m_Settings, "Geometry", geoSettingName(w))) { + w->restoreGeometry(*v); + ensureWindowOnScreen(w); + return true; + } + + return false; +} + +void GeometrySettings::ensureWindowOnScreen(QWidget* w) const +{ + // users report that the main window and/or dialogs are displayed off-screen; + // the usual workaround is keyboard navigation to move it + // + // qt should have code that deals with multiple monitors and off-screen + // geometries, but there seems to be bugs or inconsistencies that can't be + // reproduced + // + // the closest would probably be https://bugreports.qt.io/browse/QTBUG-64498, + // which is about multiple monitors and high dpi, but it seems fixed as of + // 5.12.4, which is shipped with 2.2.1 + // + // without being to reproduce the problem, some simple checks are made in a + // timer, which may mitigate the issues + + QTimer::singleShot(100, w, [w] { + const auto borders = 20; + + // desktop geometry, made smaller to make sure there isn't just a few pixels + const auto originalDg = env::Environment().metrics().desktopGeometry(); + const auto dg = originalDg.adjusted(borders, borders, -borders, -borders); + + const auto g = w->geometry(); + + if (!dg.intersects(g)) { + log::warn("window '{}' is offscreen, moving to main monitor; geo={}, desktop={}", + w->objectName(), g, originalDg); + + // widget is off-screen, center it on main monitor + centerOnMonitor(w, -1); + + log::warn("window '{}' now at {}", w->objectName(), w->geometry()); + } + }); +} + +void GeometrySettings::saveState(const QMainWindow* w) +{ + set(m_Settings, "Geometry", stateSettingName(w), w->saveState()); +} + +bool GeometrySettings::restoreState(QMainWindow* w) const +{ + if (auto v = getOptional(m_Settings, "Geometry", stateSettingName(w))) { + w->restoreState(*v); + return true; + } + + return false; +} + +void GeometrySettings::saveState(const QHeaderView* w) +{ + set(m_Settings, "Geometry", stateSettingName(w), w->saveState()); +} + +bool GeometrySettings::restoreState(QHeaderView* w) const +{ + if (auto v = getOptional(m_Settings, "Geometry", stateSettingName(w))) { + w->restoreState(*v); + return true; + } + + return false; +} + +void GeometrySettings::saveState(const QSplitter* w) +{ + set(m_Settings, "Geometry", stateSettingName(w), w->saveState()); +} + +bool GeometrySettings::restoreState(QSplitter* w) const +{ + if (auto v = getOptional(m_Settings, "Geometry", stateSettingName(w))) { + w->restoreState(*v); + return true; + } + + return false; +} + +void GeometrySettings::saveState(const ExpanderWidget* expander) +{ + set(m_Settings, "Geometry", stateSettingName(expander), expander->saveState()); +} + +bool GeometrySettings::restoreState(ExpanderWidget* expander) const +{ + if (auto v = + getOptional(m_Settings, "Geometry", stateSettingName(expander))) { + expander->restoreState(*v); + return true; + } + + return false; +} + +void GeometrySettings::saveVisibility(const QWidget* w) +{ + set(m_Settings, "Geometry", visibilitySettingName(w), w->isVisible()); +} + +bool GeometrySettings::restoreVisibility(QWidget* w, std::optional def) const +{ + if (auto v = + getOptional(m_Settings, "Geometry", visibilitySettingName(w), def)) { + w->setVisible(*v); + return true; + } + + return false; +} + +void GeometrySettings::restoreToolbars(QMainWindow* w) const +{ + // all toolbars have the same size and button style settings + const auto size = getOptional(m_Settings, "Geometry", "toolbar_size"); + const auto style = getOptional(m_Settings, "Geometry", "toolbar_button_style"); + + for (auto* tb : w->findChildren()) { + if (size) { + tb->setIconSize(*size); + } + + if (style) { + tb->setToolButtonStyle(static_cast(*style)); + } + + restoreVisibility(tb); + } +} + +void GeometrySettings::saveToolbars(const QMainWindow* w) +{ + const auto tbs = w->findChildren(); + + // save visibility for all + for (auto* tb : tbs) { + saveVisibility(tb); + } + + // all toolbars have the same size and button style settings, just save the + // first one + if (!tbs.isEmpty()) { + const auto* tb = tbs[0]; + + set(m_Settings, "Geometry", "toolbar_size", tb->iconSize()); + set(m_Settings, "Geometry", "toolbar_button_style", + static_cast(tb->toolButtonStyle())); + } +} + +QStringList GeometrySettings::modInfoTabOrder() const +{ + QStringList v; + + if (m_Settings.contains("mod_info_tabs")) { + // old byte array from 2.2.0 + QDataStream stream(m_Settings.value("mod_info_tabs").toByteArray()); + + int count = 0; + stream >> count; + + for (int i = 0; i < count; ++i) { + QString s; + stream >> s; + v.push_back(s); + } + } else { + // string list since 2.2.1 + QString string = get(m_Settings, "Widgets", "ModInfoTabOrder", ""); + QTextStream stream(&string); + + while (!stream.atEnd()) { + QString s; + stream >> s; + v.push_back(s); + } + } + + return v; +} + +void GeometrySettings::setModInfoTabOrder(const QString& names) +{ + set(m_Settings, "Widgets", "ModInfoTabOrder", names); +} + +bool GeometrySettings::centerDialogs() const +{ + return get(m_Settings, "Settings", "center_dialogs", false); +} + +void GeometrySettings::setCenterDialogs(bool b) +{ + set(m_Settings, "Settings", "center_dialogs", b); +} + +void GeometrySettings::centerOnMainWindowMonitor(QWidget* w) const +{ + const auto monitor = + getOptional(m_Settings, "Geometry", "MainWindow_monitor").value_or(-1); + + centerOnMonitor(w, monitor); +} + +void GeometrySettings::centerOnMonitor(QWidget* w, int monitor) +{ + QPoint center; + + if (monitor >= 0 && monitor < QGuiApplication::screens().size()) { + center = QGuiApplication::screens().at(monitor)->geometry().center(); + } else { + center = QGuiApplication::primaryScreen()->geometry().center(); + } + + w->move(center - w->rect().center()); +} + +void GeometrySettings::centerOnParent(QWidget* w, QWidget* parent) +{ + if (!parent) { + parent = w->parentWidget(); + + if (!parent) { + parent = qApp->activeWindow(); + } + } + + if (parent && parent->isVisible()) { + const auto pr = parent->geometry(); + w->move(pr.center() - w->rect().center()); + } +} + +void GeometrySettings::saveMainWindowMonitor(const QMainWindow* w) +{ + if (auto* handle = w->windowHandle()) { + if (auto* screen = handle->screen()) { + const int screenId = QGuiApplication::screens().indexOf(screen); + set(m_Settings, "Geometry", "MainWindow_monitor", screenId); + } + } +} + +Qt::Orientation dockOrientation(const QMainWindow* mw, const QDockWidget* d) +{ + // docks in these areas are horizontal + const auto horizontalAreas = Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea; + + if (mw->dockWidgetArea(const_cast(d)) & horizontalAreas) { + return Qt::Horizontal; + } else { + return Qt::Vertical; + } +} + +void GeometrySettings::saveDocks(const QMainWindow* mw) +{ + // this attempts to fix https://bugreports.qt.io/browse/QTBUG-46620 where dock + // sizes are not restored when the main window is maximized; it is used in + // MainWindow::readSettings() and MainWindow::storeSettings() + // + // there's also https://stackoverflow.com/questions/44005852, which has what + // seems to be a popular fix, but it breaks the restored size of the window + // by setting it to the desktop's resolution, so that doesn't work + // + // the only fix I could find is to remember the sizes of the docks and manually + // setting them back; saving is straightforward, but restoring is messy + // + // this also depends on the window being visible before the timer in restore() + // is fired and the timer must be processed by application.exec(); therefore, + // the splash screen _must_ be closed before readSettings() is called, because + // it has its own event loop, which seems to interfere with this + // + // all of this should become unnecessary when QTBUG-46620 is fixed + // + + // saves the size of each dock + for (const auto* dock : mw->findChildren()) { + int size = 0; + + // save the width for horizontal docks, or the height for vertical + if (dockOrientation(mw, dock) == Qt::Horizontal) { + size = dock->size().width(); + } else { + size = dock->size().height(); + } + + set(m_Settings, "Geometry", dockSettingName(dock), size); + } +} + +void GeometrySettings::restoreDocks(QMainWindow* mw) const +{ + struct DockInfo + { + QDockWidget* d; + int size = 0; + Qt::Orientation ori; + }; + + std::vector dockInfos; + + // for each dock + for (auto* dock : mw->findChildren()) { + if (auto size = getOptional(m_Settings, "Geometry", dockSettingName(dock))) { + // remember this dock, its size and orientation + dockInfos.push_back({dock, *size, dockOrientation(mw, dock)}); + } + } + + // the main window must have had time to process the settings from + // readSettings() or it seems to override whatever is set here + // + // some people said a single processEvents() call is enough, but it doesn't + // look like it + QTimer::singleShot(5, [=] { + for (const auto& info : dockInfos) { + mw->resizeDocks({info.d}, {info.size}, info.ori); + } + }); +} + +WidgetSettings::WidgetSettings(QSettings& s, bool globalInstance) : m_Settings(s) +{ + if (globalInstance) { + MOBase::QuestionBoxMemory::setCallbacks( + [this](auto&& w, auto&& f) { + return questionButton(w, f); + }, + [this](auto&& w, auto&& b) { + setQuestionWindowButton(w, b); + }, + [this](auto&& w, auto&& f, auto&& b) { + setQuestionFileButton(w, f, b); + }); + } +} + +void WidgetSettings::saveTreeCheckState(const QTreeView* tv, int role) +{ + QVariantList data; + for (auto index : flatIndex(tv->model())) { + data.append(index.data(role)); + } + set(m_Settings, "Widgets", indexSettingName(tv), data); +} + +void WidgetSettings::restoreTreeCheckState(QTreeView* tv, int role) const +{ + if (auto states = + getOptional(m_Settings, "Widgets", indexSettingName(tv))) { + auto allIndex = flatIndex(tv->model()); + MOBase::log::debug("restoreTreeCheckState: {}, {}", states->size(), + allIndex.size()); + if (states->size() != allIndex.size()) { + return; + } + for (int i = 0; i < states->size(); ++i) { + tv->model()->setData(allIndex[i], states->at(i), role); + } + } +} + +void WidgetSettings::saveTreeExpandState(const QTreeView* tv, int role) +{ + QVariantList expanded; + for (auto index : flatIndex(tv->model())) { + if (tv->isExpanded(index)) { + expanded.append(index.data(role)); + } + } + set(m_Settings, "Widgets", indexSettingName(tv), expanded); +} + +void WidgetSettings::restoreTreeExpandState(QTreeView* tv, int role) const +{ + if (auto expanded = + getOptional(m_Settings, "Widgets", indexSettingName(tv))) { + tv->collapseAll(); + for (auto index : flatIndex(tv->model())) { + if (expanded->contains(index.data(role))) { + tv->expand(index); + } + } + } +} + +std::optional WidgetSettings::index(const QComboBox* cb) const +{ + return getOptional(m_Settings, "Widgets", indexSettingName(cb)); +} + +void WidgetSettings::saveIndex(const QComboBox* cb) +{ + set(m_Settings, "Widgets", indexSettingName(cb), cb->currentIndex()); +} + +void WidgetSettings::restoreIndex(QComboBox* cb, std::optional def) const +{ + if (auto v = getOptional(m_Settings, "Widgets", indexSettingName(cb), def)) { + cb->setCurrentIndex(*v); + } +} + +std::optional WidgetSettings::index(const QTabWidget* w) const +{ + return getOptional(m_Settings, "Widgets", indexSettingName(w)); +} + +void WidgetSettings::saveIndex(const QTabWidget* w) +{ + set(m_Settings, "Widgets", indexSettingName(w), w->currentIndex()); +} + +void WidgetSettings::restoreIndex(QTabWidget* w, std::optional def) const +{ + if (auto v = getOptional(m_Settings, "Widgets", indexSettingName(w), def)) { + w->setCurrentIndex(*v); + } +} + +std::optional WidgetSettings::checked(const QAbstractButton* w) const +{ + warnIfNotCheckable(w); + return getOptional(m_Settings, "Widgets", checkedSettingName(w)); +} + +void WidgetSettings::saveChecked(const QAbstractButton* w) +{ + warnIfNotCheckable(w); + set(m_Settings, "Widgets", checkedSettingName(w), w->isChecked()); +} + +void WidgetSettings::restoreChecked(QAbstractButton* w, std::optional def) const +{ + warnIfNotCheckable(w); + + if (auto v = getOptional(m_Settings, "Widgets", checkedSettingName(w), def)) { + w->setChecked(*v); + } +} + +QuestionBoxMemory::Button WidgetSettings::questionButton(const QString& windowName, + const QString& filename) const +{ + const QString sectionName("DialogChoices"); + + if (!filename.isEmpty()) { + const auto fileSetting = windowName + "/" + filename; + if (auto v = getOptional(m_Settings, sectionName, fileSetting)) { + return static_cast(*v); + } + } + + if (auto v = getOptional(m_Settings, sectionName, windowName)) { + return static_cast(*v); + } + + return QuestionBoxMemory::NoButton; +} + +void WidgetSettings::setQuestionWindowButton(const QString& windowName, + QuestionBoxMemory::Button button) +{ + const QString sectionName("DialogChoices"); + + if (button == QuestionBoxMemory::NoButton) { + remove(m_Settings, sectionName, windowName); + } else { + set(m_Settings, sectionName, windowName, button); + } +} + +void WidgetSettings::setQuestionFileButton(const QString& windowName, + const QString& filename, + QuestionBoxMemory::Button button) +{ + const QString sectionName("DialogChoices"); + const QString settingName(windowName + "/" + filename); + + if (button == QuestionBoxMemory::NoButton) { + remove(m_Settings, sectionName, settingName); + } else { + set(m_Settings, sectionName, settingName, button); + } +} + +void WidgetSettings::resetQuestionButtons() +{ + removeSection(m_Settings, "DialogChoices"); +} + +ColorSettings::ColorSettings(QSettings& s) : m_Settings(s) {} + +QColor ColorSettings::modlistOverwrittenLoose() const +{ + return get(m_Settings, "Settings", "overwrittenLooseFilesColor", + QColor(0, 255, 0, 64)); +} + +void ColorSettings::setModlistOverwrittenLoose(const QColor& c) +{ + set(m_Settings, "Settings", "overwrittenLooseFilesColor", c); +} + +QColor ColorSettings::modlistOverwritingLoose() const +{ + return get(m_Settings, "Settings", "overwritingLooseFilesColor", + QColor(255, 0, 0, 64)); +} + +void ColorSettings::setModlistOverwritingLoose(const QColor& c) +{ + set(m_Settings, "Settings", "overwritingLooseFilesColor", c); +} + +QColor ColorSettings::modlistOverwrittenArchive() const +{ + return get(m_Settings, "Settings", "overwrittenArchiveFilesColor", + QColor(0, 255, 255, 64)); +} + +void ColorSettings::setModlistOverwrittenArchive(const QColor& c) +{ + set(m_Settings, "Settings", "overwrittenArchiveFilesColor", c); +} + +QColor ColorSettings::modlistOverwritingArchive() const +{ + return get(m_Settings, "Settings", "overwritingArchiveFilesColor", + QColor(255, 0, 255, 64)); +} + +void ColorSettings::setModlistOverwritingArchive(const QColor& c) +{ + set(m_Settings, "Settings", "overwritingArchiveFilesColor", c); +} + +QColor ColorSettings::modlistContainsFile() const +{ + return get(m_Settings, "Settings", "containsFileColor", + QColor(0, 0, 255, 64)); +} + +void ColorSettings::setModlistContainsFile(const QColor& c) +{ + set(m_Settings, "Settings", "containsFileColor", c); +} + +QColor ColorSettings::pluginListContained() const +{ + return get(m_Settings, "Settings", "containedColor", QColor(0, 0, 255, 64)); +} + +void ColorSettings::setPluginListContained(const QColor& c) +{ + set(m_Settings, "Settings", "containedColor", c); +} + +QColor ColorSettings::pluginListMaster() const +{ + return get(m_Settings, "Settings", "masterColor", QColor(255, 255, 0, 64)); +} + +void ColorSettings::setPluginListMaster(const QColor& c) +{ + set(m_Settings, "Settings", "masterColor", c); +} + +std::optional ColorSettings::previousSeparatorColor() const +{ + const auto c = getOptional(m_Settings, "General", "previousSeparatorColor"); + if (c && c->isValid()) { + return c; + } + + return {}; +} + +void ColorSettings::setPreviousSeparatorColor(const QColor& c) const +{ + set(m_Settings, "General", "previousSeparatorColor", c); +} + +void ColorSettings::removePreviousSeparatorColor() +{ + remove(m_Settings, "General", "previousSeparatorColor"); +} + +bool ColorSettings::colorSeparatorScrollbar() const +{ + return get(m_Settings, "Settings", "colorSeparatorScrollbars", true); +} + +void ColorSettings::setColorSeparatorScrollbar(bool b) +{ + set(m_Settings, "Settings", "colorSeparatorScrollbars", b); +} + +QColor ColorSettings::idealTextColor(const QColor& rBackgroundColor) +{ + if (rBackgroundColor.alpha() < 50) + return QColor(Qt::black); + + // "inverse' of luminance of the background + int iLuminance = (rBackgroundColor.red() * 0.299) + + (rBackgroundColor.green() * 0.587) + + (rBackgroundColor.blue() * 0.114); + return QColor(iLuminance >= 128 ? Qt::black : Qt::white); +} + +PluginSettings::PluginSettings(QSettings& settings) : m_Settings(settings) {} + +void PluginSettings::clearPlugins() +{ + m_Plugins.clear(); + m_PluginSettings.clear(); + m_PluginBlacklist.clear(); + + m_PluginBlacklist = readBlacklist(); +} + +void PluginSettings::registerPlugin(IPlugin* plugin) +{ + m_Plugins.push_back(plugin); + m_PluginSettings.insert(plugin->name(), QVariantMap()); + m_PluginDescriptions.insert(plugin->name(), QVariantMap()); + + for (const PluginSetting& setting : plugin->settings()) { + const QString settingName = plugin->name() + "/" + setting.key; + + QVariant temp = get(m_Settings, "Plugins", settingName, QVariant()); + + // No previous enabled? Skip. + if (setting.key == "enabled" && (!temp.isValid() || !temp.canConvert())) { + continue; + } + + if (!temp.isValid()) { + temp = setting.defaultValue; + } else if (!temp.convert(setting.defaultValue.type())) { + log::warn("failed to interpret \"{}\" as correct type for \"{}\" in plugin " + "\"{}\", using default", + temp.toString(), setting.key, plugin->name()); + + temp = setting.defaultValue; + } + + m_PluginSettings[plugin->name()][setting.key] = temp; + + m_PluginDescriptions[plugin->name()][setting.key] = + QString("%1 (default: %2)") + .arg(setting.description) + .arg(setting.defaultValue.toString()); + } + + // Handle previous "enabled" settings: + if (m_PluginSettings[plugin->name()].contains("enabled")) { + setPersistent(plugin->name(), "enabled", + m_PluginSettings[plugin->name()]["enabled"].toBool(), true); + m_PluginSettings[plugin->name()].remove("enabled"); + m_PluginDescriptions[plugin->name()].remove("enabled"); + + // We need to drop it manually in Settings since it is not possible to remove plugin + // settings: + remove(m_Settings, "Plugins", plugin->name() + "/enabled"); + } +} + +void PluginSettings::unregisterPlugin(IPlugin* plugin) +{ + auto it = std::find(m_Plugins.begin(), m_Plugins.end(), plugin); + if (it != m_Plugins.end()) { + m_Plugins.erase(it); + } + m_PluginSettings.remove(plugin->name()); + m_PluginDescriptions.remove(plugin->name()); +} + +std::vector PluginSettings::plugins() const +{ + return m_Plugins; +} + +QVariant PluginSettings::setting(const QString& pluginName, const QString& key) const +{ + auto iterPlugin = m_PluginSettings.find(pluginName); + if (iterPlugin == m_PluginSettings.end()) { + return QVariant(); + } + + auto iterSetting = iterPlugin->find(key); + if (iterSetting == iterPlugin->end()) { + return QVariant(); + } + + return *iterSetting; +} + +void PluginSettings::setSetting(const QString& pluginName, const QString& key, + const QVariant& value) +{ + auto iterPlugin = m_PluginSettings.find(pluginName); + + if (iterPlugin == m_PluginSettings.end()) { + throw MyException(QObject::tr("attempt to store setting for unknown plugin \"%1\"") + .arg(pluginName)); + } + + QVariant oldValue = m_PluginSettings[pluginName][key]; + + // store the new setting both in memory and in the ini + m_PluginSettings[pluginName][key] = value; + set(m_Settings, "Plugins", pluginName + "/" + key, value); + + // emit signal: + emit pluginSettingChanged(pluginName, key, oldValue, value); +} + +QVariantMap PluginSettings::settings(const QString& pluginName) const +{ + return m_PluginSettings[pluginName]; +} + +void PluginSettings::setSettings(const QString& pluginName, const QVariantMap& map) +{ + auto iterPlugin = m_PluginSettings.find(pluginName); + + if (iterPlugin == m_PluginSettings.end()) { + throw MyException(QObject::tr("attempt to store setting for unknown plugin \"%1\"") + .arg(pluginName)); + } + + QVariantMap oldSettings = m_PluginSettings[pluginName]; + m_PluginSettings[pluginName] = map; + + // Emit signals for settings that have been changed or added: + for (auto& k : map.keys()) { + // .value() return a default-constructed QVariant if k is not in oldSettings: + QVariant oldValue = oldSettings.value(k); + if (oldValue != map[k]) { + emit pluginSettingChanged(pluginName, k, oldSettings.value(k), map[k]); + } + } + + // Emit signals for settings that have been removed: + for (auto& k : oldSettings.keys()) { + if (!map.contains(k)) { + emit pluginSettingChanged(pluginName, k, oldSettings[k], QVariant()); + } + } +} + +QVariantMap PluginSettings::descriptions(const QString& pluginName) const +{ + return m_PluginDescriptions[pluginName]; +} + +void PluginSettings::setDescriptions(const QString& pluginName, const QVariantMap& map) +{ + m_PluginDescriptions[pluginName] = map; +} + +QVariant PluginSettings::persistent(const QString& pluginName, const QString& key, + const QVariant& def) const +{ + if (!m_PluginSettings.contains(pluginName)) { + return def; + } + + return get(m_Settings, "PluginPersistance", pluginName + "/" + key, def); +} + +void PluginSettings::setPersistent(const QString& pluginName, const QString& key, + const QVariant& value, bool sync) +{ + if (!m_PluginSettings.contains(pluginName)) { + throw MyException(QObject::tr("attempt to store setting for unknown plugin \"%1\"") + .arg(pluginName)); + } + + set(m_Settings, "PluginPersistance", pluginName + "/" + key, value); + + if (sync) { + m_Settings.sync(); + } +} + +void PluginSettings::addBlacklist(const QString& fileName) +{ + m_PluginBlacklist.insert(fileName); + writeBlacklist(); +} + +bool PluginSettings::blacklisted(const QString& fileName) const +{ + return m_PluginBlacklist.contains(fileName); +} + +void PluginSettings::setBlacklist(const QStringList& pluginNames) +{ + m_PluginBlacklist.clear(); + + for (const auto& name : pluginNames) { + m_PluginBlacklist.insert(name); + } +} + +const QSet& PluginSettings::blacklist() const +{ + return m_PluginBlacklist; +} + +void PluginSettings::save() +{ + for (auto iterPlugins = m_PluginSettings.begin(); + iterPlugins != m_PluginSettings.end(); ++iterPlugins) { + for (auto iterSettings = iterPlugins->begin(); iterSettings != iterPlugins->end(); + ++iterSettings) { + const auto key = iterPlugins.key() + "/" + iterSettings.key(); + set(m_Settings, "Plugins", key, iterSettings.value()); + } + } + + writeBlacklist(); +} + +void PluginSettings::writeBlacklist() +{ + const auto current = readBlacklist(); + + if (current.size() > m_PluginBlacklist.size()) { + // Qt can't remove array elements, the section must be cleared + removeSection(m_Settings, "pluginBlacklist"); + } + + ScopedWriteArray swa(m_Settings, "pluginBlacklist", m_PluginBlacklist.size()); + + for (const QString& plugin : m_PluginBlacklist) { + swa.next(); + swa.set("name", plugin); + } +} + +QSet PluginSettings::readBlacklist() const +{ + QSet set; + + ScopedReadArray sra(m_Settings, "pluginBlacklist"); + sra.for_each([&] { + set.insert(sra.get("name")); + }); + + return set; +} + +const QString PathSettings::BaseDirVariable = "%BASE_DIR%"; + +PathSettings::PathSettings(QSettings& settings) : m_Settings(settings) {} + +std::map PathSettings::recent() const +{ + std::map map; + + ScopedReadArray sra(m_Settings, "recentDirectories"); + + sra.for_each([&] { + const QVariant name = sra.get("name"); + const QVariant dir = sra.get("directory"); + + if (name.isValid() && dir.isValid()) { + map.emplace(name.toString(), loadStoredPath(dir.toString())); + } + }); + + return map; +} + +void PathSettings::setRecent(const std::map& map) +{ + const auto current = recent(); + + if (current.size() > map.size()) { + // Qt can't remove array elements, the section must be cleared + removeSection(m_Settings, "recentDirectories"); + } + + ScopedWriteArray swa(m_Settings, "recentDirectories", map.size()); + + for (auto&& p : map) { + swa.next(); + + swa.set("name", p.first); + swa.set("directory", storePathForIni(p.second)); + } +} + +QString PathSettings::getConfigurablePath(const QString& key, const QString& def, + bool resolve) const +{ + QString result = + loadStoredPath(get(m_Settings, "Settings", key, makeDefaultPath(def))); + + if (resolve) { + result = PathSettings::resolve(result, base()); + } + + return result; +} + +void PathSettings::setConfigurablePath(const QString& key, const QString& path) +{ + if (path.isEmpty()) { + remove(m_Settings, "Settings", key); + } else { + set(m_Settings, "Settings", key, storePathForIni(path)); + } +} + +QString PathSettings::resolve(const QString& path, const QString& baseDir) +{ + QString s = path; + s.replace(BaseDirVariable, baseDir); + return s; +} + +QString PathSettings::makeDefaultPath(const QString dirName) +{ + return BaseDirVariable + "/" + dirName; +} + +QString PathSettings::base() const +{ + const QString dataPath = QFileInfo(m_Settings.fileName()).dir().path(); + + return loadStoredPath(get(m_Settings, "Settings", "base_directory", + dataPath)); +} + +QString PathSettings::downloads(bool resolve) const +{ + return getConfigurablePath("download_directory", ToQString(AppConfig::downloadPath()), + resolve); +} + +QString PathSettings::cache(bool resolve) const +{ + return getConfigurablePath("cache_directory", ToQString(AppConfig::cachePath()), + resolve); +} + +QString PathSettings::mods(bool resolve) const +{ + return getConfigurablePath("mod_directory", ToQString(AppConfig::modsPath()), + resolve); +} + +QString PathSettings::profiles(bool resolve) const +{ + return getConfigurablePath("profiles_directory", ToQString(AppConfig::profilesPath()), + resolve); +} + +QString PathSettings::overwrite(bool resolve) const +{ + return getConfigurablePath("overwrite_directory", + ToQString(AppConfig::overwritePath()), resolve); +} + +void PathSettings::setBase(const QString& path) +{ + if (path.isEmpty()) { + remove(m_Settings, "Settings", "base_directory"); + } else { + set(m_Settings, "Settings", "base_directory", storePathForIni(path)); + } +} + +void PathSettings::setDownloads(const QString& path) +{ + setConfigurablePath("download_directory", path); +} + +void PathSettings::setMods(const QString& path) +{ + setConfigurablePath("mod_directory", path); +} + +void PathSettings::setCache(const QString& path) +{ + setConfigurablePath("cache_directory", path); +} + +void PathSettings::setProfiles(const QString& path) +{ + setConfigurablePath("profiles_directory", path); +} + +void PathSettings::setOverwrite(const QString& path) +{ + setConfigurablePath("overwrite_directory", path); +} + +NetworkSettings::NetworkSettings(QSettings& settings, bool globalInstance) + : m_Settings(settings) +{ + if (globalInstance) { + updateCustomBrowser(); + } +} + +void NetworkSettings::updateCustomBrowser() +{ + if (useCustomBrowser()) { + MOBase::shell::SetUrlHandler(customBrowserCommand()); + } else { + MOBase::shell::SetUrlHandler(""); + } +} + +bool NetworkSettings::offlineMode() const +{ + return get(m_Settings, "Settings", "offline_mode", false); +} + +void NetworkSettings::setOfflineMode(bool b) +{ + set(m_Settings, "Settings", "offline_mode", b); +} + +bool NetworkSettings::useProxy() const +{ + return get(m_Settings, "Settings", "use_proxy", false); +} + +void NetworkSettings::setUseProxy(bool b) +{ + set(m_Settings, "Settings", "use_proxy", b); +} + +void NetworkSettings::setDownloadSpeed(const QString& name, int bytesPerSecond) +{ + auto current = servers(); + + for (auto& server : current) { + if (server.name() == name) { + server.addDownload(bytesPerSecond); + updateServers(current); + return; + } + } + + log::error("server '{}' not found while trying to add a download with bps {}", name, + bytesPerSecond); +} + +ServerList NetworkSettings::servers() const +{ + ServerList list; + + { + ScopedReadArray sra(m_Settings, "Servers"); + + sra.for_each([&] { + ServerInfo::SpeedList lastDownloads; + + const auto lastDownloadsString = sra.get("lastDownloads", ""); + + for (const auto& s : lastDownloadsString.split(" ")) { + const auto bytesPerSecond = s.toInt(); + if (bytesPerSecond > 0) { + lastDownloads.push_back(bytesPerSecond); + } + } + + ServerInfo server( + sra.get("name", ""), sra.get("premium", false), + QDate::fromString(sra.get("lastSeen", ""), Qt::ISODate), + sra.get("preferred", 0), lastDownloads); + + list.add(std::move(server)); + }); + } + + return list; +} + +void NetworkSettings::updateServers(ServerList newServers) +{ + // clean up unavailable servers + newServers.cleanup(); + + const auto current = servers(); + + if (current.size() > newServers.size()) { + // Qt can't remove array elements, the section must be cleared + removeSection(m_Settings, "Servers"); + } + + ScopedWriteArray swa(m_Settings, "Servers", newServers.size()); + + for (const auto& server : newServers) { + swa.next(); + + swa.set("name", server.name()); + swa.set("premium", server.isPremium()); + swa.set("lastSeen", server.lastSeen().toString(Qt::ISODate)); + swa.set("preferred", server.preferred()); + + QString lastDownloads; + for (const auto& speed : server.lastDownloads()) { + if (speed > 0) { + lastDownloads += QString("%1 ").arg(speed); + } + } + + swa.set("lastDownloads", lastDownloads.trimmed()); + } +} + +void NetworkSettings::updateFromOldMap() +{ + // servers used to be a map of byte arrays until 2.2.1, it's now an array of + // individual values instead + // + // so post 2.2.1, only one key is returned: "size", the size of the arrays; + // in 2.2.1, one key per server is returned + + // sanity check that this is really 2.2.1 + { + const QStringList keys = ScopedGroup(m_Settings, "Servers").keys(); + + for (auto&& k : keys) { + if (k == "size") { + // this looks like an array, so the upgrade was probably already done + return; + } + } + } + + const auto servers = serversFromOldMap(); + removeSection(m_Settings, "Servers"); + updateServers(servers); +} + +bool NetworkSettings::useCustomBrowser() const +{ + return get(m_Settings, "Settings", "use_custom_browser", false); +} + +void NetworkSettings::setUseCustomBrowser(bool b) +{ + set(m_Settings, "Settings", "use_custom_browser", b); + updateCustomBrowser(); +} + +QString NetworkSettings::customBrowserCommand() const +{ + return get(m_Settings, "Settings", "custom_browser", ""); +} + +void NetworkSettings::setCustomBrowserCommand(const QString& s) +{ + set(m_Settings, "Settings", "custom_browser", s); + updateCustomBrowser(); +} + +ServerList NetworkSettings::serversFromOldMap() const +{ + // for 2.2.1 and before + + ServerList list; + const ScopedGroup sg(m_Settings, "Servers"); + + sg.for_each([&](auto&& serverKey) { + QVariantMap data = sg.get(serverKey); + + ServerInfo server(serverKey, data["premium"].toBool(), data["lastSeen"].toDate(), + data["preferred"].toInt(), {}); + + // ignoring download count and speed, it's now a list of values instead of + // a total + + list.add(std::move(server)); + }); + + return list; +} + +void NetworkSettings::dump() const +{ + log::debug("servers:"); + + for (const auto& server : servers()) { + QString lastDownloads; + for (auto speed : server.lastDownloads()) { + lastDownloads += QString("%1 ").arg(speed); + } + + log::debug(" . {} premium={} lastSeen={} preferred={} lastDownloads={}", + server.name(), server.isPremium() ? "yes" : "no", + server.lastSeen().toString(Qt::ISODate), server.preferred(), + lastDownloads.trimmed()); + } +} + +NexusSettings::NexusSettings(Settings& parent, QSettings& settings) + : m_Parent(parent), m_Settings(settings) +{} + +bool NexusSettings::endorsementIntegration() const +{ + return get(m_Settings, "Settings", "endorsement_integration", true); +} + +void NexusSettings::setEndorsementIntegration(bool b) const +{ + set(m_Settings, "Settings", "endorsement_integration", b); +} + +EndorsementState NexusSettings::endorsementState() const +{ + return endorsementStateFromString( + get(m_Settings, "General", "endorse_state", "")); +} + +void NexusSettings::setEndorsementState(EndorsementState s) +{ + const auto v = toString(s); + + if (v.isEmpty()) { + remove(m_Settings, "General", "endorse_state"); + } else { + set(m_Settings, "General", "endorse_state", v); + } +} + +bool NexusSettings::trackedIntegration() const +{ + return get(m_Settings, "Settings", "tracked_integration", true); +} + +void NexusSettings::setTrackedIntegration(bool b) const +{ + set(m_Settings, "Settings", "tracked_integration", b); +} + +bool NexusSettings::categoryMappings() const +{ + return get(m_Settings, "Settings", "category_mappings", true); +} + +void NexusSettings::setCategoryMappings(bool b) const +{ + set(m_Settings, "Settings", "category_mappings", b); +} + +void NexusSettings::registerAsNXMHandler(bool force) +{ +#ifndef _WIN32 + Q_UNUSED(force); + NxmHandlerLinux handler; + handler.registerHandler(); +#else + const auto nxmPath = QCoreApplication::applicationDirPath() + "/" + + QString::fromStdWString(AppConfig::nxmHandlerExe()); + + const auto executable = QCoreApplication::applicationFilePath(); + + QString mode = force ? "forcereg" : "reg"; + QString parameters = mode + " " + m_Parent.game().plugin()->gameShortName(); + for (const QString& altGame : m_Parent.game().plugin()->validShortNames()) { + parameters += "," + altGame; + } + parameters += " \"" + executable + "\""; + + const auto r = shell::Execute(nxmPath, parameters); + + if (!r.success()) { + QMessageBox::critical( + nullptr, QObject::tr("Failed"), + QObject::tr("Failed to start the helper application: %1").arg(r.toString())); + } +#endif +} + +std::vector NexusSettings::validationTimeouts() const +{ + using namespace std::chrono_literals; + + const auto s = get(m_Settings, "Settings", "validation_timeouts", ""); + + const auto numbers = s.split(" "); + std::vector v; + + for (auto ns : numbers) { + ns = ns.trimmed(); + if (ns.isEmpty()) + continue; + + bool ok = false; + const auto n = ns.toInt(&ok); + + if (!ok || n < 0 || n > 100) { + log::error("bad validation_timeouts number '{}'", ns); + continue; + } + + v.push_back(std::chrono::seconds(n)); + } + + if (v.empty()) + v = {10s, 15s, 20s}; + + return v; +} + +void NexusSettings::dump() const +{ + const auto iniPath = InstanceManager::singleton().globalInstancesRootPath() + "/" + + QString::fromStdWString(AppConfig::nxmHandlerIni()); + + if (!QFileInfo(iniPath).exists()) { + log::debug("nxm ini not found at {}", iniPath); + return; + } + + QSettings s(iniPath, QSettings::IniFormat); + if (const auto st = s.status(); st != QSettings::NoError) { + log::debug("can't read nxm ini from {}", iniPath); + return; + } + + log::debug("nxmhandler settings:"); + + QSettings handler("HKEY_CURRENT_USER\\Software\\Classes\\nxm\\", + QSettings::NativeFormat); + log::debug(" . primary: {}", handler.value("shell/open/command/Default").toString()); + + const auto noregister = getOptional(s, "General", "noregister"); + + if (noregister) { + log::debug(" . noregister: {}", *noregister); + } else { + log::debug(" . noregister: (not found)"); + } + + ScopedReadArray sra(s, "handlers"); + + sra.for_each([&] { + const auto games = sra.get("games"); + const auto executable = sra.get("executable"); + const auto arguments = sra.get("arguments"); + + log::debug(" . handler:"); + log::debug(" . games: {}", games.toString()); + log::debug(" . executable: {}", executable.toString()); + log::debug(" . arguments: {}", arguments.toString()); + }); +} + +SteamSettings::SteamSettings(Settings& parent, QSettings& settings) + : m_Parent(parent), m_Settings(settings) +{} + +QString SteamSettings::appID() const +{ + return get(m_Settings, "Settings", "app_id", + m_Parent.game().plugin()->steamAPPId()); +} + +void SteamSettings::setAppID(const QString& id) +{ + if (id.isEmpty()) { + remove(m_Settings, "Settings", "app_id"); + } else { + set(m_Settings, "Settings", "app_id", id); + } +} + +bool SteamSettings::login(QString& username, QString& password) const +{ + username = get(m_Settings, "Settings", "steam_username", ""); + password = getWindowsCredential("steam_password"); + + return !username.isEmpty() && !password.isEmpty(); +} + +void SteamSettings::setLogin(QString username, QString password) +{ + if (username == "") { + remove(m_Settings, "Settings", "steam_username"); + password = ""; + } else { + set(m_Settings, "Settings", "steam_username", username); + } + + if (!setWindowsCredential("steam_password", password)) { + const auto e = GetLastError(); + log::error("Storing or deleting password failed: {}", formatSystemMessage(e)); + } +} + +InterfaceSettings::InterfaceSettings(QSettings& settings) : m_Settings(settings) {} + +bool InterfaceSettings::lockGUI() const +{ + return get(m_Settings, "Settings", "lock_gui", true); +} + +void InterfaceSettings::setLockGUI(bool b) +{ + set(m_Settings, "Settings", "lock_gui", b); +} + +std::optional InterfaceSettings::styleName() const +{ + return getOptional(m_Settings, "Settings", "style"); +} + +void InterfaceSettings::setStyleName(const QString& name) +{ + set(m_Settings, "Settings", "style", name); +} + +bool InterfaceSettings::collapsibleSeparators(Qt::SortOrder order) const +{ + return get(m_Settings, "Settings", + order == Qt::AscendingOrder ? "collapsible_separators_asc" + : "collapsible_separators_dsc", + true); +} + +void InterfaceSettings::setCollapsibleSeparators(bool ascending, bool descending) +{ + set(m_Settings, "Settings", "collapsible_separators_asc", ascending); + set(m_Settings, "Settings", "collapsible_separators_dsc", descending); +} + +bool InterfaceSettings::collapsibleSeparatorsHighlightTo() const +{ + return get(m_Settings, "Settings", "collapsible_separators_conflicts_to", true); +} + +void InterfaceSettings::setCollapsibleSeparatorsHighlightTo(bool b) +{ + set(m_Settings, "Settings", "collapsible_separators_conflicts_to", b); +} + +bool InterfaceSettings::collapsibleSeparatorsHighlightFrom() const +{ + return get(m_Settings, "Settings", "collapsible_separators_conflicts_from", + true); +} + +void InterfaceSettings::setCollapsibleSeparatorsHighlightFrom(bool b) +{ + set(m_Settings, "Settings", "collapsible_separators_conflicts_from", b); +} + +bool InterfaceSettings::collapsibleSeparatorsIcons(int column) const +{ + return get(m_Settings, "Settings", + QString("collapsible_separators_icons_%1").arg(column), true); +} + +void InterfaceSettings::setCollapsibleSeparatorsIcons(int column, bool show) +{ + set(m_Settings, "Settings", QString("collapsible_separators_icons_%1").arg(column), + show); +} + +bool InterfaceSettings::collapsibleSeparatorsPerProfile() const +{ + return get(m_Settings, "Settings", "collapsible_separators_per_profile", false); +} + +void InterfaceSettings::setCollapsibleSeparatorsPerProfile(bool b) +{ + set(m_Settings, "Settings", "collapsible_separators_per_profile", b); +} + +bool InterfaceSettings::saveFilters() const +{ + return get(m_Settings, "Settings", "save_filters", false); +} + +void InterfaceSettings::setSaveFilters(bool b) +{ + set(m_Settings, "Settings", "save_filters", b); +} + +bool InterfaceSettings::autoCollapseOnHover() const +{ + return get(m_Settings, "Settings", "auto_collapse_on_hover", false); +} + +void InterfaceSettings::setAutoCollapseOnHover(bool b) +{ + set(m_Settings, "Settings", "auto_collapse_on_hover", b); +} + +bool InterfaceSettings::checkUpdateAfterInstallation() const +{ + return get(m_Settings, "Settings", "autocheck_update_install", true); +} + +void InterfaceSettings::setCheckUpdateAfterInstallation(bool b) +{ + set(m_Settings, "Settings", "autocheck_update_install", b); +} + +bool InterfaceSettings::compactDownloads() const +{ + return get(m_Settings, "Settings", "compact_downloads", false); +} + +void InterfaceSettings::setCompactDownloads(bool b) +{ + set(m_Settings, "Settings", "compact_downloads", b); +} + +bool InterfaceSettings::metaDownloads() const +{ + return get(m_Settings, "Settings", "meta_downloads", false); +} + +void InterfaceSettings::setMetaDownloads(bool b) +{ + set(m_Settings, "Settings", "meta_downloads", b); +} + +bool InterfaceSettings::hideDownloadsAfterInstallation() const +{ + return get(m_Settings, "Settings", "autohide_downloads", false); +} + +void InterfaceSettings::setHideDownloadsAfterInstallation(bool b) +{ + set(m_Settings, "Settings", "autohide_downloads", b); +} + +bool InterfaceSettings::hideAPICounter() const +{ + return get(m_Settings, "Settings", "hide_api_counter", false); +} + +void InterfaceSettings::setHideAPICounter(bool b) +{ + set(m_Settings, "Settings", "hide_api_counter", b); +} + +bool InterfaceSettings::displayForeign() const +{ + return get(m_Settings, "Settings", "display_foreign", true); +} + +void InterfaceSettings::setDisplayForeign(bool b) +{ + set(m_Settings, "Settings", "display_foreign", b); +} + +QString InterfaceSettings::language() +{ + QString result = get(m_Settings, "Settings", "language", ""); + + if (result.isEmpty()) { + QStringList languagePreferences = QLocale::system().uiLanguages(); + + if (languagePreferences.length() > 0) { + // the users most favoritest language + result = languagePreferences.at(0); + } else { + // fallback system locale + result = QLocale::system().name(); + } + } + + return result; +} + +void InterfaceSettings::setLanguage(const QString& name) +{ + set(m_Settings, "Settings", "language", name); +} + +bool InterfaceSettings::isTutorialCompleted(const QString& windowName) const +{ + return get(m_Settings, "CompletedWindowTutorials", windowName, false); +} + +void InterfaceSettings::setTutorialCompleted(const QString& windowName, bool b) +{ + set(m_Settings, "CompletedWindowTutorials", windowName, b); +} + +bool InterfaceSettings::showChangeGameConfirmation() const +{ + return get(m_Settings, "Settings", "show_change_game_confirmation", true); +} + +void InterfaceSettings::setShowChangeGameConfirmation(bool b) +{ + set(m_Settings, "Settings", "show_change_game_confirmation", b); +} + +bool InterfaceSettings::showMenubarOnAlt() const +{ + return get(m_Settings, "Settings", "show_menubar_on_alt", true); +} + +void InterfaceSettings::setShowMenubarOnAlt(bool b) +{ + set(m_Settings, "Settings", "show_menubar_on_alt", b); +} + +bool InterfaceSettings::doubleClicksOpenPreviews() const +{ + return get(m_Settings, "Settings", "double_click_previews", true); +} + +void InterfaceSettings::setDoubleClicksOpenPreviews(bool b) +{ + set(m_Settings, "Settings", "double_click_previews", b); +} + +FilterWidget::Options InterfaceSettings::filterOptions() const +{ + FilterWidget::Options o; + + o.useRegex = get(m_Settings, "Settings", "filter_regex", false); + o.regexCaseSensitive = + get(m_Settings, "Settings", "regex_case_sensitive", false); + o.regexExtended = get(m_Settings, "Settings", "regex_extended", false); + o.scrollToSelection = + get(m_Settings, "Settings", "filter_scroll_to_selection", false); + + return o; +} + +void InterfaceSettings::setFilterOptions(const FilterWidget::Options& o) +{ + set(m_Settings, "Settings", "filter_regex", o.useRegex); + set(m_Settings, "Settings", "regex_case_sensitive", o.regexCaseSensitive); + set(m_Settings, "Settings", "regex_extended", o.regexExtended); + set(m_Settings, "Settings", "filter_scroll_to_selection", o.scrollToSelection); +} + +DiagnosticsSettings::DiagnosticsSettings(QSettings& settings) : m_Settings(settings) {} + +log::Levels DiagnosticsSettings::logLevel() const +{ + return get(m_Settings, "Settings", "log_level", log::Levels::Info); +} + +void DiagnosticsSettings::setLogLevel(log::Levels level) +{ + set(m_Settings, "Settings", "log_level", level); +} + +lootcli::LogLevels DiagnosticsSettings::lootLogLevel() const +{ + return get(m_Settings, "Settings", "loot_log_level", + lootcli::LogLevels::Info); +} + +void DiagnosticsSettings::setLootLogLevel(lootcli::LogLevels level) +{ + set(m_Settings, "Settings", "loot_log_level", level); +} + +env::CoreDumpTypes DiagnosticsSettings::coreDumpType() const +{ + return get(m_Settings, "Settings", "crash_dumps_type", + env::CoreDumpTypes::Mini); +} + +void DiagnosticsSettings::setCoreDumpType(env::CoreDumpTypes type) +{ + set(m_Settings, "Settings", "crash_dumps_type", type); +} + +int DiagnosticsSettings::maxCoreDumps() const +{ + return get(m_Settings, "Settings", "crash_dumps_max", 5); +} + +void DiagnosticsSettings::setMaxCoreDumps(int n) +{ + set(m_Settings, "Settings", "crash_dumps_max", n); +} + +std::chrono::seconds DiagnosticsSettings::spawnDelay() const +{ + return std::chrono::seconds(get(m_Settings, "Settings", "spawn_delay", 0)); +} + +void DiagnosticsSettings::setSpawnDelay(std::chrono::seconds t) +{ + set(m_Settings, "Settings", "spawn_delay", t.count()); +} + +void GlobalSettings::updateRegistryKey() +{ + const QString OldOrganization = "Tannin"; + const QString OldApplication = "Mod Organizer"; + const QString OldInstanceValue = "CurrentInstance"; + + const QString OldRootKey = "Software\\" + OldOrganization; + + if (env::registryValueExists(OldRootKey + "\\" + OldApplication, OldInstanceValue)) { + QSettings old(OldOrganization, OldApplication); + setCurrentInstance(old.value(OldInstanceValue).toString()); + old.remove(OldInstanceValue); + } + + env::deleteRegistryKeyIfEmpty(OldRootKey); +} + +QString GlobalSettings::currentInstance() +{ + return settings().value("CurrentInstance", "").toString(); +} + +void GlobalSettings::setCurrentInstance(const QString& s) +{ + settings().setValue("CurrentInstance", s); +} + +QSettings GlobalSettings::settings() +{ + const QString Organization = "Mod Organizer Team"; + const QString Application = "Mod Organizer"; + + return QSettings(Organization, Application); +} + +bool GlobalSettings::hideCreateInstanceIntro() +{ + return settings().value("HideCreateInstanceIntro", false).toBool(); +} + +void GlobalSettings::setHideCreateInstanceIntro(bool b) +{ + settings().setValue("HideCreateInstanceIntro", b); +} + +bool GlobalSettings::hideTutorialQuestion() +{ + return settings().value("HideTutorialQuestion", false).toBool(); +} + +void GlobalSettings::setHideTutorialQuestion(bool b) +{ + settings().setValue("HideTutorialQuestion", b); +} + +bool GlobalSettings::hideCategoryReminder() +{ + return settings().value("HideCategoryReminder", false).toBool(); +} + +void GlobalSettings::setHideCategoryReminder(bool b) +{ + settings().setValue("HideCategoryReminder", b); +} + +bool GlobalSettings::hideAssignCategoriesQuestion() +{ + return settings().value("HideAssignCategoriesQuestion", false).toBool(); +} + +void GlobalSettings::setHideAssignCategoriesQuestion(bool b) +{ + settings().setValue("HideAssignCategoriesQuestion", b); +} + +bool GlobalSettings::nexusApiKey(QString& apiKey) +{ + QString tempKey = getWindowsCredential("APIKEY"); + if (tempKey.isEmpty()) + return false; + + apiKey = tempKey; + return true; +} + +bool GlobalSettings::setNexusApiKey(const QString& apiKey) +{ + if (!setWindowsCredential("APIKEY", apiKey)) { + const auto e = GetLastError(); + log::error("Storing API key failed: {}", formatSystemMessage(e)); + return false; + } + + return true; +} + +bool GlobalSettings::clearNexusApiKey() +{ + return setNexusApiKey(""); +} + +bool GlobalSettings::hasNexusApiKey() +{ + return !getWindowsCredential("APIKEY").isEmpty(); +} + +void GlobalSettings::resetDialogs() +{ + setHideCreateInstanceIntro(false); + setHideTutorialQuestion(false); +} diff --git a/src/src/settings.h b/src/src/settings.h new file mode 100644 index 0000000..7b2516f --- /dev/null +++ b/src/src/settings.h @@ -0,0 +1,992 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef SETTINGS_H +#define SETTINGS_H + +#include "envdump.h" +#include +#include +#include +#include +#ifdef _WIN32 +#include +#endif + +#ifdef interface +#undef interface +#endif + +namespace MOBase +{ +class IPlugin; +class IPluginGame; +class ExpanderWidget; +} // namespace MOBase + +class QSplitter; + +class ServerList; +class Settings; + +// setting for the currently managed game +// +class GameSettings +{ +public: + GameSettings(QSettings& setting); + + // game plugin + // + const MOBase::IPluginGame* plugin(); + void setPlugin(const MOBase::IPluginGame* gamePlugin); + + // whether files of the core game are forced-enabled so the user can't + // accidentally disable them + // + bool forceEnableCoreFiles() const; + void setForceEnableCoreFiles(bool b); + + // the directory where the managed game is stored + // + std::optional directory() const; + void setDirectory(const QString& path); + + // the name of the managed game + // + std::optional name() const; + void setName(const QString& name); + + // the edition of the managed game + // + std::optional edition() const; + void setEdition(const QString& name); + + // the current profile name + // + std::optional selectedProfileName() const; + void setSelectedProfileName(const QString& name); + +private: + QSettings& m_Settings; + const MOBase::IPluginGame* m_GamePlugin; +}; + +// geometry settings for various widgets; this should contain any setting that +// can get invalid through UI changes or when users change display settings +// (resolution, monitors, etc.); see WidgetSettings for the counterpart +// +// all these settings are stored under [Geometry] and get wiped when the +// "reset geometry settings" button is clicked in the settings +// +// saveGeometry(), restoreGeometry(), saveState() and restoreState() call the +// same functions on the given widget +// +class GeometrySettings +{ +public: + GeometrySettings(QSettings& s); + + // asks the settings to get reset + // + // this gets called from the settings dialog and gets picked up in + // resetIfNeeded(), called from runApplication() just before exiting + // + void requestReset(); + void resetIfNeeded(); + + void saveGeometry(const QMainWindow* w); + bool restoreGeometry(QMainWindow* w) const; + + void saveGeometry(const QDialog* d); + bool restoreGeometry(QDialog* d) const; + + void saveState(const QMainWindow* window); + bool restoreState(QMainWindow* window) const; + + void saveState(const QHeaderView* header); + bool restoreState(QHeaderView* header) const; + + void saveState(const QSplitter* splitter); + bool restoreState(QSplitter* splitter) const; + + void saveState(const MOBase::ExpanderWidget* expander); + bool restoreState(MOBase::ExpanderWidget* expander) const; + + void saveVisibility(const QWidget* w); + bool restoreVisibility(QWidget* w, std::optional def = {}) const; + + void saveToolbars(const QMainWindow* w); + void restoreToolbars(QMainWindow* w) const; + + void saveDocks(const QMainWindow* w); + void restoreDocks(QMainWindow* w) const; + + // this should be a generic "tab order" setting, but it only happens for the + // mod info dialog right now + // + QStringList modInfoTabOrder() const; + void setModInfoTabOrder(const QString& names); + + // whether dialogs should be centered on their parent + // + bool centerDialogs() const; + void setCenterDialogs(bool b); + + // assumes the given widget is a top-level + // + void centerOnMainWindowMonitor(QWidget* w) const; + + // saves the monitor number of the given window + // + void saveMainWindowMonitor(const QMainWindow* w); + +private: + QSettings& m_Settings; + bool m_Reset; + + void saveWindowGeometry(const QWidget* w); + bool restoreWindowGeometry(QWidget* w) const; + + void ensureWindowOnScreen(QWidget* w) const; + static void centerOnMonitor(QWidget* w, int monitor); + static void centerOnParent(QWidget* w, QWidget* parent = nullptr); +}; + +// widget settings that should stay valid regardless of UI changes or when users +// change display settings (resolution, monitors, etc.); see GeometrySettings +// for the counterpart +// +class WidgetSettings +{ +public: + // globalInstance is forwarded from the Settings constructor; WidgetSettings + // has the callbacks used by QuestionBoxMemory and those are global, so they + // should only be set by the global instance + // + WidgetSettings(QSettings& s, bool globalInstance); + + // tree item check - this saves the list of expanded items based on the given role + // + void saveTreeCheckState(const QTreeView* tv, int role = Qt::CheckStateRole); + void restoreTreeCheckState(QTreeView* tv, int role = Qt::CheckStateRole) const; + + // tree state - this saves the list of expanded items based on the given role + // + void saveTreeExpandState(const QTreeView* tv, int role = Qt::DisplayRole); + void restoreTreeExpandState(QTreeView* tv, int role = Qt::DisplayRole) const; + + // selected index for a combobox + // + std::optional index(const QComboBox* cb) const; + void saveIndex(const QComboBox* cb); + void restoreIndex(QComboBox* cb, std::optional def = {}) const; + + // selected tab index for a tab widget + // + std::optional index(const QTabWidget* w) const; + void saveIndex(const QTabWidget* w); + void restoreIndex(QTabWidget* w, std::optional def = {}) const; + + // check state for a checkable button + // + std::optional checked(const QAbstractButton* w) const; + void saveChecked(const QAbstractButton* w); + void restoreChecked(QAbstractButton* w, std::optional def = {}) const; + + // returns the remembered button for a question dialog, or NoButton if the + // user hasn't saved the choice + // + MOBase::QuestionBoxMemory::Button questionButton(const QString& windowName, + const QString& filename) const; + + // sets the button to be remembered for the given window + // + void setQuestionWindowButton(const QString& windowName, + MOBase::QuestionBoxMemory::Button button); + + // sets the button to be remembered for the given file + // + void setQuestionFileButton(const QString& windowName, const QString& filename, + MOBase::QuestionBoxMemory::Button choice); + + // wipes all the remembered buttons + // + void resetQuestionButtons(); + +private: + QSettings& m_Settings; +}; + +// various color settings +// +class ColorSettings +{ +public: + ColorSettings(QSettings& s); + + QColor modlistOverwrittenLoose() const; + void setModlistOverwrittenLoose(const QColor& c); + + QColor modlistOverwritingLoose() const; + void setModlistOverwritingLoose(const QColor& c); + + QColor modlistOverwrittenArchive() const; + void setModlistOverwrittenArchive(const QColor& c); + + QColor modlistOverwritingArchive() const; + void setModlistOverwritingArchive(const QColor& c); + + QColor modlistContainsFile() const; + void setModlistContainsFile(const QColor& c); + + QColor pluginListContained() const; + void setPluginListContained(const QColor& c); + + QColor pluginListMaster() const; + void setPluginListMaster(const QColor& c); + + std::optional previousSeparatorColor() const; + void setPreviousSeparatorColor(const QColor& c) const; + void removePreviousSeparatorColor(); + + // whether the scrollbar of the mod list should have colors for custom + // separator colors + // + bool colorSeparatorScrollbar() const; + void setColorSeparatorScrollbar(bool b); + + // returns a color with a good contrast for the given background + // + static QColor idealTextColor(const QColor& rBackgroundColor); + +private: + QSettings& m_Settings; +}; + +// settings about plugins +// +class PluginSettings : public QObject +{ + Q_OBJECT + +public: + PluginSettings(QSettings& settings); + + // forgets all the plugins + // + void clearPlugins(); + + // adds/removes the given plugin to the list and loads all of its settings + // + void registerPlugin(MOBase::IPlugin* plugin); + void unregisterPlugin(MOBase::IPlugin* plugin); + + // returns all the registered plugins + // + std::vector plugins() const; + + // returns the plugin setting for the given key + // + QVariant setting(const QString& pluginName, const QString& key) const; + + // sets the plugin setting for the given key + // + void setSetting(const QString& pluginName, const QString& key, const QVariant& value); + + // returns all settings + // + QVariantMap settings(const QString& pluginName) const; + + // overwrites all settings + // + void setSettings(const QString& pluginName, const QVariantMap& map); + + // returns all descriptions + // + QVariantMap descriptions(const QString& pluginName) const; + + // overwrites all descriptions + // + void setDescriptions(const QString& pluginName, const QVariantMap& map); + + // ? + QVariant persistent(const QString& pluginName, const QString& key, + const QVariant& def) const; + void setPersistent(const QString& pluginName, const QString& key, + const QVariant& value, bool sync); + + // adds the given plugin to the blacklist + // + void addBlacklist(const QString& fileName); + + // returns whether the given plugin is blacklisted + // + bool blacklisted(const QString& fileName) const; + + // overwrites the whole blacklist + // + void setBlacklist(const QStringList& pluginNames); + + // returns the blacklist + // + const QSet& blacklist() const; + + // commits all the settings to the ini + // + void save(); + +Q_SIGNALS: + + /** + * Emitted when a plugin setting changes. + */ + void pluginSettingChanged(QString const& pluginName, const QString& key, + const QVariant& oldValue, const QVariant& newValue); + +private: + QSettings& m_Settings; + std::vector m_Plugins; + QMap m_PluginSettings; + QMap m_PluginDescriptions; + QSet m_PluginBlacklist; + + // commits the blacklist to the ini + // + void writeBlacklist(); + + // reads the blacklist from the ini + // + QSet readBlacklist() const; +}; + +// paths for the game and various components +// +// if the 'resolve' parameter is true, %BASE_DIR% is expanded; it's set to +// false mostly in the settings dialog +// +class PathSettings +{ +public: + // %BASE_DIR% + static const QString BaseDirVariable; + + PathSettings(QSettings& settings); + + QString base() const; + void setBase(const QString& path); + + QString downloads(bool resolve = true) const; + void setDownloads(const QString& path); + + QString mods(bool resolve = true) const; + void setMods(const QString& path); + + QString cache(bool resolve = true) const; + void setCache(const QString& path); + + QString profiles(bool resolve = true) const; + void setProfiles(const QString& path); + + QString overwrite(bool resolve = true) const; + void setOverwrite(const QString& path); + + // map of names to directories, used to remember the last directory used in + // various file pickers + // + std::map recent() const; + void setRecent(const std::map& map); + + // resolves %BASE_DIR% + // + static QString resolve(const QString& path, const QString& baseDir); + + // returns %BASE_DIR%/dirName + // + static QString makeDefaultPath(const QString dirName); + +private: + QSettings& m_Settings; + + QString getConfigurablePath(const QString& key, const QString& def, + bool resolve) const; + void setConfigurablePath(const QString& key, const QString& path); +}; + +class NetworkSettings +{ +public: + // globalInstance is forwarded from the Settings constructor; NetworkSettings + // will set the global URL custom command + // + NetworkSettings(QSettings& settings, bool globalInstance); + + // whether the user has disabled online features + // + bool offlineMode() const; + void setOfflineMode(bool b); + + // whether the user wants to use the system proxy + // + bool useProxy() const; + void setUseProxy(bool b); + + // add a new download speed to the list for the given server; each server + // remembers the last couple of download speeds and displays the average in + // the network settings + // + void setDownloadSpeed(const QString& serverName, int bytesPerSecond); + + // known servers + // + ServerList servers() const; + + // sets the servers + // + void updateServers(ServerList servers); + + // for 2.2.1 and before, rewrites the old byte array map to the new format + // + void updateFromOldMap(); + + // whether to use a custom command to open links + // + bool useCustomBrowser() const; + void setUseCustomBrowser(bool b); + + // custom command to open links + // + QString customBrowserCommand() const; + void setCustomBrowserCommand(const QString& s); + + void dump() const; + +private: + QSettings& m_Settings; + + // for pre 2.2.1 ini files + // + ServerList serversFromOldMap() const; + + // sets the custom command in uibase, called when the settings change + // + void updateCustomBrowser(); +}; + +enum class EndorsementState +{ + Accepted = 1, + Refused, + NoDecision +}; + +EndorsementState endorsementStateFromString(const QString& s); +QString toString(EndorsementState s); + +class NexusSettings +{ +public: + NexusSettings(Settings& parent, QSettings& settings); + + // returns whether endorsement integration is enabled + // + bool endorsementIntegration() const; + void setEndorsementIntegration(bool b) const; + + // returns the endorsement state of MO itself + // + EndorsementState endorsementState() const; + void setEndorsementState(EndorsementState s); + + // returns whether tracked integration is enabled + // + bool trackedIntegration() const; + void setTrackedIntegration(bool b) const; + + // returns whether nexus category mappings are enabled + // + bool categoryMappings() const; + void setCategoryMappings(bool b) const; + + // registers MO as the handler for nxm links + // + // if 'force' is true, the registration dialog will be shown even if the user + // said earlier not to + // + void registerAsNXMHandler(bool force); + + std::vector validationTimeouts() const; + + // dumps nxmhandler stuff + // + void dump() const; + +private: + Settings& m_Parent; + QSettings& m_Settings; +}; + +class SteamSettings +{ +public: + SteamSettings(Settings& parent, QSettings& settings); + + // the steam appid is assigned by the steam platform to each product sold + // there. + // + // the appid may differ between different versions of a game so it may be + // impossible for MO to automatically recognize it, though usually it does + // + QString appID() const; + void setAppID(const QString& id); + + // the steam username is stored in the ini, but the password is in the + // windows credentials store; both values are independent and either can be + // empty + // + // if the username exists in the ini, it is assigned to `username`; if not + // `username` is set to an empty string + // + // if the password exists in the credentials store, it is assigned to + // `password`; if not, `password` is set to an empty string + // + // returns whether _both_ the username and password have a value + // + bool login(QString& username, QString& password) const; + + // sets the steam login; the username is saved in the ini file and the + // password in the credentials store + // + // if a value is empty, it is removed from its backing store + // + void setLogin(QString username, QString password); + +private: + Settings& m_Parent; + QSettings& m_Settings; +}; + +class InterfaceSettings +{ +public: + InterfaceSettings(QSettings& settings); + + // whether the GUI should be locked when running executables + // + bool lockGUI() const; + void setLockGUI(bool b); + + // filename of the theme + // + std::optional styleName() const; + void setStyleName(const QString& name); + + // whether to use collapsible separators when possible + // + bool collapsibleSeparators(Qt::SortOrder order) const; + void setCollapsibleSeparators(bool ascending, bool descending); + + // whether to highlight mod conflicts and plugins on collapsed + // separators + // + bool collapsibleSeparatorsHighlightTo() const; + void setCollapsibleSeparatorsHighlightTo(bool b); + + // whether to highlight mod conflicts and plugins from separators + // when selected but collapsed + // + bool collapsibleSeparatorsHighlightFrom() const; + void setCollapsibleSeparatorsHighlightFrom(bool b); + + // whether to show icons on collapsed separators + // + bool collapsibleSeparatorsIcons(int column) const; + void setCollapsibleSeparatorsIcons(int column, bool show); + + // whether each profile should have its own expansion state + // + bool collapsibleSeparatorsPerProfile() const; + void setCollapsibleSeparatorsPerProfile(bool b); + + // whether to save/restore filter states between runs + // + bool saveFilters() const; + void setSaveFilters(bool b); + + // whether to collapse groups (separators, categories, ...) after + // a delay when hovering (similar to auto-expand) + // + bool autoCollapseOnHover() const; + void setAutoCollapseOnHover(bool b); + + // whether to check for update after installing a mod + // + bool checkUpdateAfterInstallation() const; + void setCheckUpdateAfterInstallation(bool b); + + // whether to show compact downloads + // + bool compactDownloads() const; + void setCompactDownloads(bool b); + + // whether to show meta information for downloads + // + bool metaDownloads() const; + void setMetaDownloads(bool b); + + // whether to hide downloads after installing them + // + bool hideDownloadsAfterInstallation() const; + void setHideDownloadsAfterInstallation(bool b); + + // whether the API counter should be hidden + // + bool hideAPICounter() const; + void setHideAPICounter(bool b); + + // whether the user wants to see non-official plugins installed outside MO in + // the mod list + // + bool displayForeign() const; + void setDisplayForeign(bool b); + + // short code of the configured language (corresponding to the translation + // files) + // + QString language(); + void setLanguage(const QString& name); + + // whether the given tutorial has been completed + // + bool isTutorialCompleted(const QString& windowName) const; + void setTutorialCompleted(const QString& windowName, bool b = true); + + // whether to show the confirmation when switching instances + // + bool showChangeGameConfirmation() const; + void setShowChangeGameConfirmation(bool b); + + // whether to show the menubar when pressing the Alt key + // + bool showMenubarOnAlt() const; + void setShowMenubarOnAlt(bool b); + + // whether double-clicks on files should try to open previews first + // + bool doubleClicksOpenPreviews() const; + void setDoubleClicksOpenPreviews(bool b); + + // filter widget options + // + MOBase::FilterWidget::Options filterOptions() const; + void setFilterOptions(const MOBase::FilterWidget::Options& o); + +private: + QSettings& m_Settings; +}; + +class DiagnosticsSettings +{ +public: + DiagnosticsSettings(QSettings& settings); + + // log level for both MO and usvfs + // + MOBase::log::Levels logLevel() const; + void setLogLevel(MOBase::log::Levels level); + + // log level for loot + lootcli::LogLevels lootLogLevel() const; + void setLootLogLevel(lootcli::LogLevels level); + + // crash dump type for both MO and usvfs + // + env::CoreDumpTypes coreDumpType() const; + void setCoreDumpType(env::CoreDumpTypes type); + + // maximum number of dump files keps, for both MO and usvfs + // + int maxCoreDumps() const; + void setMaxCoreDumps(int n); + + std::chrono::seconds spawnDelay() const; + void setSpawnDelay(std::chrono::seconds t); + +private: + QSettings& m_Settings; +}; + +// manages the settings for MO; the settings are accessed directly through a +// QSettings and so are not cached here +// +class Settings : public QObject +{ + Q_OBJECT; + +public: + // there is one Settings global object for MO when an instance is loaded, but + // other Settings objects are required in several places, such as in the + // Instance class, the instance dialogs, etc. + // + // any Settings object created with globalInstance==false won't set the + // singleton + // + // some sub-objects like WidgetSettings and NetworkSettings need to know + // whether they were created from a globalInstance, see their constructor + // + // @param path path to an ini file + // @param globalInsance whether this is the global instance; creates the + // singleton and asserts if it already exists + // + Settings(const QString& path, bool globalInstance = false); + ~Settings(); + + // throws if there is no global Settings instance + // + static Settings& instance(); + + // returns null if there is no global Settings instance + // + static Settings* maybeInstance(); + + // name of the ini file + // + QString filename() const; + + // version of MO stored in the ini; this may be different from the current + // version if the user just updated + // + std::optional version() const; + + // updates the settings to bring them up to date + // + void processUpdates(const QVersionNumber& current, const QVersionNumber& last); + + // whether MO has been started for the first time + // + bool firstStart() const; + void setFirstStart(bool b); + + // configured executables + // + std::vector> executables() const; + void setExecutables(const std::vector>& v); + + // whether to backup existing mods on install + // + bool keepBackupOnInstall() const; + void setKeepBackupOnInstall(bool b); + + // blacklisted executables do not get hooked by usvfs; this list is managed + // by MO but given to usvfs when starting an executable + // + QString executablesBlacklist() const; + bool isExecutableBlacklisted(const QString& s) const; + void setExecutablesBlacklist(const QString& s); + + QStringList skipFileSuffixes() const; + void setSkipFileSuffixes(const QStringList& s); + + QStringList skipDirectories() const; + void setSkipDirectories(const QStringList& s); + + // ? looks obsolete, only used by dead code + // + unsigned int motdHash() const; + void setMotdHash(unsigned int hash); + + // whether archives should be parsed to show conflicts and contents + // + bool archiveParsing() const; + void setArchiveParsing(bool b); + + // whether the user wants to check for updates + // + bool checkForUpdates() const; + void setCheckForUpdates(bool b); + + // whether the user wants to upgrade to pre-releases + // + bool usePrereleases() const; + void setUsePrereleases(bool b); + + // whether profiles should default to local INIs + // + bool profileLocalInis() const; + void setProfileLocalInis(bool b); + + // whether profiles should default to local saves + // + bool profileLocalSaves() const; + void setProfileLocalSaves(bool b); + + // whether profiles should default to automatic archive invalidation + // + bool profileArchiveInvalidation() const; + void setProfileArchiveInvalidation(bool b); + + // whether to use spascreen or not + // + bool useSplash() const; + void setUseSplash(bool b); + + // number of threads to use when refreshing + // + std::size_t refreshThreadCount() const; + void setRefreshThreadCount(std::size_t n) const; + + GameSettings& game(); + const GameSettings& game() const; + + GeometrySettings& geometry(); + const GeometrySettings& geometry() const; + + WidgetSettings& widgets(); + const WidgetSettings& widgets() const; + + ColorSettings& colors(); + const ColorSettings& colors() const; + + PluginSettings& plugins(); + const PluginSettings& plugins() const; + + PathSettings& paths(); + const PathSettings& paths() const; + + NetworkSettings& network(); + const NetworkSettings& network() const; + + NexusSettings& nexus(); + const NexusSettings& nexus() const; + + SteamSettings& steam(); + const SteamSettings& steam() const; + + InterfaceSettings& interface(); + const InterfaceSettings& interface() const; + + DiagnosticsSettings& diagnostics(); + const DiagnosticsSettings& diagnostics() const; + + // makes sure the ini file is written to disk + // + QSettings::Status sync() const; + + // last status of the ini file + // + QSettings::Status iniStatus() const; + + void dump() const; + +public slots: + // this slot is connected to by various parts of MO + // + void managedGameChanged(MOBase::IPluginGame const* gamePlugin); + +signals: + // these are fired from outside the settings, mostly by the settings dialog + // + void languageChanged(const QString& newLanguage); + void styleChanged(const QString& newStyle); + +private: + static Settings* s_Instance; + mutable QSettings m_Settings; + + GameSettings m_Game; + GeometrySettings m_Geometry; + WidgetSettings m_Widgets; + ColorSettings m_Colors; + PluginSettings m_Plugins; + PathSettings m_Paths; + NetworkSettings m_Network; + NexusSettings m_Nexus; + SteamSettings m_Steam; + InterfaceSettings m_Interface; + DiagnosticsSettings m_Diagnostics; +}; + +// manages global settings in the registry +// +class GlobalSettings +{ +public: + // migrates the old settings from the Tannin key to the new one + static void updateRegistryKey(); + + static QString currentInstance(); + static void setCurrentInstance(const QString& s); + + static bool hideCreateInstanceIntro(); + static void setHideCreateInstanceIntro(bool b); + + static bool hideTutorialQuestion(); + static void setHideTutorialQuestion(bool b); + + static bool hideCategoryReminder(); + static void setHideCategoryReminder(bool b); + + static bool hideAssignCategoriesQuestion(); + static void setHideAssignCategoriesQuestion(bool b); + + // if the key exists from the credentials store, puts it in `apiKey` and + // returns true; otherwise, returns false and leaves `apiKey` untouched + // + static bool nexusApiKey(QString& apiKey); + + // sets the api key in the credentials store, removes it if empty; returns + // false on errors + // + static bool setNexusApiKey(const QString& apiKey); + + // removes the api key from the credentials store; returns false on errors + // + static bool clearNexusApiKey(); + + // returns whether an API key is currently stored + // + static bool hasNexusApiKey(); + + // resets anything that the user can disable + static void resetDialogs(); + +private: + static QSettings settings(); +}; + +// helper class that calls restoreGeometry() in the constructor and +// saveGeometry() in the destructor +// +template +class GeometrySaver +{ +public: + GeometrySaver(Settings& s, W* w) : m_settings(s), m_widget(w) + { + m_settings.geometry().restoreGeometry(m_widget); + } + + ~GeometrySaver() { m_settings.geometry().saveGeometry(m_widget); } + +private: + Settings& m_settings; + W* m_widget; +}; + +#endif // SETTINGS_H diff --git a/src/src/settingsdialog.cpp b/src/src/settingsdialog.cpp new file mode 100644 index 0000000..919bf4e --- /dev/null +++ b/src/src/settingsdialog.cpp @@ -0,0 +1,162 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "settingsdialog.h" +#include "settingsdialogdiagnostics.h" +#include "settingsdialoggeneral.h" +#include "settingsdialogmodlist.h" +#include "settingsdialognexus.h" +#include "settingsdialogpaths.h" +#include "settingsdialogplugins.h" +#include "settingsdialogproton.h" +#include "settingsdialogtheme.h" +#include "settingsdialogworkarounds.h" +#include "ui_settingsdialog.h" + +using namespace MOBase; + +SettingsDialog::SettingsDialog(PluginContainer* pluginContainer, Settings& settings, + QWidget* parent) + : TutorableDialog("SettingsDialog", parent), ui(new Ui::SettingsDialog), + m_settings(settings), m_exit(Exit::None), m_pluginContainer(pluginContainer) +{ + ui->setupUi(this); + + m_tabs.push_back( + std::unique_ptr(new GeneralSettingsTab(settings, *this))); + m_tabs.push_back(std::unique_ptr(new ThemeSettingsTab(settings, *this))); + m_tabs.push_back( + std::unique_ptr(new ModListSettingsTab(settings, *this))); + m_tabs.push_back(std::unique_ptr(new PathsSettingsTab(settings, *this))); + m_tabs.push_back( + std::unique_ptr(new DiagnosticsSettingsTab(settings, *this))); + m_tabs.push_back(std::unique_ptr(new NexusSettingsTab(settings, *this))); + m_tabs.push_back(std::unique_ptr( + new PluginsSettingsTab(settings, m_pluginContainer, *this))); + m_tabs.push_back( + std::unique_ptr(new ProtonSettingsTab(settings, *this))); + m_tabs.push_back( + std::unique_ptr(new WorkaroundsSettingsTab(settings, *this))); +} + +PluginContainer* SettingsDialog::pluginContainer() +{ + return m_pluginContainer; +} + +QWidget* SettingsDialog::parentWidgetForDialogs() +{ + if (isVisible()) { + return this; + } else { + return parentWidget(); + } +} + +void SettingsDialog::setExitNeeded(ExitFlags e) +{ + m_exit = e; +} + +ExitFlags SettingsDialog::exitNeeded() const +{ + return m_exit; +} + +int SettingsDialog::exec() +{ + GeometrySaver gs(m_settings, this); + + m_settings.widgets().restoreIndex(ui->tabWidget); + + auto ret = TutorableDialog::exec(); + + m_settings.widgets().saveIndex(ui->tabWidget); + + if (ret == QDialog::Accepted) { + for (auto&& tab : m_tabs) { + tab->closing(); + } + + // update settings for each tab + for (std::unique_ptr const& tab : m_tabs) { + tab->update(); + } + } + + return ret; +} + +SettingsDialog::~SettingsDialog() +{ + disconnect(this); + delete ui; +} + +QString SettingsDialog::getColoredButtonStyleSheet() const +{ + return QString("QPushButton {" + "background-color: %1;" + "color: %2;" + "border: 1px solid;" + "padding: 3px;" + "}"); +} + +void SettingsDialog::accept() +{ + QString newModPath = ui->modDirEdit->text(); + newModPath = PathSettings::resolve(newModPath, ui->baseDirEdit->text()); + + if ((QDir::fromNativeSeparators(newModPath) != + QDir::fromNativeSeparators(Settings::instance().paths().mods(true))) && + (QMessageBox::question( + parentWidgetForDialogs(), tr("Confirm"), + tr("Changing the mod directory affects all your profiles! " + "Mods not present (or named differently) in the new location " + "will be disabled in all profiles. " + "There is no way to undo this unless you backed up your " + "profiles manually. Proceed?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::No)) { + return; + } + + TutorableDialog::accept(); +} + +SettingsTab::SettingsTab(Settings& s, SettingsDialog& d) + : ui(d.ui), m_settings(s), m_dialog(d) +{} + +SettingsTab::~SettingsTab() = default; + +Settings& SettingsTab::settings() +{ + return m_settings; +} + +SettingsDialog& SettingsTab::dialog() +{ + return m_dialog; +} + +QWidget* SettingsTab::parentWidget() +{ + return m_dialog.parentWidgetForDialogs(); +} diff --git a/src/src/settingsdialog.h b/src/src/settingsdialog.h new file mode 100644 index 0000000..3a2cba2 --- /dev/null +++ b/src/src/settingsdialog.h @@ -0,0 +1,96 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef SETTINGSDIALOG_H +#define SETTINGSDIALOG_H + +#include "shared/util.h" +#include "tutorabledialog.h" + +class PluginContainer; +class Settings; +class SettingsDialog; +namespace Ui +{ +class SettingsDialog; +} + +class SettingsTab +{ +public: + SettingsTab(Settings& settings, SettingsDialog& m_dialog); + virtual ~SettingsTab(); + + virtual void update() = 0; + virtual void closing() {} + +protected: + Ui::SettingsDialog* ui; + + Settings& settings(); + SettingsDialog& dialog(); + QWidget* parentWidget(); + +private: + Settings& m_settings; + SettingsDialog& m_dialog; +}; + +/** + * dialog used to change settings for Mod Organizer. On top of the + * settings managed by the "Settings" class, this offers a button to open the + * CategoriesDialog + **/ +class SettingsDialog : public MOBase::TutorableDialog +{ + Q_OBJECT; + friend class SettingsTab; + +public: + explicit SettingsDialog(PluginContainer* pluginContainer, Settings& settings, + QWidget* parent = 0); + + ~SettingsDialog(); + + /** + * @brief get stylesheet of settings buttons with colored background + * @return string of stylesheet + */ + QString getColoredButtonStyleSheet() const; + + PluginContainer* pluginContainer(); + QWidget* parentWidgetForDialogs(); + + void setExitNeeded(ExitFlags e); + ExitFlags exitNeeded() const; + + int exec() override; + +public slots: + virtual void accept(); + +private: + Ui::SettingsDialog* ui; + Settings& m_settings; + std::vector> m_tabs; + ExitFlags m_exit; + PluginContainer* m_pluginContainer; +}; + +#endif // SETTINGSDIALOG_H diff --git a/src/src/settingsdialog.ui b/src/src/settingsdialog.ui new file mode 100644 index 0000000..9fdf4e3 --- /dev/null +++ b/src/src/settingsdialog.ui @@ -0,0 +1,2568 @@ + + + SettingsDialog + + + + 0 + 0 + 820 + 592 + + + + Settings + + + + + + 0 + + + + General + + + + + + #generalScrollArea { background-color: transparent; } +#generalScrollAreaWidgetContents { background-color: transparent; } + + + QFrame::NoFrame + + + QFrame::Plain + + + true + + + + + 0 + 0 + 761 + 550 + + + + false + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Language + + + + 9 + + + 9 + + + 9 + + + 9 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + https://discord.gg/ewUVAqyrQX + + + <a href="https://discord.gg/ewUVAqyrQX">Help translate Mod Organizer on Discord</a> + + + true + + + + + + + The language of the user interface. + + + The language of the user interface. + + + + + + + + + + Download List + + + + + + Show meta information instead of file names in the download list. + + + Show meta information instead of file names in the download list. + + + Show meta information + + + + + + + Make the download list more compact. + + + Make the download list more compact. + + + Compact list + + + + + + + Automatically hide downloads after successful installation. + + + Automatically hide downloads after successful installation. + + + Hide downloads after installation + + + + + + + + + + Updates + + + + + + Check for Mod Organizer updates on Github on startup. + + + Check for Mod Organizer updates on Github on startup. + + + Check for updates + + + + + + + Update to non-stable releases. + + + Update to non-stable releases. + + + Update to beta versions + + + + + + + + + + Profile Defaults + + + + + + Local INIs + + + + + + + Local Saves + + + + + + + Automatic Archive Invalidation + + + + + + + + + + Miscellaneous + + + + 9 + + + 9 + + + 9 + + + 9 + + + + + Dialogs will always be centered on the main window, but will remember their size. + + + Dialogs will always be centered on the main window, but will remember their size. + + + Always center dialogs + + + + + + + Show confirmation when changing instance + + + + + + + Show the menubar when the Alt key is pressed + + + Show the menubar when the Alt key is pressed + + + Show menubar when pressing Alt + + + + + + + Whether double-clicking on a file opens the preview window or launches the program associated with it. This applies to the Data tab as well as the Conflicts and Filetree tabs in the mod info window. + + + Open previews on double-click + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 16777215 + 16777215 + + + + Reset all choices made in dialogs. + + + Reset all choices made in dialogs. + + + Reset Dialog Choices + + + + + + + Modify the categories available to arrange your mods. + + + Modify the categories available to arrange your mods. + + + Configure Mod Categories + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + Theme + + + + + + Style + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + Visual theme of the user interface. + + + Visual theme of the user interface. + + + + + + + Explore... + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + Colors + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + QAbstractItemView::ScrollPerPixel + + + false + + + false + + + false + + + true + + + false + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Reset all colors to their default value. + + + Reset all colors to their default value. + + + Reset Colors + + + + + + + + + + + + + + + + + + + Mod List + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Colors set on separators will also be shown in the mod list scrollbar at the location of the separator. This can be useful for quickly navigating to a specific separator. + + + Colors set on separators will also be shown in the mod list scrollbar at the location of the separator. This can be useful for quickly navigating to a specific separator. + + + Show mod list separator colors on the scrollbar + + + true + + + + + + + Disable this to no longer display mods installed outside MO in the mod list (left pane). Assets from those mods will then be treated as having lowest mod priority together with the original game content. + + + By default Mod Organizer will display esp+bsa bundles installed with foreign tools as mods (left pane). This allows you to control their priority in relation to other mods. This is particularly useful if you also use Steam Workshop to install mods. +However, if you installed loose file mods outside MO which conflict with BSAs also installed outside MO those conflicts can't be resolved correctly. + +If you disable this feature, MO will only display official DLCs this way. Please note that plugins (esps, esms, and esls) displayed in the right pane are completely unaffected by this feature. + + + Display mods installed outside MO + + + true + + + + + + + Save the current filters when closing MO2 and restore them on startup. + + + Save the current filters when closing MO2 and restore them on startup. + + + Remember selected filters after restarting MO + + + + + + + Check if updates are available for mods after installing them. + + + Check if updates are available for mods after installing them. + + + Check for updates when installing mods + + + true + + + + + + + Automatically collapse separators, categories or nexus ids after a delay when hovering them during drag. + + + Automatically collapse separators, categories or nexus ids after a delay when hovering them during drag. + + + Automatically collapse items during drag on hover + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 10 + + + + + + + + Collapsible Separators + + + false + + + false + + + false + + + + + + 9 + + + + + Highlight collapsed separators based on conflicts and plugins from mods inside them. + + + Highlight collapsed separators based on conflicts and plugins from mods inside them. + + + on separators + + + true + + + + + + + Enable when sorting by + + + + + + + Show conflicts and plugins + + + + + + + When selecting a collapsed separator, highlight conflicting mods and plugins from mods inside the separator. + + + When selecting a collapsed separator, highlight conflicting mods and plugins from mods inside the separator. + + + from separators + + + true + + + + + + + ascending priority + + + true + + + + + + + descending priority + + + + + + + Show icons on separators + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + conflicts + + + true + + + + + + + flags + + + true + + + + + + + content + + + true + + + + + + + version + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + Do not share the collapse/expanded state of separators between profiles. + + + Do not share the collapse/expanded state of separators between profiles. + + + Profile-specific collapse states for separators + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + Paths + + + + + + + + ... + + + + + + + ... + + + + + + + + + + ... + + + + + + + + + + Caches + + + + + + + Overwrite + + + + + + + Directory where downloads are stored. + + + Directory where downloads are stored. + + + + + + + + + + ... + + + + + + + Downloads + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Profiles + + + + + + + + 0 + 0 + + + + ... + + + + + + + + + + Directory where mods are stored. + + + Directory where mods are stored. Please note that changing this will break all associations of profiles with mods that don't exist in the new location (with the same name). + + + + + + + ... + + + + + + + false + + + true + + + + + + + Mods + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Managed Game + + + + + + + Base Directory + + + + + + + Use %BASE_DIR% to refer to the Base Directory. + + + + + + + ... + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + All directories must be writable. + + + + + + + + Nexus + + + + + + #nexusScrollArea { background-color: transparent; } +#nexusScrollAreaWidgetContents { background-color: transparent; } + + + QFrame::NoFrame + + + QFrame::Plain + + + true + + + + + 0 + 0 + 761 + 515 + + + + false + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + Nexus Account + + + + 10 + + + + + User ID: + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + id + + + + + + + Name: + + + + + + + name + + + + + + + Account: + + + + + + + account + + + + + + + + + + Statistics + + + + 10 + + + + + Daily requests: + + + + + + + daily requests + + + + + + + Hourly requests: + + + + + + + hourly requests + + + + + + + + + + + + Nexus Connection + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Connect to Nexus + + + + + + + Manually enter the API key and try to login + + + Enter API Key Manually + + + + + + + Clear the stored Nexus API key and force reauthorization. + + + Disconnect from Nexus + + + + :/MO/gui/edit_clear:/MO/gui/edit_clear + + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QAbstractScrollArea::AdjustToContents + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + Options + + + + 9 + + + 9 + + + 9 + + + 9 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Endorsement Integration + + + true + + + + + + + Tracked Integration + + + true + + + + + + + Use Nexus category mappings + + + true + + + + + + + <html><head/><body><p>By default, a counter is displayed in the bottom right corner. This informs the user of their remaining API requests. The Nexus API becomes unusable once these API requests run out. Checking this option will hide that counter.</p></body></html> + + + <html><head/><body><p>By default, a counter is displayed in the bottom right corner. This informs the user of their remaining API requests. The Nexus API becomes unusable once these API requests run out. Checking this option will hide that counter.</p></body></html> + + + Hide API Request Counter + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Associate with "Download with manager" links + + + + + + + Remove cache and cookies. + + + Clear Cache + + + + + + + + + + + + + Servers + + + + 4 + + + 9 + + + 9 + + + 9 + + + 9 + + + + + + + Known Servers (updated on download) + + + + + + + QAbstractItemView::DragDrop + + + Qt::MoveAction + + + + + + + + + + + Preferred Servers (Drag & Drop) + + + + + + + QAbstractItemView::DragDrop + + + Qt::MoveAction + + + + + + + + + + + + + + + + + Plugins + + + + + + Qt::Vertical + + + + + 0 + 4 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Qt::Horizontal + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + 6 + + + + + Author: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + Version: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + Description: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + + + + Enabled + + + + + + + + + + 0 + + + false + + + false + + + false + + + false + + + 170 + + + + Key + + + + + Value + + + + + + + + No plugin found. + + + Qt::AlignCenter + + + + + + + + + + + + + 0 + 1 + + + + Blacklisted Plugins (use <del> to remove): + + + + + + + + + + + + + + Wine/Proton + + + + + + Prefix Management + + + + + + Proton Version: + + + + + + + + + + Prefix Path: + + + + + + + (none) + + + true + + + + + + + Create Prefix + + + + + + + Delete Prefix + + + + + + + Recreate Prefix + + + + + + + Open Prefix Folder + + + + + + + Fix Game Registries + + + Re-apply Wine registry settings for the current prefix. Use this if games fail to detect their installation or have registry-related issues. + + + + + + + Status: + + + + + + + No Prefix + + + + + + + 0 + + + + + + + + + + Launch Settings + + + + + + Use UMU Launcher (recommended) + + + true + + + Launch games using umu-run instead of raw Proton. UMU provides better compatibility and automatic dependency handling. + + + + + + + Prefer system umu-run over bundled + + + Use umu-run from PATH when available (helpful for NixOS). If unavailable, falls back to bundled umu-run. + + + + + + + Wrap launches with steam-run + + + Run launch and prefix setup commands through steam-run. Useful on NixOS and other immutable setups. + + + + + + + + + Wrapper Command: + + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + Workarounds + + + + + + #workaroundScrollArea { background-color: transparent; } +#workaroundScrollAreaWidgetContents { background-color: transparent; } + + + QFrame::NoFrame + + + QFrame::Plain + + + true + + + + + 0 + 0 + 778 + 475 + + + + false + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Options + + + + 9 + + + 9 + + + 9 + + + 9 + + + + + If checked, files (i.e. esps, esms and bsas) belonging to the core game can not be disabled in the UI. (default: on) + + + If checked, files (i.e. esps, esms and bsas) belonging to the core game can not be disabled in the UI. (default: on) +Uncheck this if you want to use Mod Organizer with total conversions (like Nehrim) but be aware that the game will crash if required files are not enabled. + + + Force-enable game files + + + true + + + + + + + Enable parsing of Archives. This is an Experimental Feature. Has negative effects on performance and known incorrectness. + + + <html><head/><body><p>By default, MO will parse archive files (BSA, BA2) to calculate conflicts between the contents of the archive files and other loose files. This process has a noticeable cost in performance.</p><p>This feature should not be confused with the archive management feature offered by MO1. MO2 will only show conflicts with archives and will NOT load them into the game or program.</p><p>If you disable this feature, MO will only display conflicts between loose files.</p></body></html> + + + Enable archives parsing (experimental) + + + true + + + + + + + Disable this to prevent the GUI from being locked when running an executable. This may result in abnormal behavior. + + + Disable this to prevent the GUI from being locked when running an executable. This may result in abnormal behavior. + + + Lock GUI when running executable + + + true + + + + + + + + + + Steam + + + + + + + + + Password + + + + + + + QLineEdit::Password + + + + + + + Username + + + + + + + Steam App ID + + + + + + + The Steam AppID for your game + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The Steam App ID is required to directly start some games. For Skyrim, if this is not set or wrong, the &quot;Mod Organizer&quot; load mechanism may not work properly.</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The preset for this is the App ID of the &quot;regular&quot; version so in most cases, you should be set.</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">If you think you have a different version (GotY or something), follow these steps to get to the id:</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">1. Navigate to the game library in steam</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">2. right-click on the game you need the id for and choose </span><span style=" font-size:8pt; font-weight:600;">Create desktop shortcut</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">3. right-click on the newly created shortcut on your desktop and select </span><span style=" font-size:8pt; font-weight:600;">Properties</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">4. in the URL-field you should see something like this: </span><span style=" font-size:8pt; font-style:italic;">steam://rungameid/22380</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">22380 is the id you're looking for.</span></p></body></html> + + + + + + + + + + + 0 + 100 + + + + Network + + + + + + Disable automatic internet features + + + Disable automatic internet features. This does not affect features that are explicitly invoked by the user (like checking mods for updates, endorsing, opening the web browser) + + + Offline Mode + + + + + + + Use a proxy for network connections. + + + Use a proxy for network connections. This uses the system-wide settings which can be configured in Internet Explorer. Please note that MO will start up a few seconds slower on some systems when using a proxy. + + + Use System HTTP Proxy + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Use "%1" as a placeholder for the URL. + + + Use "%1" as a placeholder for the URL. + + + Use "%1" as a placeholder for the URL. + + + Custom browser + + + + + + + Use "%1" as a placeholder for the URL. + + + Use "%1" as a placeholder for the URL. + + + Use "%1" as a placeholder for the URL. + + + + + + + ... + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Resets the window geometries for all windows. This can be useful if a window becomes too small or too large, if a column becomes too thin or too wide, and in similar situations. + + + Resets the window geometries for all windows. This can be useful if a window becomes too small or too large, if a column becomes too thin or too wide, and in similar situations. + + + Reset Window Geometries + + + + + + + + For Skyrim, this can be used instead of Archive Invalidation. It should make AI redundant for all Profiles. + For the other games this is not a sufficient replacement for AI! + + + + + For Skyrim, this can be used instead of Archive Invalidation. It should make AI redundant for all Profiles. + For the other games this is not a sufficient replacement for AI! + + + + Back-date BSAs + + + + :/MO/gui/resources/emblem-readonly.png:/MO/gui/resources/emblem-readonly.png + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Add executables to the blacklist to prevent them from +accessing the virtual file system. This is useful to prevent +unintended programs from being hooked. Hooking unintended +programs may affect the execution of these programs or the +programs you are intentionally running. + + + Add executables to the blacklist to prevent them from accessing the virtual file system. This is useful to prevent unintended programs from being hooked. Hooking unintended programs may affect the execution of these programs or the programs you are intentionally running. + + + Executables Blacklist + + + false + + + + + + + Files to skip or ignore from the virtual file system. + + + Files to skip or ignore from the virtual file system. + + + Skip File Suffixes + + + false + + + + + + + Directories to skip or ignore from the virtual file system. + + + Directories to skip or ignore from the virtual file system. + + + Skip Directories + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + These are workarounds for problems with Mod Organizer. Please make sure you read the help text before changing anything here. + + + true + + + + + + + + Diagnostics + + + + 12 + + + + + Logs and Crashes + + + + QFormLayout::FieldsStayAtSizeHint + + + + + Log Level + + + + + + + Decides the amount of data printed to "ModOrganizer.log" + + + + Decides the amount of data printed to "ModOrganizer.log". + "Debug" produces very useful information for finding problems. There is usually no noteworthy performance impact but the file may become rather large. If this is a problem you may prefer the "Info" level for regular use. On the "Error" level the log file usually remains empty. + + + + + + + + Crash Dumps + + + + + + + Decides which type of crash dumps are collected when injected processes crash. + + + + Decides which type of crash dumps are collected when injected processes crash. + "None" Disables the generation of crash dumps by MO. + "Mini" Default level which generates small dumps (only stack traces). + "Data" Much larger dumps with additional information which may be need (also data segments). + "Full" Even larger dumps with a full memory dump of the process. + + + + + + + + Max Dumps To Keep + + + + + + + Maximum number of crash dumps to keep on disk. Use 0 for unlimited. + + + + Maximum number of crash dumps to keep on disk. Use 0 for unlimited. + Set "Crash Dumps" above to None to disable crash dump collection. + + + + + + + + + + + Integrated LOOT + + + + QFormLayout::FieldsStayAtSizeHint + + + + + LOOT Log Level + + + + + + + + + + + + + Click a link to open the location + + + + Logs and crash dumps are stored under your current instance in the <a href="LOGS_FULL_PATH">LOGS_DIR</a> + and <a href="DUMPS_FULL_PATH">DUMPS_DIR</a> folders. + Sending logs and/or crash dumps to the developers can help investigate issues. + It is recommended to compress large log and dmp files before sending. + + + + true + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 20 + 232 + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + LinkLabel + QLabel +
        linklabel.h
        +
        + + ColorTable + QTableWidget +
        colortable.h
        +
        +
        + + languageBox + usePrereleaseBox + baseDirEdit + browseBaseDirBtn + downloadDirEdit + browseDownloadDirBtn + modDirEdit + browseModDirBtn + cacheDirEdit + browseCacheDirBtn + profilesDirEdit + browseProfilesDirBtn + overwriteDirEdit + browseOverwriteDirBtn + managedGameDirEdit + browseGameDirBtn + pluginSettingsList + lockGUIBox + + + + + + + buttonBox + accepted() + SettingsDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + SettingsDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + +
        diff --git a/src/src/settingsdialogdiagnostics.cpp b/src/src/settingsdialogdiagnostics.cpp new file mode 100644 index 0000000..ab3c922 --- /dev/null +++ b/src/src/settingsdialogdiagnostics.cpp @@ -0,0 +1,113 @@ +#include "settingsdialogdiagnostics.h" +#include "organizercore.h" +#include "shared/appconfig.h" +#include "ui_settingsdialog.h" +#include + +using namespace MOBase; + +DiagnosticsSettingsTab::DiagnosticsSettingsTab(Settings& s, SettingsDialog& d) + : SettingsTab(s, d) +{ + setLogLevel(); + setLootLogLevel(); + setCrashDumpTypesBox(); + + ui->dumpsMaxEdit->setValue(settings().diagnostics().maxCoreDumps()); + + QString logsPath = QUrl::fromLocalFile(qApp->property("dataPath").toString() + "/" + + QString::fromStdWString(AppConfig::logPath())) + .toString(); + + ui->diagnosticsExplainedLabel->setText( + ui->diagnosticsExplainedLabel->text() + .replace("LOGS_FULL_PATH", logsPath) + .replace("LOGS_DIR", QString::fromStdWString(AppConfig::logPath())) + .replace("DUMPS_FULL_PATH", + QUrl::fromLocalFile( + QString::fromStdWString(OrganizerCore::getGlobalCoreDumpPath())) + .toString()) + .replace("DUMPS_DIR", QString::fromStdWString(AppConfig::dumpsDir()))); +} + +void DiagnosticsSettingsTab::setLogLevel() +{ + ui->logLevelBox->clear(); + + ui->logLevelBox->addItem(QObject::tr("Debug"), log::Debug); + ui->logLevelBox->addItem(QObject::tr("Info (recommended)"), log::Info); + ui->logLevelBox->addItem(QObject::tr("Warning"), log::Warning); + ui->logLevelBox->addItem(QObject::tr("Error"), log::Error); + + const auto sel = settings().diagnostics().logLevel(); + + for (int i = 0; i < ui->logLevelBox->count(); ++i) { + if (ui->logLevelBox->itemData(i) == sel) { + ui->logLevelBox->setCurrentIndex(i); + break; + } + } +} + +void DiagnosticsSettingsTab::setLootLogLevel() +{ + using L = lootcli::LogLevels; + + auto v = [](L level) { + return QVariant(static_cast(level)); + }; + + ui->lootLogLevel->clear(); + + ui->lootLogLevel->addItem(QObject::tr("Trace"), v(L::Trace)); + ui->lootLogLevel->addItem(QObject::tr("Debug"), v(L::Debug)); + ui->lootLogLevel->addItem(QObject::tr("Info (recommended)"), v(L::Info)); + ui->lootLogLevel->addItem(QObject::tr("Warning"), v(L::Warning)); + ui->lootLogLevel->addItem(QObject::tr("Error"), v(L::Error)); + + const auto sel = settings().diagnostics().lootLogLevel(); + + for (int i = 0; i < ui->lootLogLevel->count(); ++i) { + if (ui->lootLogLevel->itemData(i) == v(sel)) { + ui->lootLogLevel->setCurrentIndex(i); + break; + } + } +} + +void DiagnosticsSettingsTab::setCrashDumpTypesBox() +{ + ui->dumpsTypeBox->clear(); + + auto add = [&](auto&& text, auto&& type) { + ui->dumpsTypeBox->addItem(text, static_cast(type)); + }; + + add(QObject::tr("None"), env::CoreDumpTypes::None); + add(QObject::tr("Mini (recommended)"), env::CoreDumpTypes::Mini); + add(QObject::tr("Data"), env::CoreDumpTypes::Data); + add(QObject::tr("Full"), env::CoreDumpTypes::Full); + + const auto current = static_cast(settings().diagnostics().coreDumpType()); + + for (int i = 0; i < ui->dumpsTypeBox->count(); ++i) { + if (ui->dumpsTypeBox->itemData(i) == current) { + ui->dumpsTypeBox->setCurrentIndex(i); + break; + } + } +} + +void DiagnosticsSettingsTab::update() +{ + settings().diagnostics().setLogLevel( + static_cast(ui->logLevelBox->currentData().toInt())); + + settings().diagnostics().setCoreDumpType( + static_cast(ui->dumpsTypeBox->currentData().toInt())); + + settings().diagnostics().setMaxCoreDumps(ui->dumpsMaxEdit->value()); + + settings().diagnostics().setLootLogLevel( + static_cast(ui->lootLogLevel->currentData().toInt())); +} diff --git a/src/src/settingsdialogdiagnostics.h b/src/src/settingsdialogdiagnostics.h new file mode 100644 index 0000000..0888ced --- /dev/null +++ b/src/src/settingsdialogdiagnostics.h @@ -0,0 +1,20 @@ +#ifndef SETTINGSDIALOGDIAGNOSTICS_H +#define SETTINGSDIALOGDIAGNOSTICS_H + +#include "settings.h" +#include "settingsdialog.h" + +class DiagnosticsSettingsTab : public SettingsTab +{ +public: + DiagnosticsSettingsTab(Settings& settings, SettingsDialog& dialog); + + void update(); + +private: + void setLogLevel(); + void setLootLogLevel(); + void setCrashDumpTypesBox(); +}; + +#endif // SETTINGSDIALOGDIAGNOSTICS_H diff --git a/src/src/settingsdialoggeneral.cpp b/src/src/settingsdialoggeneral.cpp new file mode 100644 index 0000000..9585d3a --- /dev/null +++ b/src/src/settingsdialoggeneral.cpp @@ -0,0 +1,181 @@ +#include "settingsdialoggeneral.h" +#include "categoriesdialog.h" +#include "colortable.h" +#include "shared/appconfig.h" +#include "ui_settingsdialog.h" +#include +#include + +using namespace MOBase; + +GeneralSettingsTab::GeneralSettingsTab(Settings& s, SettingsDialog& d) + : SettingsTab(s, d) +{ + // language + addLanguages(); + selectLanguage(); + + // download list + ui->compactBox->setChecked(settings().interface().compactDownloads()); + ui->showMetaBox->setChecked(settings().interface().metaDownloads()); + ui->hideDownloadInstallBox->setChecked( + settings().interface().hideDownloadsAfterInstallation()); + + // updates + ui->checkForUpdates->setChecked(settings().checkForUpdates()); + ui->usePrereleaseBox->setChecked(settings().usePrereleases()); + + // profile defaults + ui->localINIs->setChecked(settings().profileLocalInis()); + ui->localSaves->setChecked(settings().profileLocalSaves()); + ui->automaticArchiveInvalidation->setChecked(settings().profileArchiveInvalidation()); + + // miscellaneous + ui->centerDialogs->setChecked(settings().geometry().centerDialogs()); + ui->changeGameConfirmation->setChecked( + settings().interface().showChangeGameConfirmation()); + ui->showMenubarOnAlt->setChecked(settings().interface().showMenubarOnAlt()); + ui->doubleClickPreviews->setChecked( + settings().interface().doubleClicksOpenPreviews()); + + QObject::connect(ui->categoriesBtn, &QPushButton::clicked, [&] { + onEditCategories(); + }); + + QObject::connect(ui->resetDialogsButton, &QPushButton::clicked, [&] { + onResetDialogs(); + }); +} + +void GeneralSettingsTab::update() +{ + // language + const QString oldLanguage = settings().interface().language(); + const QString newLanguage = + ui->languageBox->itemData(ui->languageBox->currentIndex()).toString(); + + if (newLanguage != oldLanguage) { + settings().interface().setLanguage(newLanguage); + emit settings().languageChanged(newLanguage); + } + + // download list + settings().interface().setCompactDownloads(ui->compactBox->isChecked()); + settings().interface().setMetaDownloads(ui->showMetaBox->isChecked()); + settings().interface().setHideDownloadsAfterInstallation( + ui->hideDownloadInstallBox->isChecked()); + + // updates + settings().setCheckForUpdates(ui->checkForUpdates->isChecked()); + settings().setUsePrereleases(ui->usePrereleaseBox->isChecked()); + + // profile defaults + settings().setProfileLocalInis(ui->localINIs->isChecked()); + settings().setProfileLocalSaves(ui->localSaves->isChecked()); + settings().setProfileArchiveInvalidation( + ui->automaticArchiveInvalidation->isChecked()); + + // miscellaneous + settings().geometry().setCenterDialogs(ui->centerDialogs->isChecked()); + settings().interface().setShowChangeGameConfirmation( + ui->changeGameConfirmation->isChecked()); + settings().interface().setShowMenubarOnAlt(ui->showMenubarOnAlt->isChecked()); + settings().interface().setDoubleClicksOpenPreviews( + ui->doubleClickPreviews->isChecked()); +} + +void GeneralSettingsTab::addLanguages() +{ + // matches the end of filenames for something like "_en.qm" or "_zh_CN.qm" + const QString pattern = QString::fromStdWString(AppConfig::translationPrefix()) + + "_([a-z]{2,3}(_[A-Z]{2,2})?).qm"; + + const QRegularExpression exp(QRegularExpression::anchoredPattern(pattern)); + + QDirIterator iter(QCoreApplication::applicationDirPath() + "/translations", + QDir::Files); + + std::vector> languages; + + while (iter.hasNext()) { + iter.next(); + + const QString file = iter.fileName(); + auto match = exp.match(file); + if (!match.hasMatch()) { + continue; + } + + const QString languageCode = match.captured(1); + const QLocale locale(languageCode); + + QString languageString = QString("%1 (%2)") + .arg(locale.nativeLanguageName()) + .arg(locale.nativeTerritoryName()); + + if (locale.language() == QLocale::Chinese) { + if (languageCode == "zh_TW") { + languageString = "Chinese (Traditional)"; + } else { + languageString = "Chinese (Simplified)"; + } + } + + languages.push_back({languageString, match.captured(1)}); + } + + if (!ui->languageBox->findText("English")) { + languages.push_back({QString("English"), QString("en_US")}); + } + + std::sort(languages.begin(), languages.end()); + + for (const auto& lang : languages) { + ui->languageBox->addItem(lang.first, lang.second); + } +} + +void GeneralSettingsTab::selectLanguage() +{ + QString languageCode = settings().interface().language(); + int currentID = ui->languageBox->findData(languageCode); + // I made a mess. :( Most languages are stored with only the iso country + // code (2 characters like "de") but chinese + // with the exact language variant (zh_TW) so I have to search for both + // variants + if (currentID == -1) { + currentID = ui->languageBox->findData(languageCode.mid(0, 2)); + } + if (currentID != -1) { + ui->languageBox->setCurrentIndex(currentID); + } +} + +void GeneralSettingsTab::resetDialogs() +{ + settings().widgets().resetQuestionButtons(); + GlobalSettings::resetDialogs(); +} + +void GeneralSettingsTab::onEditCategories() +{ + CategoriesDialog catDialog(&dialog()); + + if (catDialog.exec() == QDialog::Accepted) { + catDialog.commitChanges(); + } +} + +void GeneralSettingsTab::onResetDialogs() +{ + const auto r = QMessageBox::question( + parentWidget(), QObject::tr("Confirm?"), + QObject::tr( + "This will reset all the choices you made to dialogs and make them all " + "visible again. Continue?"), + QMessageBox::Yes | QMessageBox::No); + + if (r == QMessageBox::Yes) { + resetDialogs(); + } +} diff --git a/src/src/settingsdialoggeneral.h b/src/src/settingsdialoggeneral.h new file mode 100644 index 0000000..94fbd9e --- /dev/null +++ b/src/src/settingsdialoggeneral.h @@ -0,0 +1,25 @@ +#ifndef SETTINGSDIALOGGENERAL_H +#define SETTINGSDIALOGGENERAL_H + +#include "plugincontainer.h" +#include "settings.h" +#include "settingsdialog.h" + +class GeneralSettingsTab : public SettingsTab +{ +public: + GeneralSettingsTab(Settings& settings, SettingsDialog& dialog); + + void update(); + +private: + void addLanguages(); + void selectLanguage(); + + void resetDialogs(); + + void onEditCategories(); + void onResetDialogs(); +}; + +#endif // SETTINGSDIALOGGENERAL_H diff --git a/src/src/settingsdialogmodlist.cpp b/src/src/settingsdialogmodlist.cpp new file mode 100644 index 0000000..8e93767 --- /dev/null +++ b/src/src/settingsdialogmodlist.cpp @@ -0,0 +1,85 @@ +#include "settingsdialogmodlist.h" +#include "categoriesdialog.h" +#include "colortable.h" +#include "modlist.h" +#include "shared/appconfig.h" +#include "ui_settingsdialog.h" +#include +#include + +using namespace MOBase; + +ModListSettingsTab::ModListSettingsTab(Settings& s, SettingsDialog& d) + : SettingsTab(s, d), + m_columnToBox{ + {ModList::COL_CONFLICTFLAGS, ui->collapsibleSeparatorsIconsConflictsBox}, + {ModList::COL_FLAGS, ui->collapsibleSeparatorsIconsFlagsBox}, + {ModList::COL_CONTENT, ui->collapsibleSeparatorsIconsContentsBox}, + {ModList::COL_VERSION, ui->collapsibleSeparatorsIconsVersionBox}} +{ + // connect before setting to trigger + QObject::connect(ui->collapsibleSeparatorsAscBox, &QCheckBox::toggled, [=] { + updateCollapsibleSeparatorsGroup(); + }); + QObject::connect(ui->collapsibleSeparatorsDscBox, &QCheckBox::toggled, [=] { + updateCollapsibleSeparatorsGroup(); + }); + + ui->colorSeparatorsBox->setChecked(settings().colors().colorSeparatorScrollbar()); + ui->displayForeignBox->setChecked(settings().interface().displayForeign()); + ui->collapsibleSeparatorsAscBox->setChecked( + settings().interface().collapsibleSeparators(Qt::AscendingOrder)); + ui->collapsibleSeparatorsDscBox->setChecked( + settings().interface().collapsibleSeparators(Qt::DescendingOrder)); + ui->collapsibleSeparatorsHighlightFromBox->setChecked( + settings().interface().collapsibleSeparatorsHighlightFrom()); + ui->collapsibleSeparatorsHighlightToBox->setChecked( + settings().interface().collapsibleSeparatorsHighlightTo()); + ui->collapsibleSeparatorsPerProfileBox->setChecked( + settings().interface().collapsibleSeparatorsPerProfile()); + ui->saveFiltersBox->setChecked(settings().interface().saveFilters()); + ui->autoCollapseDelayBox->setChecked(settings().interface().autoCollapseOnHover()); + ui->checkUpdateInstallBox->setChecked( + settings().interface().checkUpdateAfterInstallation()); + + for (auto& p : m_columnToBox) { + p.second->setChecked(settings().interface().collapsibleSeparatorsIcons(p.first)); + } +} + +void ModListSettingsTab::update() +{ + // mod list + settings().colors().setColorSeparatorScrollbar(ui->colorSeparatorsBox->isChecked()); + settings().interface().setDisplayForeign(ui->displayForeignBox->isChecked()); + settings().interface().setCollapsibleSeparators( + ui->collapsibleSeparatorsAscBox->isChecked(), + ui->collapsibleSeparatorsDscBox->isChecked()); + settings().interface().setCollapsibleSeparatorsHighlightFrom( + ui->collapsibleSeparatorsHighlightFromBox->isChecked()); + settings().interface().setCollapsibleSeparatorsHighlightTo( + ui->collapsibleSeparatorsHighlightToBox->isChecked()); + settings().interface().setCollapsibleSeparatorsPerProfile( + ui->collapsibleSeparatorsPerProfileBox->isChecked()); + settings().interface().setSaveFilters(ui->saveFiltersBox->isChecked()); + settings().interface().setAutoCollapseOnHover(ui->autoCollapseDelayBox->isChecked()); + settings().interface().setCheckUpdateAfterInstallation( + ui->checkUpdateInstallBox->isChecked()); + + for (auto& p : m_columnToBox) { + settings().interface().setCollapsibleSeparatorsIcons(p.first, + p.second->isChecked()); + } +} + +void ModListSettingsTab::updateCollapsibleSeparatorsGroup() +{ + const auto checked = ui->collapsibleSeparatorsAscBox->isChecked() || + ui->collapsibleSeparatorsDscBox->isChecked(); + for (auto* widget : ui->collapsibleSeparatorsWidget->findChildren()) { + widget->setEnabled(checked); + } + ui->collapsibleSeparatorsLabel->setEnabled(true); + ui->collapsibleSeparatorsAscBox->setEnabled(true); + ui->collapsibleSeparatorsDscBox->setEnabled(true); +} diff --git a/src/src/settingsdialogmodlist.h b/src/src/settingsdialogmodlist.h new file mode 100644 index 0000000..860feba --- /dev/null +++ b/src/src/settingsdialogmodlist.h @@ -0,0 +1,26 @@ +#ifndef SETTINGSDIALOGMODLIST_H +#define SETTINGSDIALOGMODLIST_H + +#include + +#include "settings.h" +#include "settingsdialog.h" + +class ModListSettingsTab : public SettingsTab +{ +public: + ModListSettingsTab(Settings& settings, SettingsDialog& dialog); + + void update() override; + +protected slots: + + // enable/disable the collapsible separators group depending on + // the checkbox states + void updateCollapsibleSeparatorsGroup(); + +private: + const std::map m_columnToBox; +}; + +#endif // SETTINGSDIALOGGENERAL_H diff --git a/src/src/settingsdialognexus.cpp b/src/src/settingsdialognexus.cpp new file mode 100644 index 0000000..5ca69a1 --- /dev/null +++ b/src/src/settingsdialognexus.cpp @@ -0,0 +1,469 @@ +#include "settingsdialognexus.h" +#include "log.h" +#include "nexusinterface.h" +#include "serverinfo.h" +#include "ui_nexusmanualkey.h" +#include "ui_settingsdialog.h" +#include + +using namespace MOBase; + +template +class ServerItem : public QListWidgetItem +{ +public: + ServerItem(const QString& text, int sortRole = Qt::DisplayRole, + QListWidget* parent = 0, int type = Type) + : QListWidgetItem(text, parent, type), m_SortRole(sortRole) + {} + + virtual bool operator<(const QListWidgetItem& other) const + { + return this->data(m_SortRole).value() < other.data(m_SortRole).value(); + } + +private: + int m_SortRole; +}; + +class NexusManualKeyDialog : public QDialog +{ +public: + NexusManualKeyDialog(QWidget* parent) + : QDialog(parent), ui(new Ui::NexusManualKeyDialog) + { + ui->setupUi(this); + ui->key->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + + connect(ui->openBrowser, &QPushButton::clicked, [&] { + openBrowser(); + }); + connect(ui->paste, &QPushButton::clicked, [&] { + paste(); + }); + connect(ui->clear, &QPushButton::clicked, [&] { + clear(); + }); + } + + void accept() override + { + m_key = ui->key->toPlainText(); + QDialog::accept(); + } + + const QString& key() const { return m_key; } + + void openBrowser() + { + shell::Open(QUrl("https://www.nexusmods.com/users/myaccount?tab=api")); + } + + void paste() + { + const auto text = QApplication::clipboard()->text(); + if (!text.isEmpty()) { + ui->key->setPlainText(text); + } + } + + void clear() { ui->key->clear(); } + +private: + std::unique_ptr ui; + QString m_key; +}; + +NexusConnectionUI::NexusConnectionUI(QWidget* parent, Settings* s, + QAbstractButton* connectButton, + QAbstractButton* disconnectButton, + QAbstractButton* manualButton, + QListWidget* logList) + : m_parent(parent), m_settings(s), m_connect(connectButton), + m_disconnect(disconnectButton), m_manual(manualButton), m_log(logList) +{ + if (m_connect) { + QObject::connect(m_connect, &QPushButton::clicked, [&] { + connect(); + }); + } + + if (m_disconnect) { + QObject::connect(m_disconnect, &QPushButton::clicked, [&] { + disconnect(); + }); + } + + if (m_manual) { + QObject::connect(manualButton, &QPushButton::clicked, [&] { + manual(); + }); + } + + if (GlobalSettings::hasNexusApiKey()) { + addLog(tr("Connected.")); + } else { + addLog(tr("Not connected.")); + } + + updateState(); +} + +void NexusConnectionUI::connect() +{ + if (m_nexusLogin && m_nexusLogin->isActive()) { + m_nexusLogin->cancel(); + return; + } + + if (!m_nexusLogin) { + m_nexusLogin.reset(new NexusSSOLogin); + + m_nexusLogin->keyChanged = [&](auto&& s) { + onSSOKeyChanged(s); + }; + + m_nexusLogin->stateChanged = [&](auto&& s, auto&& e) { + onSSOStateChanged(s, e); + }; + } + + m_log->clear(); + m_nexusLogin->start(); + updateState(); +} + +void NexusConnectionUI::manual() +{ + if (m_nexusValidator && m_nexusValidator->isActive()) { + m_nexusValidator->cancel(); + return; + } + + NexusManualKeyDialog d(m_parent); + if (d.exec() != QDialog::Accepted) { + return; + } + + const auto key = d.key(); + if (key.isEmpty()) { + clearKey(); + return; + } + + m_log->clear(); + validateKey(key); +} + +void NexusConnectionUI::disconnect() +{ + clearKey(); + m_log->clear(); + addLog(tr("Disconnected.")); +} + +void NexusConnectionUI::validateKey(const QString& key) +{ + if (!m_nexusValidator) { + m_nexusValidator.reset(new NexusKeyValidator( + m_settings, *NexusInterface::instance().getAccessManager())); + + m_nexusValidator->finished = [&](auto&& r, auto&& m, auto&& u) { + onValidatorFinished(r, m, u); + }; + } + + addLog(tr("Checking API key...")); + m_nexusValidator->start(key, NexusKeyValidator::OneShot); +} + +void NexusConnectionUI::onSSOKeyChanged(const QString& key) +{ + if (key.isEmpty()) { + clearKey(); + } else { + addLog(tr("Received API key.")); + validateKey(key); + } +} + +void NexusConnectionUI::onSSOStateChanged(NexusSSOLogin::States s, const QString& e) +{ + if (s != NexusSSOLogin::Finished) { + // finished state is handled in onSSOKeyChanged() + const auto log = NexusSSOLogin::stateToString(s, e); + + for (auto&& line : log.split("\n")) { + addLog(line); + } + } + + updateState(); +} + +void NexusConnectionUI::onValidatorFinished(ValidationAttempt::Result r, + const QString& message, + std::optional user) +{ + if (user) { + NexusInterface::instance().setUserAccount(*user); + addLog(tr("Received user account information")); + + if (setKey(user->apiKey())) { + addLog(tr("Linked with Nexus successfully.")); + } else { + addLog(tr("Failed to set API key")); + } + } else { + if (message.isEmpty()) { + // shouldn't happen + addLog("Unknown error"); + } else { + addLog(message); + } + } + + updateState(); +} + +void NexusConnectionUI::addLog(const QString& s) +{ + m_log->addItem(s); + m_log->scrollToBottom(); +} + +bool NexusConnectionUI::setKey(const QString& key) +{ + const bool ret = GlobalSettings::setNexusApiKey(key); + updateState(); + + emit keyChanged(); + + return ret; +} + +bool NexusConnectionUI::clearKey() +{ + const auto ret = GlobalSettings::clearNexusApiKey(); + + NexusInterface::instance().getAccessManager()->clearApiKey(); + updateState(); + + emit keyChanged(); + + return ret; +} + +void NexusConnectionUI::updateState() +{ + auto setButton = [&](QAbstractButton* b, bool enabled, QString caption = {}) { + if (b) { + b->setEnabled(enabled); + if (!caption.isEmpty()) { + b->setText(caption); + } + } + }; + + if (m_nexusLogin && m_nexusLogin->isActive()) { + // api key is in the process of being retrieved + setButton(m_connect, true, QObject::tr("Cancel")); + setButton(m_disconnect, false); + setButton(m_manual, false, QObject::tr("Enter API Key Manually")); + } else if (m_nexusValidator && m_nexusValidator->isActive()) { + // api key is in the process of being tested + setButton(m_connect, false, QObject::tr("Connect to Nexus")); + setButton(m_disconnect, false); + setButton(m_manual, true, QObject::tr("Cancel")); + } else if (GlobalSettings::hasNexusApiKey()) { + // api key is present + setButton(m_connect, false, QObject::tr("Connect to Nexus")); + setButton(m_disconnect, true); + setButton(m_manual, false, QObject::tr("Enter API Key Manually")); + } else { + // api key not present + setButton(m_connect, true, QObject::tr("Connect to Nexus")); + setButton(m_disconnect, false); + setButton(m_manual, true, QObject::tr("Enter API Key Manually")); + } + + emit stateChanged(); +} + +NexusSettingsTab::NexusSettingsTab(Settings& s, SettingsDialog& d) : SettingsTab(s, d) +{ + ui->endorsementBox->setChecked(settings().nexus().endorsementIntegration()); + ui->trackedBox->setChecked(settings().nexus().trackedIntegration()); + ui->categoryMappingsBox->setChecked(settings().nexus().categoryMappings()); + ui->hideAPICounterBox->setChecked(settings().interface().hideAPICounter()); + + // display server preferences + for (const auto& server : s.network().servers()) { + QString descriptor = server.name(); + + if (!descriptor.compare("CDN", Qt::CaseInsensitive)) { + descriptor += QStringLiteral(" (automatic)"); + } + + const auto averageSpeed = server.averageSpeed(); + if (averageSpeed > 0) { + descriptor += QString(" (%1)").arg(MOBase::localizedByteSpeed(averageSpeed)); + } + + QListWidgetItem* newItem = new ServerItem(descriptor, Qt::UserRole + 1); + + newItem->setData(Qt::UserRole, server.name()); + newItem->setData(Qt::UserRole + 1, server.preferred()); + + if (server.preferred() > 0) { + ui->preferredServersList->addItem(newItem); + } else { + ui->knownServersList->addItem(newItem); + } + + ui->preferredServersList->sortItems(Qt::DescendingOrder); + } + + m_connectionUI.reset(new NexusConnectionUI(&dialog(), &settings(), ui->nexusConnect, + ui->nexusDisconnect, ui->nexusManualKey, + ui->nexusLog)); + + QObject::connect( + m_connectionUI.get(), &NexusConnectionUI::stateChanged, &d, + [&] { + updateNexusData(); + }, + Qt::QueuedConnection); + + QObject::connect(m_connectionUI.get(), &NexusConnectionUI::keyChanged, &d, [&] { + dialog().setExitNeeded(Exit::Restart); + }); + + QObject::connect(ui->clearCacheButton, &QPushButton::clicked, [&] { + clearCache(); + }); + QObject::connect(ui->associateButton, &QPushButton::clicked, [&] { + associate(); + }); + QObject::connect(ui->useCustomBrowser, &QCheckBox::clicked, [&] { + updateCustomBrowser(); + }); + QObject::connect(ui->browseCustomBrowser, &QPushButton::clicked, [&] { + browseCustomBrowser(); + }); + + updateNexusData(); + updateCustomBrowser(); +} + +void NexusSettingsTab::update() +{ + settings().nexus().setEndorsementIntegration(ui->endorsementBox->isChecked()); + settings().nexus().setTrackedIntegration(ui->trackedBox->isChecked()); + settings().nexus().setCategoryMappings(ui->categoryMappingsBox->isChecked()); + settings().interface().setHideAPICounter(ui->hideAPICounterBox->isChecked()); + + auto servers = settings().network().servers(); + + // store server preference + for (int i = 0; i < ui->knownServersList->count(); ++i) { + const QString key = ui->knownServersList->item(i)->data(Qt::UserRole).toString(); + + bool found = false; + + for (auto& server : servers) { + if (server.name() == key) { + server.setPreferred(0); + found = true; + break; + } + } + + if (!found) { + log::error("while setting preferred to 0, server '{}' not found", key); + } + } + + const int count = ui->preferredServersList->count(); + + for (int i = 0; i < count; ++i) { + const QString key = + ui->preferredServersList->item(i)->data(Qt::UserRole).toString(); + const int newPreferred = count - i; + + bool found = false; + + for (auto& server : servers) { + + if (server.name() == key) { + server.setPreferred(newPreferred); + found = true; + break; + } + } + + if (!found) { + log::error("while setting preference to {}, server '{}' not found", newPreferred, + key); + } + } + + settings().network().updateServers(servers); +} + +void NexusSettingsTab::clearCache() +{ + QDir(Settings::instance().paths().cache()).removeRecursively(); + NexusInterface::instance().clearCache(); +} + +void NexusSettingsTab::associate() +{ + Settings::instance().nexus().registerAsNXMHandler(true); +} + +void NexusSettingsTab::updateNexusData() +{ + const auto user = NexusInterface::instance().getAPIUserAccount(); + + if (user.isValid()) { + ui->nexusUserID->setText(user.id()); + ui->nexusName->setText(user.name()); + ui->nexusAccount->setText(localizedUserAccountType(user.type())); + + ui->nexusDailyRequests->setText(QString("%1/%2") + .arg(user.limits().remainingDailyRequests) + .arg(user.limits().maxDailyRequests)); + + ui->nexusHourlyRequests->setText(QString("%1/%2") + .arg(user.limits().remainingHourlyRequests) + .arg(user.limits().maxHourlyRequests)); + } else { + ui->nexusUserID->setText(QObject::tr("N/A")); + ui->nexusName->setText(QObject::tr("N/A")); + ui->nexusAccount->setText(QObject::tr("N/A")); + ui->nexusDailyRequests->setText(QObject::tr("N/A")); + ui->nexusHourlyRequests->setText(QObject::tr("N/A")); + } +} + +void NexusSettingsTab::updateCustomBrowser() +{ + ui->browserCommand->setEnabled(ui->useCustomBrowser->isChecked()); +} + +void NexusSettingsTab::browseCustomBrowser() +{ + const QString Filters = + QObject::tr("Executables (*.exe)") + ";;" + QObject::tr("All Files (*.*)"); + + QString file = QFileDialog::getOpenFileName( + parentWidget(), QObject::tr("Select the browser executable"), + ui->browserCommand->text(), Filters); + + if (file.isNull() || file == "") { + return; + } + + ui->browserCommand->setText(file + " \"%1\""); +} diff --git a/src/src/settingsdialognexus.h b/src/src/settingsdialognexus.h new file mode 100644 index 0000000..067638a --- /dev/null +++ b/src/src/settingsdialognexus.h @@ -0,0 +1,70 @@ +#ifndef SETTINGSDIALOGNEXUS_H +#define SETTINGSDIALOGNEXUS_H + +#include "nxmaccessmanager.h" +#include "settings.h" +#include "settingsdialog.h" + +// used by the settings dialog and the create instance dialog +// +class NexusConnectionUI : public QObject +{ + Q_OBJECT; + +public: + NexusConnectionUI(QWidget* parent, Settings* s, QAbstractButton* connectButton, + QAbstractButton* disconnectButton, QAbstractButton* manualButton, + QListWidget* logList); + + void connect(); + void manual(); + void disconnect(); + +signals: + void stateChanged(); + void keyChanged(); + +private: + QWidget* m_parent; + Settings* m_settings; + QAbstractButton* m_connect; + QAbstractButton* m_disconnect; + QAbstractButton* m_manual; + QListWidget* m_log; + + std::unique_ptr m_nexusLogin; + std::unique_ptr m_nexusValidator; + + void addLog(const QString& s); + + void updateState(); + + void validateKey(const QString& key); + bool setKey(const QString& key); + bool clearKey(); + + void onSSOKeyChanged(const QString& key); + void onSSOStateChanged(NexusSSOLogin::States s, const QString& e); + + void onValidatorFinished(ValidationAttempt::Result r, const QString& message, + std::optional useR); +}; + +class NexusSettingsTab : public SettingsTab +{ +public: + NexusSettingsTab(Settings& settings, SettingsDialog& dialog); + void update(); + +private: + std::unique_ptr m_connectionUI; + + void clearCache(); + void associate(); + + void updateNexusData(); + void updateCustomBrowser(); + void browseCustomBrowser(); +}; + +#endif // SETTINGSDIALOGNEXUS_H diff --git a/src/src/settingsdialogpaths.cpp b/src/src/settingsdialogpaths.cpp new file mode 100644 index 0000000..1e48523 --- /dev/null +++ b/src/src/settingsdialogpaths.cpp @@ -0,0 +1,281 @@ +#include "settingsdialogpaths.h" +#include "shared/appconfig.h" +#include "ui_settingsdialog.h" +#include + +PathsSettingsTab::PathsSettingsTab(Settings& s, SettingsDialog& d) + : SettingsTab(s, d), m_gameDir(settings().game().plugin()->gameDirectory()) +{ + ui->baseDirEdit->setText(settings().paths().base()); + + ui->managedGameDirEdit->setText(QDir::toNativeSeparators( + m_gameDir.absoluteFilePath(settings().game().plugin()->binaryName()))); + + QString basePath = settings().paths().base(); + QDir baseDir(basePath); + + for (const auto& dir : + {std::make_pair(ui->downloadDirEdit, settings().paths().downloads(false)), + std::make_pair(ui->modDirEdit, settings().paths().mods(false)), + std::make_pair(ui->cacheDirEdit, settings().paths().cache(false)), + std::make_pair(ui->profilesDirEdit, settings().paths().profiles(false)), + std::make_pair(ui->overwriteDirEdit, settings().paths().overwrite(false))}) { + + QString storePath = baseDir.relativeFilePath(dir.second); + storePath = dir.second; + dir.first->setText(storePath); + } + + QObject::connect(ui->browseBaseDirBtn, &QPushButton::clicked, [&] { + on_browseBaseDirBtn_clicked(); + }); + QObject::connect(ui->browseCacheDirBtn, &QPushButton::clicked, [&] { + on_browseCacheDirBtn_clicked(); + }); + QObject::connect(ui->browseDownloadDirBtn, &QPushButton::clicked, [&] { + on_browseDownloadDirBtn_clicked(); + }); + QObject::connect(ui->browseGameDirBtn, &QPushButton::clicked, [&] { + on_browseGameDirBtn_clicked(); + }); + QObject::connect(ui->browseModDirBtn, &QPushButton::clicked, [&] { + on_browseModDirBtn_clicked(); + }); + QObject::connect(ui->browseOverwriteDirBtn, &QPushButton::clicked, [&] { + on_browseOverwriteDirBtn_clicked(); + }); + QObject::connect(ui->browseProfilesDirBtn, &QPushButton::clicked, [&] { + on_browseProfilesDirBtn_clicked(); + }); + + QObject::connect(ui->baseDirEdit, &QLineEdit::editingFinished, [&] { + on_baseDirEdit_editingFinished(); + }); + QObject::connect(ui->cacheDirEdit, &QLineEdit::editingFinished, [&] { + on_cacheDirEdit_editingFinished(); + }); + QObject::connect(ui->downloadDirEdit, &QLineEdit::editingFinished, [&] { + on_downloadDirEdit_editingFinished(); + }); + QObject::connect(ui->modDirEdit, &QLineEdit::editingFinished, [&] { + on_modDirEdit_editingFinished(); + }); + QObject::connect(ui->overwriteDirEdit, &QLineEdit::editingFinished, [&] { + on_overwriteDirEdit_editingFinished(); + }); + QObject::connect(ui->profilesDirEdit, &QLineEdit::editingFinished, [&] { + on_profilesDirEdit_editingFinished(); + }); +} + +void PathsSettingsTab::update() +{ + using Setter = void (PathSettings::*)(const QString&); + using Directory = std::tuple; + + QString basePath = settings().paths().base(); + + for (const Directory& dir : + {Directory{ui->downloadDirEdit->text(), &PathSettings::setDownloads, + AppConfig::downloadPath()}, + Directory{ui->cacheDirEdit->text(), &PathSettings::setCache, + AppConfig::cachePath()}, + Directory{ui->modDirEdit->text(), &PathSettings::setMods, + AppConfig::modsPath()}, + Directory{ui->overwriteDirEdit->text(), &PathSettings::setOverwrite, + AppConfig::overwritePath()}, + Directory{ui->profilesDirEdit->text(), &PathSettings::setProfiles, + AppConfig::profilesPath()}}) { + QString path; + Setter setter; + std::wstring defaultName; + std::tie(path, setter, defaultName) = dir; + + QString realPath = path; + realPath = PathSettings::resolve(realPath, ui->baseDirEdit->text()); + + if (!QDir(realPath).exists()) { + if (!QDir().mkpath(realPath)) { + QMessageBox::warning( + parentWidget(), QObject::tr("Error"), + QObject::tr("Failed to create \"%1\", you may not have the " + "necessary permissions. Path remains unchanged.") + .arg(realPath)); + + continue; + } + } + + if (QFileInfo(realPath) != + QFileInfo(basePath + "/" + QString::fromStdWString(defaultName))) { + (settings().paths().*setter)(path); + } else { + (settings().paths().*setter)(""); + } + } + + if (QFileInfo(ui->baseDirEdit->text()) != + QFileInfo(qApp->property("dataPath").toString())) { + settings().paths().setBase(ui->baseDirEdit->text()); + } else { + settings().paths().setBase(""); + } + + if (m_gameDir != settings().game().plugin()->gameDirectory()) { + settings().game().setDirectory(m_gameDir.absolutePath()); + } +} + +void PathsSettingsTab::on_browseBaseDirBtn_clicked() +{ + QString temp = QFileDialog::getExistingDirectory( + &dialog(), QObject::tr("Select base directory"), ui->baseDirEdit->text()); + if (!temp.isEmpty()) { + ui->baseDirEdit->setText(temp); + } +} + +void PathsSettingsTab::on_browseDownloadDirBtn_clicked() +{ + QString searchPath = ui->downloadDirEdit->text(); + searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text()); + + QString temp = QFileDialog::getExistingDirectory( + &dialog(), QObject::tr("Select download directory"), searchPath); + if (!temp.isEmpty()) { + ui->downloadDirEdit->setText(temp); + } +} + +void PathsSettingsTab::on_browseModDirBtn_clicked() +{ + QString searchPath = ui->modDirEdit->text(); + searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text()); + + QString temp = QFileDialog::getExistingDirectory( + &dialog(), QObject::tr("Select mod directory"), searchPath); + if (!temp.isEmpty()) { + ui->modDirEdit->setText(temp); + } +} + +void PathsSettingsTab::on_browseCacheDirBtn_clicked() +{ + QString searchPath = ui->cacheDirEdit->text(); + searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text()); + + QString temp = QFileDialog::getExistingDirectory( + &dialog(), QObject::tr("Select cache directory"), searchPath); + if (!temp.isEmpty()) { + ui->cacheDirEdit->setText(temp); + } +} + +void PathsSettingsTab::on_browseProfilesDirBtn_clicked() +{ + QString searchPath = ui->profilesDirEdit->text(); + searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text()); + + QString temp = QFileDialog::getExistingDirectory( + &dialog(), QObject::tr("Select profiles directory"), searchPath); + if (!temp.isEmpty()) { + ui->profilesDirEdit->setText(temp); + } +} + +void PathsSettingsTab::on_browseOverwriteDirBtn_clicked() +{ + QString searchPath = ui->overwriteDirEdit->text(); + searchPath = PathSettings::resolve(searchPath, ui->baseDirEdit->text()); + + QString temp = QFileDialog::getExistingDirectory( + &dialog(), QObject::tr("Select overwrite directory"), searchPath); + if (!temp.isEmpty()) { + ui->overwriteDirEdit->setText(temp); + } +} + +void PathsSettingsTab::on_browseGameDirBtn_clicked() +{ + QFileInfo oldGameExe(ui->managedGameDirEdit->text()); + + // this gets the name of the game executable + // + // spaces in the name are interpreted as separators ";" in the filter, + // so we replace them with the single character matching wildcard "?" + // + QString temp = QFileDialog::getOpenFileName( + &dialog(), QObject::tr("Select game executable"), oldGameExe.absolutePath(), + oldGameExe.fileName().replace(QRegularExpression(" "), "?")); + + if (temp.isEmpty()) { + return; + } + + // we need to find the game folder corresponding to the executable + // + // some game plugins have executable in subfolder, e.g. bin/game.exe, + // so we need to go up the parent folders until the concatenation of + // the folder and the binary path equals the game executable specified + // by the user + // + QFileInfo newExe(temp); + const auto binaryPath = settings().game().plugin()->binaryName(); + QDir folder = newExe.absoluteDir(); + while (folder.exists() && !folder.isRoot() && + QFileInfo(folder.filePath(binaryPath)) != newExe) { + folder.cdUp(); + } + + if (folder.exists(binaryPath)) { + m_gameDir = folder; + ui->managedGameDirEdit->setText(QDir::toNativeSeparators( + m_gameDir.absoluteFilePath(settings().game().plugin()->binaryName()))); + } else { + QMessageBox::warning( + parentWidget(), QObject::tr("Error"), + QObject::tr("The given path was not recognized as a valid game installation. " + "The current game plugin requires the executable to be in a \"%1\" " + "subfolder of the game directory.") + .arg(QFileInfo(binaryPath).path())); + } +} + +void PathsSettingsTab::on_baseDirEdit_editingFinished() +{ + normalizePath(ui->baseDirEdit); +} + +void PathsSettingsTab::on_downloadDirEdit_editingFinished() +{ + normalizePath(ui->downloadDirEdit); +} + +void PathsSettingsTab::on_modDirEdit_editingFinished() +{ + normalizePath(ui->modDirEdit); +} + +void PathsSettingsTab::on_cacheDirEdit_editingFinished() +{ + normalizePath(ui->cacheDirEdit); +} + +void PathsSettingsTab::on_profilesDirEdit_editingFinished() +{ + normalizePath(ui->profilesDirEdit); +} + +void PathsSettingsTab::on_overwriteDirEdit_editingFinished() +{ + normalizePath(ui->overwriteDirEdit); +} + +void PathsSettingsTab::normalizePath(QLineEdit* lineEdit) +{ + QString text = lineEdit->text(); + while (text.endsWith('/') || text.endsWith('\\')) { + text.chop(1); + } + lineEdit->setText(text); +} diff --git a/src/src/settingsdialogpaths.h b/src/src/settingsdialogpaths.h new file mode 100644 index 0000000..287e891 --- /dev/null +++ b/src/src/settingsdialogpaths.h @@ -0,0 +1,34 @@ +#ifndef SETTINGSDIALOGPATHS_H +#define SETTINGSDIALOGPATHS_H + +#include "settings.h" +#include "settingsdialog.h" + +class PathsSettingsTab : public SettingsTab +{ +public: + PathsSettingsTab(Settings& settings, SettingsDialog& dialog); + void update(); + +private: + void on_browseBaseDirBtn_clicked(); + void on_browseCacheDirBtn_clicked(); + void on_browseDownloadDirBtn_clicked(); + void on_browseGameDirBtn_clicked(); + void on_browseModDirBtn_clicked(); + void on_browseOverwriteDirBtn_clicked(); + void on_browseProfilesDirBtn_clicked(); + + void on_baseDirEdit_editingFinished(); + void on_cacheDirEdit_editingFinished(); + void on_downloadDirEdit_editingFinished(); + void on_modDirEdit_editingFinished(); + void on_overwriteDirEdit_editingFinished(); + void on_profilesDirEdit_editingFinished(); + + void normalizePath(QLineEdit* lineEdit); + + QDir m_gameDir; +}; + +#endif // SETTINGSDIALOGPATHS_H diff --git a/src/src/settingsdialogplugins.cpp b/src/src/settingsdialogplugins.cpp new file mode 100644 index 0000000..409c5f3 --- /dev/null +++ b/src/src/settingsdialogplugins.cpp @@ -0,0 +1,380 @@ +#include "settingsdialogplugins.h" +#include "noeditdelegate.h" +#include "ui_settingsdialog.h" +#include + +#include "disableproxyplugindialog.h" +#include "organizercore.h" +#include "plugincontainer.h" + +using namespace MOBase; + +PluginsSettingsTab::PluginsSettingsTab(Settings& s, PluginContainer* pluginContainer, + SettingsDialog& d) + : SettingsTab(s, d), m_pluginContainer(pluginContainer) +{ + ui->pluginSettingsList->setStyleSheet("QTreeWidget::item {padding-right: 10px;}"); + + // Create top-level tree widget: + QStringList pluginInterfaces = m_pluginContainer->pluginInterfaces(); + pluginInterfaces.sort(Qt::CaseInsensitive); + std::map topItems; + for (QString interfaceName : pluginInterfaces) { + auto* item = new QTreeWidgetItem(ui->pluginsList, {interfaceName}); + item->setFlags(item->flags() & ~Qt::ItemIsSelectable); + auto font = item->font(0); + font.setBold(true); + item->setFont(0, font); + topItems[interfaceName] = item; + item->setExpanded(true); + item->setFlags(item->flags() & ~Qt::ItemIsSelectable); + } + ui->pluginsList->setHeaderHidden(true); + + // display plugin settings + QSet handledNames; + for (IPlugin* plugin : settings().plugins().plugins()) { + if (handledNames.contains(plugin->name()) || + m_pluginContainer->requirements(plugin).master()) { + continue; + } + + QTreeWidgetItem* listItem = new QTreeWidgetItem( + topItems.at(m_pluginContainer->topImplementedInterface(plugin))); + listItem->setData(0, Qt::DisplayRole, plugin->localizedName()); + listItem->setData(0, PluginRole, QVariant::fromValue((void*)plugin)); + listItem->setData(0, SettingsRole, settings().plugins().settings(plugin->name())); + listItem->setData(0, DescriptionsRole, + settings().plugins().descriptions(plugin->name())); + + // Handle child item: + auto children = m_pluginContainer->requirements(plugin).children(); + for (auto* child : children) { + QTreeWidgetItem* childItem = new QTreeWidgetItem(listItem); + childItem->setData(0, Qt::DisplayRole, child->localizedName()); + childItem->setData(0, PluginRole, QVariant::fromValue((void*)child)); + childItem->setData(0, SettingsRole, settings().plugins().settings(child->name())); + childItem->setData(0, DescriptionsRole, + settings().plugins().descriptions(child->name())); + + handledNames.insert(child->name()); + } + + handledNames.insert(plugin->name()); + } + + for (auto& [k, item] : topItems) { + if (item->childCount() == 0) { + item->setHidden(true); + } + } + + ui->pluginsList->sortByColumn(0, Qt::AscendingOrder); + + // display plugin blacklist + for (const QString& pluginName : settings().plugins().blacklist()) { + ui->pluginBlacklist->addItem(pluginName); + } + + m_filter.setEdit(ui->pluginFilterEdit); + + QObject::connect(ui->pluginsList, &QTreeWidget::currentItemChanged, + [&](auto* current, auto* previous) { + on_pluginsList_currentItemChanged(current, previous); + }); + QObject::connect(ui->enabledCheckbox, &QCheckBox::clicked, [&](bool checked) { + on_checkboxEnabled_clicked(checked); + }); + + QShortcut* delShortcut = + new QShortcut(QKeySequence(Qt::Key_Delete), ui->pluginBlacklist); + QObject::connect(delShortcut, &QShortcut::activated, &dialog(), [&] { + deleteBlacklistItem(); + }); + QObject::connect(&m_filter, &FilterWidget::changed, [&] { + filterPluginList(); + }); + + updateListItems(); + filterPluginList(); +} + +void PluginsSettingsTab::updateListItems() +{ + for (auto i = 0; i < ui->pluginsList->topLevelItemCount(); ++i) { + auto* topLevelItem = ui->pluginsList->topLevelItem(i); + for (auto j = 0; j < topLevelItem->childCount(); ++j) { + auto* item = topLevelItem->child(j); + auto* plugin = this->plugin(item); + + bool inactive = !m_pluginContainer->implementInterface(plugin) && + !m_pluginContainer->isEnabled(plugin); + + auto font = item->font(0); + font.setItalic(inactive); + item->setFont(0, font); + for (auto k = 0; k < item->childCount(); ++k) { + item->child(k)->setFont(0, font); + } + } + } +} + +void PluginsSettingsTab::filterPluginList() +{ + auto selectedItems = ui->pluginsList->selectedItems(); + QTreeWidgetItem* firstNotHidden = nullptr; + + for (auto i = 0; i < ui->pluginsList->topLevelItemCount(); ++i) { + auto* topLevelItem = ui->pluginsList->topLevelItem(i); + + bool found = false; + for (auto j = 0; j < topLevelItem->childCount(); ++j) { + auto* item = topLevelItem->child(j); + auto* plugin = this->plugin(item); + + // Check the item or the child - If any match (item or child), the whole + // group is displayed. + bool match = m_filter.matches([plugin](const QRegularExpression& regex) { + return regex.match(plugin->localizedName()).hasMatch(); + }); + for (auto* child : m_pluginContainer->requirements(plugin).children()) { + match = match || m_filter.matches([child](const QRegularExpression& regex) { + return regex.match(child->localizedName()).hasMatch(); + }); + } + + if (match) { + found = true; + item->setHidden(false); + + if (firstNotHidden == nullptr) { + firstNotHidden = item; + } + } else { + item->setHidden(true); + } + } + + topLevelItem->setHidden(!found); + } + + // Unselect item if hidden: + if (firstNotHidden) { + ui->pluginDescription->setVisible(true); + ui->pluginSettingsList->setVisible(true); + ui->noPluginLabel->setVisible(false); + if (selectedItems.isEmpty()) { + ui->pluginsList->setCurrentItem(firstNotHidden); + } else if (selectedItems[0]->isHidden()) { + ui->pluginsList->setCurrentItem(firstNotHidden); + } + } else { + ui->pluginDescription->setVisible(false); + ui->pluginSettingsList->setVisible(false); + ui->noPluginLabel->setVisible(true); + } +} + +IPlugin* PluginsSettingsTab::plugin(QTreeWidgetItem* pluginItem) const +{ + return static_cast(qvariant_cast(pluginItem->data(0, PluginRole))); +} + +void PluginsSettingsTab::update() +{ + // transfer plugin settings to in-memory structure + for (int i = 0; i < ui->pluginsList->topLevelItemCount(); ++i) { + auto* topLevelItem = ui->pluginsList->topLevelItem(i); + for (int j = 0; j < topLevelItem->childCount(); ++j) { + auto* item = topLevelItem->child(j); + settings().plugins().setSettings(plugin(item)->name(), + item->data(0, SettingsRole).toMap()); + } + } + + // set plugin blacklist + QStringList names; + for (QListWidgetItem* item : ui->pluginBlacklist->findItems("*", Qt::MatchWildcard)) { + names.push_back(item->text()); + } + + settings().plugins().setBlacklist(names); + + settings().plugins().save(); +} + +void PluginsSettingsTab::closing() +{ + storeSettings(ui->pluginsList->currentItem()); +} + +void PluginsSettingsTab::on_checkboxEnabled_clicked(bool checked) +{ + // Retrieve the plugin: + auto* item = ui->pluginsList->currentItem(); + if (!item || !item->data(0, PluginRole).isValid()) { + return; + } + IPlugin* plugin = this->plugin(item); + const auto& requirements = m_pluginContainer->requirements(plugin); + + // User wants to enable: + if (checked) { + m_pluginContainer->setEnabled(plugin, true, false); + } else { + // Custom check for proxy + current game: + if (m_pluginContainer->implementInterface(plugin)) { + + // Current game: + auto* game = m_pluginContainer->managedGame(); + if (m_pluginContainer->requirements(game).proxy() == plugin) { + QMessageBox::warning(parentWidget(), QObject::tr("Cannot disable plugin"), + QObject::tr("The '%1' plugin is used by the current game " + "plugin and cannot disabled.") + .arg(plugin->localizedName()), + QMessageBox::Ok); + ui->enabledCheckbox->setChecked(true); + return; + } + + // Check the proxied plugins: + auto proxied = requirements.proxied(); + if (!proxied.empty()) { + DisableProxyPluginDialog dialog(plugin, proxied, parentWidget()); + if (dialog.exec() != QDialog::Accepted) { + ui->enabledCheckbox->setChecked(true); + return; + } + } + } + + // Check if the plugins is required for other plugins: + auto requiredFor = requirements.requiredFor(); + if (!requiredFor.empty()) { + QStringList pluginNames; + for (auto& p : requiredFor) { + pluginNames.append(p->localizedName()); + } + pluginNames.sort(); + QString message = + QObject::tr("

        Disabling the '%1' plugin will also disable the following " + "plugins:

          %1

        Do you want to continue?

        ") + .arg(plugin->localizedName()) + .arg("
      • " + pluginNames.join("
      • ") + "
      • "); + if (QMessageBox::warning(parentWidget(), QObject::tr("Really disable plugin?"), + message, + QMessageBox::Yes | QMessageBox::No) == QMessageBox::No) { + ui->enabledCheckbox->setChecked(true); + return; + } + } + m_pluginContainer->setEnabled(plugin, false, true); + } + + // Proxy was disabled / enabled, need restart: + if (m_pluginContainer->implementInterface(plugin)) { + dialog().setExitNeeded(Exit::Restart); + } + + updateListItems(); +} + +void PluginsSettingsTab::on_pluginsList_currentItemChanged(QTreeWidgetItem* current, + QTreeWidgetItem* previous) +{ + storeSettings(previous); + + if (!current->data(0, PluginRole).isValid()) { + return; + } + + ui->pluginSettingsList->clear(); + IPlugin* plugin = this->plugin(current); + ui->authorLabel->setText(plugin->author()); + ui->versionLabel->setText(plugin->version().canonicalString()); + ui->descriptionLabel->setText(plugin->description()); + + // Checkbox, do not show for children or game plugins, disable + // if the plugin cannot be enabled. + ui->enabledCheckbox->setVisible( + !m_pluginContainer->implementInterface(plugin) && + plugin->master().isEmpty()); + + bool enabled = m_pluginContainer->isEnabled(plugin); + auto& requirements = m_pluginContainer->requirements(plugin); + auto problems = requirements.problems(); + + if (m_pluginContainer->requirements(plugin).isCorePlugin()) { + ui->enabledCheckbox->setDisabled(true); + ui->enabledCheckbox->setToolTip( + QObject::tr("This plugin is required for Mod Organizer to work properly and " + "cannot be disabled.")); + } + // Plugin is enable or can be enabled. + else if (enabled || problems.empty()) { + ui->enabledCheckbox->setDisabled(false); + ui->enabledCheckbox->setToolTip(""); + ui->enabledCheckbox->setChecked(enabled); + } + // Plugin is disable and cannot be enabled. + else { + ui->enabledCheckbox->setDisabled(true); + ui->enabledCheckbox->setChecked(false); + if (problems.size() == 1) { + ui->enabledCheckbox->setToolTip(problems[0].shortDescription()); + } else { + QStringList descriptions; + for (auto& problem : problems) { + descriptions.append(problem.shortDescription()); + } + ui->enabledCheckbox->setToolTip("
        • " + descriptions.join("
        • ") + + "
        "); + } + } + + QVariantMap settings = current->data(0, SettingsRole).toMap(); + QVariantMap descriptions = current->data(0, DescriptionsRole).toMap(); + ui->pluginSettingsList->setEnabled(settings.count() != 0); + for (auto iter = settings.begin(); iter != settings.end(); ++iter) { + QTreeWidgetItem* newItem = new QTreeWidgetItem(QStringList(iter.key())); + QVariant value = *iter; + QString description; + { + auto descriptionIter = descriptions.find(iter.key()); + if (descriptionIter != descriptions.end()) { + description = descriptionIter->toString(); + } + } + + ui->pluginSettingsList->setItemDelegateForColumn(0, new NoEditDelegate()); + newItem->setData(1, Qt::DisplayRole, value); + newItem->setData(1, Qt::EditRole, value); + newItem->setToolTip(1, description); + + newItem->setFlags(newItem->flags() | Qt::ItemIsEditable); + ui->pluginSettingsList->addTopLevelItem(newItem); + } + + ui->pluginSettingsList->resizeColumnToContents(0); + ui->pluginSettingsList->resizeColumnToContents(1); +} + +void PluginsSettingsTab::deleteBlacklistItem() +{ + ui->pluginBlacklist->takeItem(ui->pluginBlacklist->currentIndex().row()); +} + +void PluginsSettingsTab::storeSettings(QTreeWidgetItem* pluginItem) +{ + if (pluginItem != nullptr && pluginItem->data(0, PluginRole).isValid()) { + QVariantMap settings = pluginItem->data(0, SettingsRole).toMap(); + + for (int i = 0; i < ui->pluginSettingsList->topLevelItemCount(); ++i) { + const QTreeWidgetItem* item = ui->pluginSettingsList->topLevelItem(i); + settings[item->text(0)] = item->data(1, Qt::DisplayRole); + } + + pluginItem->setData(0, SettingsRole, settings); + } +} diff --git a/src/src/settingsdialogplugins.h b/src/src/settingsdialogplugins.h new file mode 100644 index 0000000..78d5b04 --- /dev/null +++ b/src/src/settingsdialogplugins.h @@ -0,0 +1,57 @@ +#ifndef SETTINGSDIALOGPLUGINS_H +#define SETTINGSDIALOGPLUGINS_H + +#include "filterwidget.h" + +#include "settings.h" +#include "settingsdialog.h" + +class PluginsSettingsTab : public SettingsTab +{ +public: + PluginsSettingsTab(Settings& settings, PluginContainer* pluginContainer, + SettingsDialog& dialog); + + void update(); + void closing() override; + +private: + void on_pluginsList_currentItemChanged(QTreeWidgetItem* current, + QTreeWidgetItem* previous); + void on_checkboxEnabled_clicked(bool checked); + void deleteBlacklistItem(); + void storeSettings(QTreeWidgetItem* pluginItem); + +private slots: + + /** + * @brief Update the list item to display inactive plugins. + */ + void updateListItems(); + + /** + * @brief Filter the plugin list according to the filter widget. + * + */ + void filterPluginList(); + + /** + * @brief Retrieve the plugin associated to the given item in the list. + * + */ + MOBase::IPlugin* plugin(QTreeWidgetItem* pluginItem) const; + + enum + { + PluginRole = Qt::UserRole, + SettingsRole = Qt::UserRole + 1, + DescriptionsRole = Qt::UserRole + 2 + }; + +private: + PluginContainer* m_pluginContainer; + + MOBase::FilterWidget m_filter; +}; + +#endif // SETTINGSDIALOGPLUGINS_H diff --git a/src/src/settingsdialogproton.cpp b/src/src/settingsdialogproton.cpp new file mode 100644 index 0000000..3062794 --- /dev/null +++ b/src/src/settingsdialogproton.cpp @@ -0,0 +1,447 @@ +#include "settingsdialogproton.h" + +#include "fluorineconfig.h" +#include "ui_settingsdialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +std::atomic g_activeInstallTab = nullptr; +} + +ProtonSettingsTab::ProtonSettingsTab(Settings& s, SettingsDialog& d) + : QObject(&d), SettingsTab(s, d) +{ + ui->protonProgressBar->setRange(0, 100); + ui->protonProgressBar->setValue(0); + ui->protonProgressBar->setVisible(false); + + ui->umuCheckBox->setChecked(QSettings().value("fluorine/use_umu", true).toBool()); + ui->umuSystemCheckBox->setChecked( + QSettings().value("fluorine/prefer_system_umu", false).toBool()); + ui->steamRunCheckBox->setChecked( + QSettings().value("fluorine/use_steam_run", false).toBool()); + + ui->launchWrapperEdit->setPlaceholderText("mangohud --dlsym"); + ui->launchWrapperEdit->setText(QSettings().value("fluorine/launch_wrapper").toString()); + + populateProtons(); + + QObject::connect(ui->protonVersionCombo, &QComboBox::currentIndexChanged, this, + [this](int) { + if (auto cfg = FluorineConfig::load(); + cfg.has_value() && cfg->prefixExists()) { + const QString protonName = + ui->protonVersionCombo->currentText().trimmed(); + const QString protonPath = ui->protonVersionCombo + ->currentData(Qt::UserRole + 1) + .toString() + .trimmed(); + + if (!protonName.isEmpty() && !protonPath.isEmpty() && + (cfg->proton_name != protonName || + cfg->proton_path != protonPath)) { + cfg->proton_name = protonName; + cfg->proton_path = protonPath; + cfg->save(); + } + } + }); + + QObject::connect(ui->createPrefixButton, &QPushButton::clicked, this, + &ProtonSettingsTab::onCreatePrefix); + QObject::connect(ui->deletePrefixButton, &QPushButton::clicked, this, + &ProtonSettingsTab::onDeletePrefix); + QObject::connect(ui->recreatePrefixButton, &QPushButton::clicked, this, + &ProtonSettingsTab::onRecreatePrefix); + QObject::connect(ui->openPrefixFolderButton, &QPushButton::clicked, this, + &ProtonSettingsTab::onOpenPrefixFolder); + QObject::connect(ui->fixGameRegistriesButton, &QPushButton::clicked, this, + &ProtonSettingsTab::onFixGameRegistries); + + QObject::connect(&m_installWatcher, &QFutureWatcher::finished, this, + &ProtonSettingsTab::onInstallFinished); + + refreshState(); +} + +void ProtonSettingsTab::update() +{ + QSettings().setValue("fluorine/use_umu", ui->umuCheckBox->isChecked()); + QSettings().setValue("fluorine/prefer_system_umu", + ui->umuSystemCheckBox->isChecked()); + QSettings().setValue("fluorine/use_steam_run", + ui->steamRunCheckBox->isChecked()); + QSettings().setValue("fluorine/launch_wrapper", ui->launchWrapperEdit->text()); +} + +void ProtonSettingsTab::populateProtons() +{ + ui->protonVersionCombo->clear(); + + const NakProtonList protonList = nak_find_steam_protons(); + + for (size_t i = 0; i < protonList.count; ++i) { + const NakSteamProton& proton = protonList.protons[i]; + + const QString protonName = QString::fromUtf8(proton.name ? proton.name : ""); + const QString protonPath = QString::fromUtf8(proton.path ? proton.path : ""); + + if (protonName.isEmpty() || protonPath.isEmpty()) { + continue; + } + + ui->protonVersionCombo->addItem(protonName); + ui->protonVersionCombo->setItemData(ui->protonVersionCombo->count() - 1, protonPath, + Qt::UserRole + 1); + } + + nak_proton_list_free(protonList); + + if (auto cfg = FluorineConfig::load(); cfg.has_value()) { + const int idx = ui->protonVersionCombo->findText(cfg->proton_name); + if (idx >= 0) { + ui->protonVersionCombo->setCurrentIndex(idx); + } + } +} + +void ProtonSettingsTab::refreshState() +{ + const auto prefix = FluorineConfig::prefixPath(); + const bool active = prefix.has_value(); + + if (!m_busy) { + ui->protonStatusLabel->setText(active ? tr("Prefix Active") : tr("No Prefix")); + ui->protonProgressBar->setVisible(false); + } + + ui->prefixPathValueLabel->setText(active ? *prefix : tr("(none)")); + + ui->createPrefixButton->setEnabled(!m_busy && !active); + ui->deletePrefixButton->setEnabled(!m_busy && active); + ui->recreatePrefixButton->setEnabled(!m_busy && active); + ui->openPrefixFolderButton->setEnabled(!m_busy && active); + ui->fixGameRegistriesButton->setEnabled(!m_busy && active); + ui->protonVersionCombo->setEnabled(!m_busy); +} + +void ProtonSettingsTab::setBusy(bool busy) +{ + m_busy = busy; + ui->protonProgressBar->setVisible(busy); + + if (!busy) { + ui->protonProgressBar->setValue(0); + } + + refreshState(); +} + +void ProtonSettingsTab::onCreatePrefix() +{ + if (m_busy) { + return; + } + + const QString protonName = ui->protonVersionCombo->currentText().trimmed(); + const QString protonPath = + ui->protonVersionCombo->currentData(Qt::UserRole + 1).toString().trimmed(); + + if (protonName.isEmpty() || protonPath.isEmpty()) { + ui->protonStatusLabel->setText(tr("Select a Proton version first")); + return; + } + + setBusy(true); + ui->protonStatusLabel->setText(tr("Creating Steam shortcut...")); + + const QByteArray protonNameUtf8 = protonName.toUtf8(); + NakShortcutResult result = nak_add_mod_manager_shortcut( + "Fluorine Manager", "/usr/bin/true", "/tmp", protonNameUtf8.constData()); + + const QString prefixPath = + QString::fromUtf8(result.prefix_path ? result.prefix_path : ""); + const QString error = QString::fromUtf8(result.error ? result.error : ""); + const uint32_t appId = result.app_id; + + nak_shortcut_result_free(result); + + if (!error.isEmpty() || prefixPath.isEmpty()) { + setBusy(false); + ui->protonStatusLabel->setText(error.isEmpty() ? tr("Failed to create prefix") + : tr("Error: %1").arg(error)); + return; + } + + startInstallTask(appId, prefixPath, protonName, protonPath, + ui->umuCheckBox->isChecked(), + ui->umuSystemCheckBox->isChecked(), + ui->steamRunCheckBox->isChecked()); +} + +void ProtonSettingsTab::onDeletePrefix() +{ + if (m_busy) { + return; + } + + auto cfg = FluorineConfig::load(); + if (!cfg.has_value()) { + ui->protonStatusLabel->setText(tr("No Prefix")); + return; + } + + if (char* error = nak_remove_steam_shortcut(cfg->app_id); error != nullptr) { + nak_string_free(error); + } + + cfg->destroyPrefix(); + + ui->protonStatusLabel->setText(tr("No Prefix")); + refreshState(); +} + +void ProtonSettingsTab::onRecreatePrefix() +{ + if (m_busy) { + return; + } + + auto cfg = FluorineConfig::load(); + if (!cfg.has_value() || !cfg->prefixExists()) { + ui->protonStatusLabel->setText(tr("No existing prefix to recreate")); + refreshState(); + return; + } + + QDir prefixDir(cfg->prefix_path); + if (prefixDir.exists() && !prefixDir.removeRecursively()) { + ui->protonStatusLabel->setText(tr("Failed to delete existing prefix")); + refreshState(); + return; + } + + setBusy(true); + ui->protonStatusLabel->setText(tr("Recreating prefix...")); + + startInstallTask(cfg->app_id, cfg->prefix_path, cfg->proton_name, + cfg->proton_path, ui->umuCheckBox->isChecked(), + ui->umuSystemCheckBox->isChecked(), + ui->steamRunCheckBox->isChecked()); +} + +void ProtonSettingsTab::onOpenPrefixFolder() +{ + auto path = FluorineConfig::prefixPath(); + if (!path.has_value()) { + ui->protonStatusLabel->setText(tr("No Prefix")); + return; + } + + QProcess::startDetached("xdg-open", {*path}); +} + +void ProtonSettingsTab::onFixGameRegistries() +{ + if (m_busy) { + return; + } + + auto cfg = FluorineConfig::load(); + if (!cfg.has_value() || !cfg->prefixExists()) { + ui->protonStatusLabel->setText(tr("No existing prefix")); + refreshState(); + return; + } + + setBusy(true); + ui->protonStatusLabel->setText(tr("Fixing game registries...")); + + const QString prefixPath = cfg->prefix_path; + const QString protonName = cfg->proton_name; + const QString protonPath = cfg->proton_path; + const uint32_t appId = cfg->app_id; + + g_activeInstallTab.store(this); + + m_pendingAppId = appId; + m_pendingPrefixPath = prefixPath; + m_pendingProtonName = protonName; + m_pendingProtonPath = protonPath; + + m_installWatcher.setFuture(QtConcurrent::run([prefixPath, protonName, + protonPath, + appId]() -> InstallResult { + const QByteArray prefixPathUtf8 = prefixPath.toUtf8(); + const QByteArray protonNameUtf8 = protonName.toUtf8(); + const QByteArray protonPathUtf8 = protonPath.toUtf8(); + + char* error = nak_apply_wine_registry_settings( + prefixPathUtf8.constData(), protonNameUtf8.constData(), + protonPathUtf8.constData(), &ProtonSettingsTab::logCallback, appId); + + InstallResult r; + if (error != nullptr) { + r.error = QString::fromUtf8(error); + nak_string_free(error); + } + + return r; + })); +} + +void ProtonSettingsTab::startInstallTask(uint32_t appId, const QString& prefixPath, + const QString& protonName, + const QString& protonPath, + bool useUmuForPrefix, + bool preferSystemUmu, + bool useSteamRun) +{ + m_pendingAppId = appId; + m_pendingPrefixPath = prefixPath; + m_pendingProtonName = protonName; + m_pendingProtonPath = protonPath; + + ui->protonProgressBar->setValue(0); + + g_activeInstallTab.store(this); + + m_installWatcher.setFuture(QtConcurrent::run([ + appId, + prefixPath, + protonName, + protonPath, + useUmuForPrefix, + preferSystemUmu, + useSteamRun]() -> InstallResult { + const QByteArray prefixPathUtf8 = prefixPath.toUtf8(); + const QByteArray protonNameUtf8 = protonName.toUtf8(); + const QByteArray protonPathUtf8 = protonPath.toUtf8(); + const QByteArray bundledUmuPathUtf8 = + QDir(QCoreApplication::applicationDirPath()).filePath("umu-run").toUtf8(); + + qputenv("NAK_USE_UMU_FOR_PREFIX", useUmuForPrefix ? "1" : "0"); + qputenv("NAK_PREFER_SYSTEM_UMU", preferSystemUmu ? "1" : "0"); + qputenv("NAK_USE_STEAM_RUN", useSteamRun ? "1" : "0"); + + if (QFileInfo::exists(QString::fromUtf8(bundledUmuPathUtf8))) { + qputenv("NAK_BUNDLED_UMU_RUN", bundledUmuPathUtf8); + } else { + qunsetenv("NAK_BUNDLED_UMU_RUN"); + } + + const auto restoreNakEnv = qScopeGuard([] { + qunsetenv("NAK_USE_UMU_FOR_PREFIX"); + qunsetenv("NAK_PREFER_SYSTEM_UMU"); + qunsetenv("NAK_USE_STEAM_RUN"); + qunsetenv("NAK_BUNDLED_UMU_RUN"); + }); + + int cancelFlag = 0; + char* error = nak_install_all_dependencies( + prefixPathUtf8.constData(), protonNameUtf8.constData(), + protonPathUtf8.constData(), &ProtonSettingsTab::statusCallback, + &ProtonSettingsTab::logCallback, &ProtonSettingsTab::progressCallback, + &cancelFlag, appId); + + InstallResult r; + if (error != nullptr) { + r.error = QString::fromUtf8(error); + nak_string_free(error); + } + + return r; + })); +} + +void ProtonSettingsTab::enqueueStatus(const QString& message) +{ + QMetaObject::invokeMethod(this, + [this, message] { + if (m_busy) { + ui->protonStatusLabel->setText(message); + } + }, + Qt::QueuedConnection); +} + +void ProtonSettingsTab::enqueueProgress(float progress) +{ + QMetaObject::invokeMethod(this, + [this, progress] { + if (m_busy) { + const int clamped = + qBound(0, static_cast(progress * 100.0f), 100); + ui->protonProgressBar->setValue(clamped); + } + }, + Qt::QueuedConnection); +} + +void ProtonSettingsTab::statusCallback(const char* message) +{ + if (auto* tab = g_activeInstallTab.load(); tab != nullptr) { + tab->enqueueStatus(QString::fromUtf8(message ? message : "")); + } +} + +void ProtonSettingsTab::logCallback(const char* message) +{ + Q_UNUSED(message); +} + +void ProtonSettingsTab::progressCallback(float progress) +{ + if (auto* tab = g_activeInstallTab.load(); tab != nullptr) { + tab->enqueueProgress(progress); + } +} + +void ProtonSettingsTab::onInstallFinished() +{ + g_activeInstallTab.store(nullptr); + + const InstallResult result = m_installWatcher.result(); + + setBusy(false); + + if (!result.error.isEmpty()) { + ui->protonStatusLabel->setText(tr("Error: %1").arg(result.error)); + return; + } + + // Set up prefix directory structure (temp dir + game symlinks) + { + const QByteArray prefixPathUtf8 = m_pendingPrefixPath.toUtf8(); + nak_ensure_temp_directory(prefixPathUtf8.constData()); + nak_create_game_symlinks_auto(prefixPathUtf8.constData()); + } + + FluorineConfig cfg; + cfg.app_id = m_pendingAppId; + cfg.prefix_path = m_pendingPrefixPath; + cfg.proton_name = m_pendingProtonName; + cfg.proton_path = m_pendingProtonPath; + cfg.created = QDateTime::currentDateTime().toString(Qt::ISODate); + + if (!cfg.save()) { + ui->protonStatusLabel->setText(tr("Error saving Fluorine config")); + refreshState(); + return; + } + + ui->protonStatusLabel->setText(tr("Prefix Active")); + refreshState(); +} diff --git a/src/src/settingsdialogproton.h b/src/src/settingsdialogproton.h new file mode 100644 index 0000000..555ab4f --- /dev/null +++ b/src/src/settingsdialogproton.h @@ -0,0 +1,61 @@ +#ifndef SETTINGSDIALOGPROTON_H +#define SETTINGSDIALOGPROTON_H + +#include +#include + +#include "settings.h" +#include "settingsdialog.h" + +class ProtonSettingsTab : public QObject, public SettingsTab +{ + Q_OBJECT + +public: + ProtonSettingsTab(Settings& settings, SettingsDialog& dialog); + + void update() override; + +private: + struct InstallResult + { + QString error; + }; + + void populateProtons(); + void refreshState(); + void setBusy(bool busy); + + void onCreatePrefix(); + void onDeletePrefix(); + void onRecreatePrefix(); + void onOpenPrefixFolder(); + void onFixGameRegistries(); + + void startInstallTask(uint32_t appId, const QString& prefixPath, + const QString& protonName, const QString& protonPath, + bool useUmuForPrefix, bool preferSystemUmu, + bool useSteamRun); + + void enqueueStatus(const QString& message); + void enqueueProgress(float progress); + + static void statusCallback(const char* message); + static void logCallback(const char* message); + static void progressCallback(float progress); + +private slots: + void onInstallFinished(); + +private: + QFutureWatcher m_installWatcher; + + uint32_t m_pendingAppId = 0; + QString m_pendingPrefixPath; + QString m_pendingProtonName; + QString m_pendingProtonPath; + + bool m_busy = false; +}; + +#endif // SETTINGSDIALOGPROTON_H diff --git a/src/src/settingsdialogtheme.cpp b/src/src/settingsdialogtheme.cpp new file mode 100644 index 0000000..363ae3b --- /dev/null +++ b/src/src/settingsdialogtheme.cpp @@ -0,0 +1,81 @@ +#include "settingsdialogtheme.h" +#include "categoriesdialog.h" +#include "colortable.h" +#include "modlist.h" +#include "shared/appconfig.h" +#include "ui_settingsdialog.h" +#include +#include + +using namespace MOBase; + +ThemeSettingsTab::ThemeSettingsTab(Settings& s, SettingsDialog& d) : SettingsTab(s, d) +{ + // style + addStyles(); + selectStyle(); + + // colors + ui->colorTable->load(s); + + QObject::connect(ui->resetColorsBtn, &QPushButton::clicked, [&] { + ui->colorTable->resetColors(); + }); + + QObject::connect(ui->exploreStyles, &QPushButton::clicked, [&] { + onExploreStyles(); + }); +} + +void ThemeSettingsTab::update() +{ + // style + const QString oldStyle = settings().interface().styleName().value_or(""); + const QString newStyle = + ui->styleBox->itemData(ui->styleBox->currentIndex()).toString(); + + if (oldStyle != newStyle) { + settings().interface().setStyleName(newStyle); + emit settings().styleChanged(newStyle); + } + + // colors + ui->colorTable->commitColors(); +} + +void ThemeSettingsTab::addStyles() +{ + ui->styleBox->addItem("None", ""); + for (auto&& key : QStyleFactory::keys()) { + ui->styleBox->addItem(key, key); + } + + ui->styleBox->insertSeparator(ui->styleBox->count()); + + QDirIterator iter(QCoreApplication::applicationDirPath() + "/" + + QString::fromStdWString(AppConfig::stylesheetsPath()), + QStringList("*.qss"), QDir::Files); + + while (iter.hasNext()) { + iter.next(); + + ui->styleBox->addItem(iter.fileInfo().completeBaseName(), iter.fileName()); + } +} + +void ThemeSettingsTab::selectStyle() +{ + const int currentID = + ui->styleBox->findData(settings().interface().styleName().value_or("")); + + if (currentID != -1) { + ui->styleBox->setCurrentIndex(currentID); + } +} + +void ThemeSettingsTab::onExploreStyles() +{ + QString ssPath = QCoreApplication::applicationDirPath() + "/" + + ToQString(AppConfig::stylesheetsPath()); + shell::Explore(ssPath); +} diff --git a/src/src/settingsdialogtheme.h b/src/src/settingsdialogtheme.h new file mode 100644 index 0000000..de1aa2c --- /dev/null +++ b/src/src/settingsdialogtheme.h @@ -0,0 +1,22 @@ +#ifndef SETTINGSDIALOGTHEME_H +#define SETTINGSDIALOGTHEME_H + +#include + +#include "settings.h" +#include "settingsdialog.h" + +class ThemeSettingsTab : public SettingsTab +{ +public: + ThemeSettingsTab(Settings& settings, SettingsDialog& dialog); + + void update() override; + +private: + void addStyles(); + void selectStyle(); + void onExploreStyles(); +}; + +#endif // SETTINGSDIALOGGENERAL_H diff --git a/src/src/settingsdialogworkarounds.cpp b/src/src/settingsdialogworkarounds.cpp new file mode 100644 index 0000000..658ae7d --- /dev/null +++ b/src/src/settingsdialogworkarounds.cpp @@ -0,0 +1,244 @@ +#include "settingsdialogworkarounds.h" +#include "settings.h" +#include "spawn.h" +#include "ui_settingsdialog.h" +#include +#include +#include + +WorkaroundsSettingsTab::WorkaroundsSettingsTab(Settings& s, SettingsDialog& d) + : SettingsTab(s, d) +{ + // options + ui->forceEnableBox->setChecked(settings().game().forceEnableCoreFiles()); + ui->lockGUIBox->setChecked(settings().interface().lockGUI()); + ui->enableArchiveParsingBox->setChecked(settings().archiveParsing()); + + // steam + QString username, password; + settings().steam().login(username, password); + if (username.length() > 0) + MOBase::log::getDefault().addToBlacklist(username.toStdString(), "STEAM_USERNAME"); + if (password.length() > 0) + MOBase::log::getDefault().addToBlacklist(password.toStdString(), "STEAM_PASSWORD"); + + ui->appIDEdit->setText(settings().steam().appID()); + ui->steamUserEdit->setText(username); + ui->steamPassEdit->setText(password); + + // network + ui->offlineBox->setChecked(settings().network().offlineMode()); + ui->proxyBox->setChecked(settings().network().useProxy()); + ui->useCustomBrowser->setChecked(settings().network().useCustomBrowser()); + ui->browserCommand->setText(settings().network().customBrowserCommand()); + + // buttons + m_ExecutableBlacklist = settings().executablesBlacklist(); + m_SkipFileSuffixes = settings().skipFileSuffixes(); + m_SkipDirectories = settings().skipDirectories(); + + QObject::connect(ui->bsaDateBtn, &QPushButton::clicked, [&] { + on_bsaDateBtn_clicked(); + }); + QObject::connect(ui->execBlacklistBtn, &QPushButton::clicked, [&] { + on_execBlacklistBtn_clicked(); + }); + QObject::connect(ui->skipFileSuffixBtn, &QPushButton::clicked, [&] { + on_skipFileSuffixBtn_clicked(); + }); + QObject::connect(ui->skipDirectoriesBtn, &QPushButton::clicked, [&] { + on_skipDirectoriesBtn_clicked(); + }); + QObject::connect(ui->resetGeometryBtn, &QPushButton::clicked, [&] { + on_resetGeometryBtn_clicked(); + }); +} + +void WorkaroundsSettingsTab::update() +{ + // options + settings().game().setForceEnableCoreFiles(ui->forceEnableBox->isChecked()); + settings().interface().setLockGUI(ui->lockGUIBox->isChecked()); + settings().setArchiveParsing(ui->enableArchiveParsingBox->isChecked()); + + // steam + if (ui->appIDEdit->text() != settings().game().plugin()->steamAPPId()) { + settings().steam().setAppID(ui->appIDEdit->text()); + } else { + settings().steam().setAppID(""); + } + settings().steam().setLogin(ui->steamUserEdit->text(), ui->steamPassEdit->text()); + + // network + settings().network().setOfflineMode(ui->offlineBox->isChecked()); + settings().network().setUseProxy(ui->proxyBox->isChecked()); + settings().network().setUseCustomBrowser(ui->useCustomBrowser->isChecked()); + settings().network().setCustomBrowserCommand(ui->browserCommand->text()); + + // buttons + settings().setExecutablesBlacklist(m_ExecutableBlacklist); + settings().setSkipFileSuffixes(m_SkipFileSuffixes); + settings().setSkipDirectories(m_SkipDirectories); +} + +bool WorkaroundsSettingsTab::changeBlacklistNow(QWidget* parent, Settings& settings) +{ + const auto current = settings.executablesBlacklist(); + + if (auto s = changeBlacklistLater(parent, current)) { + settings.setExecutablesBlacklist(*s); + return true; + } + + return false; +} + +std::optional +WorkaroundsSettingsTab::changeBlacklistLater(QWidget* parent, const QString& current) +{ + bool ok = false; + + QString result = QInputDialog::getMultiLineText( + parent, QObject::tr("Executables Blacklist"), + QObject::tr("Enter one executable per line to be blacklisted from the virtual " + "file system.\n" + "Mods and other virtualized files will not be visible to these " + "executables and\n" + "any executables launched by them.\n\n" + "Example:\n" + " Chrome.exe\n" + " Firefox.exe"), + current.split(";").join("\n"), &ok); + + if (!ok) { + return {}; + } + + QStringList blacklist; + for (auto exec : result.split("\n")) { + if (exec.trimmed().endsWith(".exe", Qt::CaseInsensitive)) { + blacklist << exec.trimmed(); + } + } + + return blacklist.join(";"); +} + +std::optional +WorkaroundsSettingsTab::changeSkipFileSuffixes(QWidget* parent, + const QStringList& current) +{ + bool ok = false; + + QString result = QInputDialog::getMultiLineText( + parent, QObject::tr("Skip File Suffixes"), + QObject::tr( + "Enter one file suffix per line to be skipped / ignored from the virtual " + "file system.\n" + "Not to be confused with file extensions, file suffixes are simply how the " + "filename ends.\n\n" + "Example:\n" + " .txt - Would skip all files that end with .txt, .txt\n" + " some_file.txt - Would skip all files that end with some_file.txt, some_file.txt"), + current.join("\n"), &ok); + + if (!ok) { + return {}; + } + + QStringList fileSuffixes; + for (auto& suffix : result.split("\n")) { + auto trimmed = suffix.trimmed(); + if (!trimmed.isEmpty()) { + fileSuffixes << trimmed; + } + } + + return fileSuffixes; +} + +std::optional +WorkaroundsSettingsTab::changeSkipDirectories(QWidget* parent, + const QStringList& current) +{ + bool ok = false; + + QString result = QInputDialog::getMultiLineText( + parent, QObject::tr("Skip Directories"), + QObject::tr( + "Enter one directory per line to be skipped / ignored from the virtual " + "file system.\n\n" + "Example:\n" + " .git\n" + " instructions"), + current.join("\n"), &ok); + + if (!ok) { + return {}; + } + + QStringList directories; + for (auto& dir : result.split("\n")) { + auto trimmed = dir.trimmed(); + if (!trimmed.isEmpty()) { + directories << trimmed; + } + } + + return directories; +} + +void WorkaroundsSettingsTab::on_execBlacklistBtn_clicked() +{ + if (auto s = changeBlacklistLater(parentWidget(), m_ExecutableBlacklist)) { + m_ExecutableBlacklist = *s; + } +} + +void WorkaroundsSettingsTab::on_skipFileSuffixBtn_clicked() +{ + if (auto s = changeSkipFileSuffixes(parentWidget(), m_SkipFileSuffixes)) { + m_SkipFileSuffixes = *s; + } +} + +void WorkaroundsSettingsTab::on_skipDirectoriesBtn_clicked() +{ + if (auto s = changeSkipDirectories(parentWidget(), m_SkipDirectories)) { + m_SkipDirectories = *s; + } +} + +void WorkaroundsSettingsTab::on_bsaDateBtn_clicked() +{ + const auto* game = qApp->property("managed_game").value(); + QDir dir = game->dataDirectory(); + +#ifdef _WIN32 + helper::backdateBSAs(parentWidget(), qApp->applicationDirPath().toStdWString(), + dir.absolutePath().toStdWString()); +#else + // backdateBSAs is Windows-only (uses helper.exe for admin rights) + Q_UNUSED(dir); +#endif +} + +void WorkaroundsSettingsTab::on_resetGeometryBtn_clicked() +{ + const auto r = + MOBase::TaskDialog(parentWidget()) + .title(QObject::tr("Restart Mod Organizer")) + .main(QObject::tr("Restart Mod Organizer")) + .content(QObject::tr("Geometries will be reset to their default values.")) + .icon(QMessageBox::Question) + .button({QObject::tr("Restart Mod Organizer"), QMessageBox::Ok}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + if (r == QMessageBox::Ok) { + settings().geometry().requestReset(); + ExitModOrganizer(Exit::Restart); + dialog().close(); + } +} diff --git a/src/src/settingsdialogworkarounds.h b/src/src/settingsdialogworkarounds.h new file mode 100644 index 0000000..9c537df --- /dev/null +++ b/src/src/settingsdialogworkarounds.h @@ -0,0 +1,49 @@ +#ifndef SETTINGSDIALOGWORKAROUNDS_H +#define SETTINGSDIALOGWORKAROUNDS_H + +#include "settings.h" +#include "settingsdialog.h" + +class WorkaroundsSettingsTab : public SettingsTab +{ +public: + WorkaroundsSettingsTab(Settings& settings, SettingsDialog& dialog); + + // shows the blacklist dialog from the given settings, and changes the + // settings when the user accepts it + // + static bool changeBlacklistNow(QWidget* parent, Settings& settings); + + // shows the blacklist dialog from the given string and returns the new + // blacklist if the user accepted it + // + static std::optional changeBlacklistLater(QWidget* parent, + const QString& current); + + // shows the blacklist dialog from the given string and returns the new + // blacklist if the user accepted it + // + static std::optional changeSkipFileSuffixes(QWidget* parent, + const QStringList& current); + + // shows the blacklist dialog from the given string and returns the new + // blacklist if the user accepted it + // + static std::optional changeSkipDirectories(QWidget* parent, + const QStringList& current); + + void update(); + +private: + QString m_ExecutableBlacklist; + QStringList m_SkipFileSuffixes; + QStringList m_SkipDirectories; + + void on_bsaDateBtn_clicked(); + void on_execBlacklistBtn_clicked(); + void on_skipFileSuffixBtn_clicked(); + void on_skipDirectoriesBtn_clicked(); + void on_resetGeometryBtn_clicked(); +}; + +#endif // SETTINGSDIALOGWORKAROUNDS_H diff --git a/src/src/settingsutilities.cpp b/src/src/settingsutilities.cpp new file mode 100644 index 0000000..5e7d3e2 --- /dev/null +++ b/src/src/settingsutilities.cpp @@ -0,0 +1,350 @@ +#include "settingsutilities.h" +#include "expanderwidget.h" +#include + +#ifndef _WIN32 +#include +#include +#endif + +using namespace MOBase; + +bool shouldLogSetting(const QString& displayName) +{ + // don't log Geometry/ and Widgets/, too noisy and not very useful + static const QStringList ignorePrefixes = {"Geometry/", "Widgets/"}; + + for (auto&& prefix : ignorePrefixes) { + if (displayName.startsWith(prefix, Qt::CaseInsensitive)) { + return false; + } + } + + return true; +} + +void logRemoval(const QString& name) +{ + if (!shouldLogSetting(name)) { + return; + } + + log::debug("setting '{}' removed", name); +} + +QString settingName(const QString& section, const QString& key) +{ + if (section.isEmpty()) { + return key; + } else if (key.isEmpty()) { + return section; + } else { + if (section.compare("General", Qt::CaseInsensitive) == 0) { + return key; + } else { + return section + "/" + key; + } + } +} + +void removeImpl(QSettings& settings, const QString& displayName, const QString& section, + const QString& key) +{ + if (key.isEmpty()) { + if (!settings.childGroups().contains(section, Qt::CaseInsensitive)) { + // not there + return; + } + } else { + if (!settings.contains(settingName(section, key))) { + // not there + return; + } + } + + logRemoval(displayName); + settings.remove(settingName(section, key)); +} + +void remove(QSettings& settings, const QString& section, const QString& key) +{ + removeImpl(settings, settingName(section, key), section, key); +} + +void removeSection(QSettings& settings, const QString& section) +{ + removeImpl(settings, section, section, ""); +} + +ScopedGroup::ScopedGroup(QSettings& s, const QString& name) + : m_settings(s), m_name(name) +{ + m_settings.beginGroup(m_name); +} + +ScopedGroup::~ScopedGroup() +{ + m_settings.endGroup(); +} + +void ScopedGroup::remove(const QString& key) +{ + removeImpl(m_settings, settingName(m_name, key), "", key); +} + +QStringList ScopedGroup::keys() const +{ + return m_settings.childKeys(); +} + +ScopedReadArray::ScopedReadArray(QSettings& s, const QString& section) + : m_settings(s), m_count(0) +{ + m_count = m_settings.beginReadArray(section); +} + +ScopedReadArray::~ScopedReadArray() +{ + m_settings.endArray(); +} + +int ScopedReadArray::count() const +{ + return m_count; +} + +QStringList ScopedReadArray::keys() const +{ + return m_settings.childKeys(); +} + +ScopedWriteArray::ScopedWriteArray(QSettings& s, const QString& section, + std::size_t size) + : m_settings(s), m_section(section), m_i(0) +{ + m_settings.beginWriteArray(section, size == NoSize ? -1 : static_cast(size)); +} + +ScopedWriteArray::~ScopedWriteArray() +{ + m_settings.endArray(); +} + +void ScopedWriteArray::next() +{ + m_settings.setArrayIndex(m_i); + ++m_i; +} + +QString widgetNameWithTopLevel(const QWidget* widget) +{ + QStringList components; + + auto* tl = widget->window(); + + if (tl == widget) { + // this is a top level widget, such as a dialog + components.push_back(widget->objectName()); + } else { + // this is a widget + const auto toplevelName = tl->objectName(); + if (!toplevelName.isEmpty()) { + components.push_back(toplevelName); + } + + const auto widgetName = widget->objectName(); + if (!widgetName.isEmpty()) { + components.push_back(widgetName); + } + } + + if (components.isEmpty()) { + // can't do much + return "unknown_widget"; + } + + return components.join("_"); +} + +QString widgetName(const QMainWindow* w) +{ + return w->objectName(); +} + +QString widgetName(const QHeaderView* w) +{ + return widgetNameWithTopLevel(w->parentWidget()); +} + +QString widgetName(const ExpanderWidget* w) +{ + return widgetNameWithTopLevel(w->button()); +} + +QString widgetName(const QWidget* w) +{ + return widgetNameWithTopLevel(w); +} + +QString dockSettingName(const QDockWidget* dock) +{ + return "MainWindow_docks_" + dock->objectName() + "_size"; +} + +QString indexSettingName(const QWidget* widget) +{ + return widgetNameWithTopLevel(widget) + "_index"; +} + +QString checkedSettingName(const QAbstractButton* b) +{ + return widgetNameWithTopLevel(b) + "_checked"; +} + +void warnIfNotCheckable(const QAbstractButton* b) +{ + if (!b->isCheckable()) { + log::warn("button '{}' used in the settings as a checkbox or radio button " + "but is not checkable", + b->objectName()); + } +} + +QString credentialName(const QString& key) +{ + return "ModOrganizer2_" + key; +} + +#ifdef _WIN32 +bool deleteWindowsCredential(const QString& key) +{ + const auto credName = credentialName(key); + + if (!CredDeleteW(credName.toStdWString().c_str(), CRED_TYPE_GENERIC, 0)) { + const auto e = GetLastError(); + + // not an error if the key already doesn't exist, and don't log it because + // it happens all the time when the settings dialog is closed since it + // doesn't check first + if (e == ERROR_NOT_FOUND) { + return true; + } + + log::error("failed to delete windows credential {}, {}", credName, + formatSystemMessage(e)); + return false; + } + + log::debug("deleted windows credential {}", credName); + + return true; +} + +bool addWindowsCredential(const QString& key, const QString& data) +{ + const auto credName = credentialName(key); + + const auto wname = credName.toStdWString(); + const auto wdata = data.toStdWString(); + + const auto* blob = reinterpret_cast(wdata.data()); + const auto blobSize = wdata.size() * sizeof(decltype(wdata)::value_type); + + CREDENTIALW cred = {}; + cred.Flags = 0; + cred.Type = CRED_TYPE_GENERIC; + cred.TargetName = const_cast(wname.c_str()); + cred.CredentialBlob = const_cast(blob); + cred.CredentialBlobSize = static_cast(blobSize); + cred.Persist = CRED_PERSIST_LOCAL_MACHINE; + + if (!CredWriteW(&cred, 0)) { + const auto e = GetLastError(); + + log::error("failed to delete windows credential {}, {}", credName, + formatSystemMessage(e)); + + return false; + } + + log::debug("set windows credential {}", credName); + + return true; +} + +struct CredentialFreer +{ + void operator()(CREDENTIALW* c) + { + if (c) { + CredFree(c); + } + } +}; + +using CredentialPtr = std::unique_ptr; + +QString getWindowsCredential(const QString& key) +{ + const QString credName = credentialName(key); + + CREDENTIALW* rawCreds = nullptr; + + const auto ret = + CredReadW(credName.toStdWString().c_str(), CRED_TYPE_GENERIC, 0, &rawCreds); + + CredentialPtr creds(rawCreds); + + if (!ret) { + const auto e = GetLastError(); + + if (e != ERROR_NOT_FOUND) { + log::error("failed to retrieve windows credential {}: {}", credName, + formatSystemMessage(e)); + } + + return {}; + } + + QString value; + if (creds->CredentialBlob) { + value = + QString::fromWCharArray(reinterpret_cast(creds->CredentialBlob), + creds->CredentialBlobSize / sizeof(wchar_t)); + } + + return value; +} + +bool setWindowsCredential(const QString& key, const QString& data) +{ + if (data.isEmpty()) { + return deleteWindowsCredential(key); + } else { + return addWindowsCredential(key, data); + } +} +#else +// Linux stub: use QSettings for credential storage instead of Windows Credential Manager +static QSettings& credentialSettings() +{ + static QSettings s(QDir::homePath() + "/.config/ModOrganizer/credentials.ini", + QSettings::IniFormat); + return s; +} + +QString getWindowsCredential(const QString& key) +{ + return credentialSettings().value(credentialName(key)).toString(); +} + +bool setWindowsCredential(const QString& key, const QString& data) +{ + if (data.isEmpty()) { + credentialSettings().remove(credentialName(key)); + } else { + credentialSettings().setValue(credentialName(key), data); + } + return true; +} +#endif diff --git a/src/src/settingsutilities.h b/src/src/settingsutilities.h new file mode 100644 index 0000000..be89943 --- /dev/null +++ b/src/src/settingsutilities.h @@ -0,0 +1,272 @@ +#ifndef SETTINGSUTILITIES_H +#define SETTINGSUTILITIES_H + +#include + +namespace MOBase +{ +class ExpanderWidget; +} + +template +struct ValueConverter +{ + static const T& convert(const T& t) { return t; } +}; + +template +struct ValueConverter>> +{ + static QString convert(const T& t) + { + return QString("%1").arg(static_cast>(t)); + } +}; + +template <> +struct ValueConverter +{ + static QString convert(const QVariantList& t) + { + return QString("%1").arg(QVariant(t).toStringList().join(",")); + } +}; + +template <> +struct ValueConverter +{ + static QString convert(const QStringList& t) { return t.join(", "); } +}; + +bool shouldLogSetting(const QString& displayName); + +template +void logChange(const QString& displayName, std::optional oldValue, const T& newValue) +{ + if (!shouldLogSetting(displayName)) { + return; + } + + using VC = ValueConverter; + + if (oldValue) { + MOBase::log::debug("setting '{}' changed from '{}' to '{}'", displayName, + VC::convert(*oldValue), VC::convert(newValue)); + } else { + MOBase::log::debug("setting '{}' set to '{}'", displayName, VC::convert(newValue)); + } +} + +void logRemoval(const QString& name); + +QString settingName(const QString& section, const QString& key); + +template +void setImpl(QSettings& settings, const QString& displayName, const QString& section, + const QString& key, const T& value) +{ + const auto current = getOptional(settings, section, key); + + if (current && *current == value) { + // no change + return; + } + + const auto name = settingName(section, key); + + logChange(displayName, current, value); + + if constexpr (std::is_enum_v) { + settings.setValue(name, static_cast>(value)); + } else if constexpr (std::is_integral_v && std::is_unsigned_v && sizeof(T) >= sizeof(long)) { + settings.setValue(name, static_cast(value)); + } else if constexpr (std::is_integral_v && std::is_signed_v && sizeof(T) >= sizeof(long)) { + settings.setValue(name, static_cast(value)); + } else { + settings.setValue(name, value); + } +} + +void removeImpl(QSettings& settings, const QString& displayName, const QString& section, + const QString& key); + +template +std::optional getOptional(const QSettings& settings, const QString& section, + const QString& key, std::optional def = {}) +{ + if (settings.contains(settingName(section, key))) { + const auto v = settings.value(settingName(section, key)); + + if constexpr (std::is_enum_v) { + return static_cast(v.value>()); + } else { + return v.value(); + } + } + + return def; +} + +template +T get(const QSettings& settings, const QString& section, const QString& key, T def) +{ + if (auto v = getOptional(settings, section, key)) { + return *v; + } else { + return def; + } +} + +template +void set(QSettings& settings, const QString& section, const QString& key, + const T& value) +{ + setImpl(settings, settingName(section, key), section, key, value); +} + +void remove(QSettings& settings, const QString& section, const QString& key); +void removeSection(QSettings& settings, const QString& section); + +class ScopedGroup +{ +public: + ScopedGroup(QSettings& s, const QString& name); + ~ScopedGroup(); + + ScopedGroup(const ScopedGroup&) = delete; + ScopedGroup& operator=(const ScopedGroup&) = delete; + + template + void set(const QString& key, const T& value) + { + setImpl(m_settings, settingName(m_name, key), "", key, value); + } + + void remove(const QString& key); + + QStringList keys() const; + + template + void for_each(F&& f) const + { + for (const QString& key : keys()) { + f(key); + } + } + + template + std::optional getOptional(const QString& key, std::optional def = {}) const + { + return ::getOptional(m_settings, "", key, def); + } + + template + T get(const QString& key, T def = {}) const + { + return ::get(m_settings, "", key, def); + } + +private: + QSettings& m_settings; + QString m_name; +}; + +class ScopedReadArray +{ +public: + ScopedReadArray(QSettings& s, const QString& section); + ~ScopedReadArray(); + + ScopedReadArray(const ScopedReadArray&) = delete; + ScopedReadArray& operator=(const ScopedReadArray&) = delete; + + template + void for_each(F&& f) const + { + for (int i = 0; i < count(); ++i) { + m_settings.setArrayIndex(i); + f(); + } + } + + template + std::optional getOptional(const QString& key, std::optional def = {}) const + { + return ::getOptional(m_settings, "", key, def); + } + + template + T get(const QString& key, T def = {}) const + { + return ::get(m_settings, "", key, def); + } + + int count() const; + QStringList keys() const; + +private: + QSettings& m_settings; + int m_count; +}; + +class ScopedWriteArray +{ +public: + static const auto NoSize = std::numeric_limits::max(); + + ScopedWriteArray(QSettings& s, const QString& section, std::size_t size = NoSize); + ~ScopedWriteArray(); + + ScopedWriteArray(const ScopedWriteArray&) = delete; + ScopedWriteArray& operator=(const ScopedWriteArray&) = delete; + + void next(); + + template + void set(const QString& key, const T& value) + { + const auto displayName = QString("%1/%2\\%3").arg(m_section).arg(m_i).arg(key); + + setImpl(m_settings, displayName, "", key, value); + } + +private: + QSettings& m_settings; + QString m_section; + int m_i; +}; + +QString widgetNameWithTopLevel(const QWidget* widget); +QString widgetName(const QMainWindow* w); +QString widgetName(const QHeaderView* w); +QString widgetName(const MOBase::ExpanderWidget* w); +QString widgetName(const QWidget* w); + +template +QString geoSettingName(const Widget* widget) +{ + return widgetName(widget) + "_geometry"; +} + +template +QString stateSettingName(const Widget* widget) +{ + return widgetName(widget) + "_state"; +} + +template +QString visibilitySettingName(const Widget* widget) +{ + return widgetName(widget) + "_visibility"; +} + +QString dockSettingName(const QDockWidget* dock); +QString indexSettingName(const QWidget* widget); +QString checkedSettingName(const QAbstractButton* b); + +void warnIfNotCheckable(const QAbstractButton* b); + +bool setWindowsCredential(const QString& key, const QString& data); +QString getWindowsCredential(const QString& key); + +#endif // SETTINGSUTILITIES_H diff --git a/src/src/shared/appconfig.cpp b/src/src/shared/appconfig.cpp new file mode 100644 index 0000000..90bbffb --- /dev/null +++ b/src/src/shared/appconfig.cpp @@ -0,0 +1,55 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "appconfig.h" + +#include + +#include +#include + +namespace AppConfig +{ + +#define PARWSTRING wstring +#define APPPARAM(partype, parid, value) \ + partype parid() \ + { \ + return value; \ + } +#include "appconfig.inc" + +QString basePath() +{ +#ifndef _WIN32 + const char* envBase = std::getenv("MO2_BASE_DIR"); + if (envBase && envBase[0] != '\0') { + return QString::fromUtf8(envBase); + } +#endif + return QCoreApplication::applicationDirPath(); +} + +namespace MOShared +{ +#undef PARWSTRING +#undef APPPARAM + +} // namespace MOShared +} // namespace AppConfig diff --git a/src/src/shared/appconfig.h b/src/src/shared/appconfig.h new file mode 100644 index 0000000..03f3300 --- /dev/null +++ b/src/src/shared/appconfig.h @@ -0,0 +1,48 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef APPCONFIG_H +#define APPCONFIG_H + +#include + +class QString; + +namespace AppConfig +{ + +#define PARWSTRING wstring +#define APPPARAM(partype, parid, value) partype parid(); +#include "appconfig.inc" + +// Returns the application base directory. On Linux, if the MO2_BASE_DIR +// environment variable is set (e.g. by an AppImage wrapper) that value is +// returned; otherwise falls back to QCoreApplication::applicationDirPath(). +QString basePath(); + +namespace MOShared +{ +#undef PARWSTRING +#undef APPPARAM + +} // namespace MOShared + +} // namespace AppConfig + +#endif // APPCONFIG_H diff --git a/src/src/shared/appconfig.inc b/src/src/shared/appconfig.inc new file mode 100644 index 0000000..9058bf1 --- /dev/null +++ b/src/src/shared/appconfig.inc @@ -0,0 +1,27 @@ +APPPARAM(std::wstring, translationPrefix, L"organizer") +APPPARAM(std::wstring, pluginPath, L"plugins") +APPPARAM(std::wstring, profilesPath, L"profiles") +APPPARAM(std::wstring, modsPath, L"mods") +APPPARAM(std::wstring, downloadPath, L"downloads") +APPPARAM(std::wstring, overwritePath, L"overwrite") +APPPARAM(std::wstring, stylesheetsPath, L"stylesheets") +APPPARAM(std::wstring, cachePath, L"webcache") +APPPARAM(std::wstring, tutorialsPath, L"tutorials") +APPPARAM(std::wstring, logPath, L"logs") +APPPARAM(std::wstring, dumpsDir, L"crashDumps") +APPPARAM(std::wstring, defaultProfileName, L"Default") +APPPARAM(std::wstring, profileTweakIni, L"profile_tweaks.ini") +APPPARAM(std::wstring, logFileName, L"mo_interface.log") +APPPARAM(std::wstring, iniFileName, L"ModOrganizer.ini") +APPPARAM(std::wstring, proxyDLLTarget, L"steam_api.dll") +APPPARAM(std::wstring, proxyDLLOrig, L"steam_api_orig.dll") // needs to be identical to the value used in proxydll-project +APPPARAM(std::wstring, proxyDLLSource, L"proxy.dll") +APPPARAM(std::wstring, vfs32DLLName, L"usvfs_x86.dll") +APPPARAM(std::wstring, vfs64DLLName, L"usvfs_x64.dll") +APPPARAM(std::wstring, nxmHandlerExe, L"nxmhandler.exe") +APPPARAM(std::wstring, nxmHandlerIni, L"nxmhandler.ini") +APPPARAM(std::wstring, portableLockFileName, L"portable.txt") +APPPARAM(const wchar_t*, localSavePlaceholder, L"__MOProfileSave__\\") + +APPPARAM(std::wstring, firstStepsTutorial, L"tutorial_firststeps_main.js") +APPPARAM(int, numLogFiles, 5) // number of log files to keep diff --git a/src/src/shared/directoryentry.cpp b/src/src/shared/directoryentry.cpp new file mode 100644 index 0000000..3f9eeef --- /dev/null +++ b/src/src/shared/directoryentry.cpp @@ -0,0 +1,993 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "directoryentry.h" +#include "../envfs.h" +#include "fileentry.h" +#include "filesorigin.h" +#include "originconnection.h" +#include "util.h" +#include "windows_error.h" +#include +#include +#include + +namespace MOShared +{ + +using namespace MOBase; +const int MAXPATH_UNICODE = 32767; + +template +void elapsedImpl(std::chrono::nanoseconds& out, F&& f) +{ + if constexpr (DirectoryStats::EnableInstrumentation) { + const auto start = std::chrono::high_resolution_clock::now(); + f(); + const auto end = std::chrono::high_resolution_clock::now(); + out += (end - start); + } else { + f(); + } +} + +// elapsed() is not optimized out when EnableInstrumentation is false even +// though it's equivalent that this macro +#define elapsed(OUT, F) (F)(); +// #define elapsed(OUT, F) elapsedImpl(OUT, F); + +#ifdef _WIN32 +static bool SupportOptimizedFind() +{ + // large fetch and basic info for FindFirstFileEx is supported on win server 2008 r2, + // win 7 and newer + + OSVERSIONINFOEX versionInfo; + versionInfo.dwOSVersionInfoSize = sizeof(OSVERSIONINFOEX); + versionInfo.dwMajorVersion = 6; + versionInfo.dwMinorVersion = 1; + + ULONGLONG mask = ::VerSetConditionMask( + ::VerSetConditionMask(0, VER_MAJORVERSION, VER_GREATER_EQUAL), VER_MINORVERSION, + VER_GREATER_EQUAL); + + return (::VerifyVersionInfo(&versionInfo, VER_MAJORVERSION | VER_MINORVERSION, + mask) == TRUE); +} +#else +static bool SupportOptimizedFind() +{ + return true; // Always true on Linux +} +#endif + +bool DirCompareByName::operator()(const DirectoryEntry* lhs, + const DirectoryEntry* rhs) const +{ +#ifdef _WIN32 + return _wcsicmp(lhs->getName().c_str(), rhs->getName().c_str()) < 0; +#else + return wcscasecmp(lhs->getName().c_str(), rhs->getName().c_str()) < 0; +#endif +} + +DirectoryEntry::DirectoryEntry(std::wstring name, DirectoryEntry* parent, int originID) + : m_OriginConnection(new OriginConnection), m_Name(std::move(name)), + m_Parent(parent), m_Populated(false), m_TopLevel(true) +{ + m_FileRegister.reset(new FileRegister(m_OriginConnection)); + m_Origins.insert(originID); +} + +DirectoryEntry::DirectoryEntry(std::wstring name, DirectoryEntry* parent, int originID, + boost::shared_ptr fileRegister, + boost::shared_ptr originConnection) + : m_FileRegister(fileRegister), m_OriginConnection(originConnection), + m_Name(std::move(name)), m_Parent(parent), m_Populated(false), m_TopLevel(false) +{ + m_Origins.insert(originID); +} + +DirectoryEntry::~DirectoryEntry() +{ + clear(); +} + +void DirectoryEntry::clear() +{ + for (auto itor = m_SubDirectories.rbegin(); itor != m_SubDirectories.rend(); ++itor) { + delete *itor; + } + + m_Files.clear(); + m_FilesLookup.clear(); + m_SubDirectories.clear(); + m_SubDirectoriesLookup.clear(); +} + +void DirectoryEntry::addFromOrigin(const std::wstring& originName, + const std::wstring& directory, int priority, + DirectoryStats& stats) +{ + env::DirectoryWalker walker; + addFromOrigin(walker, originName, directory, priority, stats); +} + +void DirectoryEntry::addFromOrigin(env::DirectoryWalker& walker, + const std::wstring& originName, + const std::wstring& directory, int priority, + DirectoryStats& stats) +{ + FilesOrigin& origin = createOrigin(originName, directory, priority, stats); + + if (!directory.empty()) { + addFiles(walker, origin, directory, stats); + } + + m_Populated = true; +} + +void DirectoryEntry::addFromList(const std::wstring& originName, + const std::wstring& directory, env::Directory& root, + int priority, DirectoryStats& stats) +{ + stats = {}; + + FilesOrigin& origin = createOrigin(originName, directory, priority, stats); + addDir(origin, root, stats); +} + +void DirectoryEntry::addDir(FilesOrigin& origin, env::Directory& d, + DirectoryStats& stats) +{ + elapsed(stats.dirTimes, [&] { + for (auto& sd : d.dirs) { + auto* sdirEntry = getSubDirectory(sd, true, stats, origin.getID()); + sdirEntry->addDir(origin, sd, stats); + } + }); + + elapsed(stats.fileTimes, [&] { + for (auto& f : d.files) { + insert(f, origin, L"", -1, stats); + } + }); + + m_Populated = true; +} + +void DirectoryEntry::addFromAllBSAs(const std::wstring& originName, + const std::wstring& directory, int priority, + const std::vector& archives, + const std::set& enabledArchives, + const std::vector& loadOrder, + DirectoryStats& stats) +{ + for (const auto& archive : archives) { + const std::filesystem::path archivePath(archive); + const auto filename = archivePath.filename().wstring(); + + if (!enabledArchives.contains(filename)) { + continue; + } + + const auto filenameLc = ToLowerCopy(filename); + + int order = -1; + + for (auto plugin : loadOrder) { + const auto pluginNameLc = + ToLowerCopy(std::filesystem::path(plugin).stem().wstring()); + + if (filenameLc.starts_with(pluginNameLc + L" - ") || + filenameLc.starts_with(pluginNameLc + L".")) { + auto itor = std::find(loadOrder.begin(), loadOrder.end(), plugin); + if (itor != loadOrder.end()) { + order = std::distance(loadOrder.begin(), itor); + } + } + } + + addFromBSA(originName, directory, archivePath.wstring(), priority, order, stats); + } +} + +void DirectoryEntry::addFromBSA(const std::wstring& originName, + const std::wstring& directory, + const std::wstring& archivePath, int priority, + int order, DirectoryStats& stats) +{ + FilesOrigin& origin = createOrigin(originName, directory, priority, stats); + const auto archiveName = std::filesystem::path(archivePath).filename().wstring(); + + if (containsArchive(archiveName)) { + return; + } + + BSA::Archive archive; + BSA::EErrorCode res = BSA::ERROR_NONE; + + try { + // read() can return an error, but it can also throw if the file is not a + // valid bsa + res = archive.read(ToString(archivePath, false).c_str(), false); + } catch (std::exception& e) { + log::error("invalid bsa '{}', error {}", archivePath, e.what()); + return; + } + + if ((res != BSA::ERROR_NONE) && (res != BSA::ERROR_INVALIDHASHES)) { + log::error("invalid bsa '{}', error {}", archivePath, res); + return; + } + + std::error_code ec; + const auto lwt = std::filesystem::last_write_time(archivePath, ec); + FILETIME ft = {}; + + if (ec) { + log::warn("failed to get last modified date for '{}', {}", archivePath, + ec.message()); + } else { + ft = ToFILETIME(lwt); + } + + addFiles(origin, archive.getRoot(), ft, archiveName, order, stats); + + m_Populated = true; +} + +void DirectoryEntry::propagateOrigin(int origin) +{ + { + std::scoped_lock lock(m_OriginsMutex); + m_Origins.insert(origin); + } + + if (m_Parent != nullptr) { + m_Parent->propagateOrigin(origin); + } +} + +bool DirectoryEntry::originExists(const std::wstring& name) const +{ + return m_OriginConnection->exists(name); +} + +FilesOrigin& DirectoryEntry::getOriginByID(int ID) const +{ + return m_OriginConnection->getByID(ID); +} + +FilesOrigin& DirectoryEntry::getOriginByName(const std::wstring& name) const +{ + return m_OriginConnection->getByName(name); +} + +const FilesOrigin* DirectoryEntry::findOriginByID(int ID) const +{ + return m_OriginConnection->findByID(ID); +} + +int DirectoryEntry::anyOrigin() const +{ + bool ignore; + + for (auto iter = m_Files.begin(); iter != m_Files.end(); ++iter) { + FileEntryPtr entry = m_FileRegister->getFile(iter->second); + if ((entry.get() != nullptr) && !entry->isFromArchive()) { + return entry->getOrigin(ignore); + } + } + + // if we got here, no file directly within this directory is a valid indicator for a + // mod, thus we continue looking in subdirectories + for (DirectoryEntry* entry : m_SubDirectories) { + int res = entry->anyOrigin(); + if (res != InvalidOriginID) { + return res; + } + } + + return *(m_Origins.begin()); +} + +std::vector DirectoryEntry::getFiles() const +{ + std::vector result; + result.reserve(m_Files.size()); + + for (auto iter = m_Files.begin(); iter != m_Files.end(); ++iter) { + result.push_back(m_FileRegister->getFile(iter->second)); + } + + return result; +} + +DirectoryEntry* DirectoryEntry::findSubDirectory(const std::wstring& name, + bool alreadyLowerCase) const +{ + SubDirectoriesLookup::const_iterator itor; + + if (alreadyLowerCase) { + itor = m_SubDirectoriesLookup.find(name); + } else { + itor = m_SubDirectoriesLookup.find(ToLowerCopy(name)); + } + + if (itor == m_SubDirectoriesLookup.end()) { + return nullptr; + } + + return itor->second; +} + +DirectoryEntry* DirectoryEntry::findSubDirectoryRecursive(const std::wstring& path) +{ + DirectoryStats dummy; + return getSubDirectoryRecursive(path, false, dummy, InvalidOriginID); +} + +const FileEntryPtr DirectoryEntry::findFile(const std::wstring& name, + bool alreadyLowerCase) const +{ + FilesLookup::const_iterator iter; + + if (alreadyLowerCase) { + iter = m_FilesLookup.find(DirectoryEntryFileKey(name)); + } else { + iter = m_FilesLookup.find(DirectoryEntryFileKey(ToLowerCopy(name))); + } + + if (iter != m_FilesLookup.end()) { + return m_FileRegister->getFile(iter->second); + } else { + return FileEntryPtr(); + } +} + +const FileEntryPtr DirectoryEntry::findFile(const DirectoryEntryFileKey& key) const +{ + auto iter = m_FilesLookup.find(key); + + if (iter != m_FilesLookup.end()) { + return m_FileRegister->getFile(iter->second); + } else { + return FileEntryPtr(); + } +} + +bool DirectoryEntry::hasFile(const std::wstring& name) const +{ + return m_Files.contains(ToLowerCopy(name)); +} + +bool DirectoryEntry::containsArchive(std::wstring archiveName) +{ + for (auto iter = m_Files.begin(); iter != m_Files.end(); ++iter) { + FileEntryPtr entry = m_FileRegister->getFile(iter->second); + if (entry->isFromArchive(archiveName)) { + return true; + } + } + + return false; +} + +const FileEntryPtr DirectoryEntry::searchFile(const std::wstring& path, + const DirectoryEntry** directory) const +{ + if (directory != nullptr) { + *directory = nullptr; + } + + if ((path.length() == 0) || (path == L"*")) { + // no file name -> the path ended on a (back-)slash + if (directory != nullptr) { + *directory = this; + } + + return FileEntryPtr(); + } + + const size_t len = path.find_first_of(L"\\/"); + + if (len == std::string::npos) { + // no more path components + auto iter = m_Files.find(ToLowerCopy(path)); + + if (iter != m_Files.end()) { + return m_FileRegister->getFile(iter->second); + } else if (directory != nullptr) { + DirectoryEntry* temp = findSubDirectory(path); + if (temp != nullptr) { + *directory = temp; + } + } + } else { + // file is in a subdirectory, recurse into the matching subdirectory + std::wstring pathComponent = path.substr(0, len); + DirectoryEntry* temp = findSubDirectory(pathComponent); + + if (temp != nullptr) { + if (len >= path.size()) { + log::error("{}", QObject::tr("unexpected end of path")); + return FileEntryPtr(); + } + + return temp->searchFile(path.substr(len + 1), directory); + } + } + + return FileEntryPtr(); +} + +void DirectoryEntry::removeFile(FileIndex index) +{ + removeFileFromList(index); +} + +bool DirectoryEntry::removeFile(const std::wstring& filePath, int* origin) +{ + size_t pos = filePath.find_first_of(L"\\/"); + + if (pos == std::string::npos) { + return this->remove(filePath, origin); + } + + std::wstring dirName = filePath.substr(0, pos); + std::wstring rest = filePath.substr(pos + 1); + + DirectoryStats dummy; + DirectoryEntry* entry = getSubDirectoryRecursive(dirName, false, dummy); + + if (entry != nullptr) { + return entry->removeFile(rest, origin); + } else { + return false; + } +} + +void DirectoryEntry::removeDir(const std::wstring& path) +{ + size_t pos = path.find_first_of(L"\\/"); + + if (pos == std::string::npos) { + for (auto iter = m_SubDirectories.begin(); iter != m_SubDirectories.end(); ++iter) { + DirectoryEntry* entry = *iter; + + if (CaseInsensitiveEqual(entry->getName(), path)) { + entry->removeDirRecursive(); + removeDirectoryFromList(iter); + delete entry; + break; + } + } + } else { + std::wstring dirName = path.substr(0, pos); + std::wstring rest = path.substr(pos + 1); + + DirectoryStats dummy; + DirectoryEntry* entry = getSubDirectoryRecursive(dirName, false, dummy); + + if (entry != nullptr) { + entry->removeDir(rest); + } + } +} + +bool DirectoryEntry::remove(const std::wstring& fileName, int* origin) +{ + const auto lcFileName = ToLowerCopy(fileName); + + auto iter = m_Files.find(lcFileName); + bool b = false; + + if (iter != m_Files.end()) { + if (origin != nullptr) { + FileEntryPtr entry = m_FileRegister->getFile(iter->second); + if (entry.get() != nullptr) { + bool ignore; + *origin = entry->getOrigin(ignore); + } + } + + b = m_FileRegister->removeFile(iter->second); + } + + return b; +} + +bool DirectoryEntry::hasContentsFromOrigin(int originID) const +{ + return m_Origins.find(originID) != m_Origins.end(); +} + +FilesOrigin& DirectoryEntry::createOrigin(const std::wstring& originName, + const std::wstring& directory, int priority, + DirectoryStats& stats) +{ + auto r = m_OriginConnection->getOrCreate(originName, directory, priority, + m_FileRegister, m_OriginConnection, stats); + + if (r.second) { + ++stats.originCreate; + } else { + ++stats.originExists; + } + + return r.first; +} + +void DirectoryEntry::removeFiles(const std::set& indices) +{ + removeFilesFromList(indices); +} + +FileEntryPtr DirectoryEntry::insert(std::wstring_view fileName, FilesOrigin& origin, + FILETIME fileTime, std::wstring_view archive, + int order, DirectoryStats& stats) +{ + std::wstring fileNameLower = ToLowerCopy(fileName); + FileEntryPtr fe; + + DirectoryEntryFileKey key(std::move(fileNameLower)); + + { + std::unique_lock lock(m_FilesMutex); + + FilesLookup::iterator itor; + + elapsed(stats.filesLookupTimes, [&] { + itor = m_FilesLookup.find(key); + }); + + if (itor != m_FilesLookup.end()) { + lock.unlock(); + ++stats.fileExists; + fe = m_FileRegister->getFile(itor->second); + } else { + ++stats.fileCreate; + fe = m_FileRegister->createFile(std::wstring(fileName.begin(), fileName.end()), + this, stats); + + elapsed(stats.addFileTimes, [&] { + addFileToList(std::move(key.value), fe->getIndex()); + }); + + // fileNameLower has moved from this point + } + } + + elapsed(stats.addOriginToFileTimes, [&] { + fe->addOrigin(origin.getID(), fileTime, archive, order); + }); + + elapsed(stats.addFileToOriginTimes, [&] { + origin.addFile(fe->getIndex()); + }); + + return fe; +} + +FileEntryPtr DirectoryEntry::insert(env::File& file, FilesOrigin& origin, + std::wstring_view archive, int order, + DirectoryStats& stats) +{ + FileEntryPtr fe; + + { + std::unique_lock lock(m_FilesMutex); + + FilesMap::iterator itor; + + elapsed(stats.filesLookupTimes, [&] { + itor = m_Files.find(file.lcname); + }); + + if (itor != m_Files.end()) { + lock.unlock(); + ++stats.fileExists; + fe = m_FileRegister->getFile(itor->second); + } else { + ++stats.fileCreate; + fe = m_FileRegister->createFile(std::move(file.name), this, stats); + // file.name has been moved from this point + + elapsed(stats.addFileTimes, [&] { + addFileToList(std::move(file.lcname), fe->getIndex()); + }); + + // file.lcname has been moved from this point + } + } + + elapsed(stats.addOriginToFileTimes, [&] { + fe->addOrigin(origin.getID(), file.lastModified, archive, order); + }); + + elapsed(stats.addFileToOriginTimes, [&] { + origin.addFile(fe->getIndex()); + }); + + return fe; +} + +struct DirectoryEntry::Context +{ + FilesOrigin& origin; + DirectoryStats& stats; + std::stack current; +}; + +void DirectoryEntry::addFiles(env::DirectoryWalker& walker, FilesOrigin& origin, + const std::wstring& path, DirectoryStats& stats) +{ + Context cx = {origin, stats}; + cx.current.push(this); + +#ifdef _WIN32 + const bool pathExists = std::filesystem::exists(path); +#else + // On Linux, convert wstring to narrow string for std::filesystem + // to avoid locale-dependent wchar_t conversion issues + const bool pathExists = + std::filesystem::exists(QString::fromStdWString(path).toStdString()); +#endif + + if (pathExists) { + walker.forEachEntry( + path, &cx, + [](void* pcx, std::wstring_view path) { + onDirectoryStart((Context*)pcx, path); + }, + + [](void* pcx, std::wstring_view path) { + onDirectoryEnd((Context*)pcx, path); + }, + + [](void* pcx, std::wstring_view path, FILETIME ft, uint64_t) { + onFile((Context*)pcx, path, ft); + }); + } +} + +void DirectoryEntry::onDirectoryStart(Context* cx, std::wstring_view path) +{ + elapsed(cx->stats.dirTimes, [&] { + auto* sd = + cx->current.top()->getSubDirectory(path, true, cx->stats, cx->origin.getID()); + + cx->current.push(sd); + }); +} + +void DirectoryEntry::onDirectoryEnd(Context* cx, std::wstring_view path) +{ + elapsed(cx->stats.dirTimes, [&] { + cx->current.pop(); + }); +} + +void DirectoryEntry::onFile(Context* cx, std::wstring_view path, FILETIME ft) +{ + elapsed(cx->stats.fileTimes, [&] { + cx->current.top()->insert(path, cx->origin, ft, L"", -1, cx->stats); + }); +} + +void DirectoryEntry::addFiles(FilesOrigin& origin, const BSA::Folder::Ptr archiveFolder, + FILETIME fileTime, const std::wstring& archiveName, + int order, DirectoryStats& stats) +{ + // add files + const auto fileCount = archiveFolder->getNumFiles(); + for (unsigned int i = 0; i < fileCount; ++i) { + const BSA::File::Ptr file = archiveFolder->getFile(i); + + auto f = insert(ToWString(file->getName(), true), origin, fileTime, archiveName, + order, stats); + + if (f) { + if (file->getUncompressedFileSize() > 0) { + f->setFileSize(file->getFileSize(), file->getUncompressedFileSize()); + } else { + f->setFileSize(file->getFileSize(), FileEntry::NoFileSize); + } + } + } + + // recurse into subdirectories + const auto dirCount = archiveFolder->getNumSubFolders(); + for (unsigned int i = 0; i < dirCount; ++i) { + const BSA::Folder::Ptr folder = archiveFolder->getSubFolder(i); + + DirectoryEntry* folderEntry = getSubDirectoryRecursive( + ToWString(folder->getName(), true), true, stats, origin.getID()); + + folderEntry->addFiles(origin, folder, fileTime, archiveName, order, stats); + } +} + +DirectoryEntry* DirectoryEntry::getSubDirectory(std::wstring_view name, bool create, + DirectoryStats& stats, int originID) +{ + std::wstring nameLc = ToLowerCopy(name); + + std::scoped_lock lock(m_SubDirMutex); + + SubDirectoriesLookup::iterator itor; + elapsed(stats.subdirLookupTimes, [&] { + itor = m_SubDirectoriesLookup.find(nameLc); + }); + + if (itor != m_SubDirectoriesLookup.end()) { + ++stats.subdirExists; + return itor->second; + } + + if (create) { + ++stats.subdirCreate; + + auto* entry = new DirectoryEntry(std::wstring(name.begin(), name.end()), this, + originID, m_FileRegister, m_OriginConnection); + + elapsed(stats.addDirectoryTimes, [&] { + addDirectoryToList(entry, std::move(nameLc)); + // nameLc is moved from this point + }); + + return entry; + } else { + return nullptr; + } +} + +DirectoryEntry* DirectoryEntry::getSubDirectory(env::Directory& dir, bool create, + DirectoryStats& stats, int originID) +{ + SubDirectoriesLookup::iterator itor; + + std::scoped_lock lock(m_SubDirMutex); + + elapsed(stats.subdirLookupTimes, [&] { + itor = m_SubDirectoriesLookup.find(dir.lcname); + }); + + if (itor != m_SubDirectoriesLookup.end()) { + ++stats.subdirExists; + return itor->second; + } + + if (create) { + ++stats.subdirCreate; + + auto* entry = new DirectoryEntry(std::move(dir.name), this, originID, + m_FileRegister, m_OriginConnection); + // dir.name is moved from this point + + elapsed(stats.addDirectoryTimes, [&] { + addDirectoryToList(entry, std::move(dir.lcname)); + }); + + // dir.lcname is moved from this point + + return entry; + } else { + return nullptr; + } +} + +DirectoryEntry* DirectoryEntry::getSubDirectoryRecursive(const std::wstring& path, + bool create, + DirectoryStats& stats, + int originID) +{ + if (path.length() == 0) { + // path ended with a backslash? + return this; + } + + const size_t pos = path.find_first_of(L"\\/"); + + if (pos == std::wstring::npos) { + return getSubDirectory(path, create, stats); + } else { + DirectoryEntry* nextChild = + getSubDirectory(path.substr(0, pos), create, stats, originID); + + if (nextChild == nullptr) { + return nullptr; + } else { + return nextChild->getSubDirectoryRecursive(path.substr(pos + 1), create, stats, + originID); + } + } +} + +void DirectoryEntry::removeDirRecursive() +{ + while (!m_Files.empty()) { + m_FileRegister->removeFile(m_Files.begin()->second); + } + + m_FilesLookup.clear(); + + for (DirectoryEntry* entry : m_SubDirectories) { + entry->removeDirRecursive(); + delete entry; + } + + m_SubDirectories.clear(); + m_SubDirectoriesLookup.clear(); +} + +void DirectoryEntry::addDirectoryToList(DirectoryEntry* e, std::wstring nameLc) +{ + m_SubDirectories.insert(e); + m_SubDirectoriesLookup.emplace(std::move(nameLc), e); +} + +void DirectoryEntry::removeDirectoryFromList(SubDirectories::iterator itor) +{ + const auto* entry = *itor; + + { + auto itor2 = std::find_if(m_SubDirectoriesLookup.begin(), + m_SubDirectoriesLookup.end(), [&](auto&& d) { + return (d.second == entry); + }); + + if (itor2 == m_SubDirectoriesLookup.end()) { + log::error("entry {} not in sub directories map", entry->getName()); + } else { + m_SubDirectoriesLookup.erase(itor2); + } + } + + m_SubDirectories.erase(itor); +} + +void DirectoryEntry::removeFileFromList(FileIndex index) +{ + auto removeFrom = [&](auto& list) { + auto iter = std::find_if(list.begin(), list.end(), [&index](auto&& pair) { + return (pair.second == index); + }); + + if (iter == list.end()) { + auto f = m_FileRegister->getFile(index); + + if (f) { + log::error("can't remove file '{}', not in directory entry '{}'", f->getName(), + getName()); + } else { + log::error("can't remove file with index {}, not in directory entry '{}' and " + "not in register", + index, getName()); + } + } else { + list.erase(iter); + } + }; + + removeFrom(m_FilesLookup); + removeFrom(m_Files); +} + +void DirectoryEntry::removeFilesFromList(const std::set& indices) +{ + for (auto iter = m_Files.begin(); iter != m_Files.end();) { + if (indices.find(iter->second) != indices.end()) { + iter = m_Files.erase(iter); + } else { + ++iter; + } + } + + for (auto iter = m_FilesLookup.begin(); iter != m_FilesLookup.end();) { + if (indices.find(iter->second) != indices.end()) { + iter = m_FilesLookup.erase(iter); + } else { + ++iter; + } + } +} + +void DirectoryEntry::addFileToList(std::wstring fileNameLower, FileIndex index) +{ + m_FilesLookup.emplace(fileNameLower, index); + m_Files.emplace(std::move(fileNameLower), index); + // fileNameLower has been moved from this point +} + +struct DumpFailed : public std::runtime_error +{ + using runtime_error::runtime_error; +}; + +void DirectoryEntry::dump(const std::wstring& file) const +{ + try { +#ifdef _WIN32 + std::FILE* f = nullptr; + auto e = _wfopen_s(&f, file.c_str(), L"wb"); + + if (e != 0 || !f) { + throw DumpFailed(std::format("failed to open, {} ({})", std::strerror(e), e)); + } +#else + std::string narrowFile(file.begin(), file.end()); + std::FILE* f = std::fopen(narrowFile.c_str(), "wb"); + if (!f) { + auto e = errno; + throw DumpFailed(std::format("failed to open, {} ({})", std::strerror(e), e)); + } +#endif + + Guard g([&] { + std::fclose(f); + }); + + dump(f, L"Data"); + } catch (DumpFailed& e) { + log::error("failed to write list to '{}': {}", + QString::fromStdWString(file).toStdString(), e.what()); + } +} + +void DirectoryEntry::dump(std::FILE* f, const std::wstring& parentPath) const +{ + { + std::scoped_lock lock(m_FilesMutex); + + for (auto&& index : m_Files) { + const auto file = m_FileRegister->getFile(index.second); + if (!file) { + continue; + } + + if (file->isFromArchive()) { + // TODO: don't list files from archives. maybe make this an option? + continue; + } + + const auto& o = m_OriginConnection->getByID(file->getOrigin()); + const auto path = parentPath + NativeWPathSep + file->getName(); + const auto line = path + L"\t(" + o.getName() + L")\r\n"; + + const auto lineu8 = MOShared::ToString(line, true); + + if (std::fwrite(lineu8.data(), lineu8.size(), 1, f) != 1) { + const auto e = errno; + throw DumpFailed(std::format("failed to write, {} ({})", std::strerror(e), e)); + } + } + } + + { + std::scoped_lock lock(m_SubDirMutex); + for (auto&& d : m_SubDirectories) { + const auto path = parentPath + NativeWPathSep + d->m_Name; + d->dump(f, path); + } + } +} + +} // namespace MOShared diff --git a/src/src/shared/directoryentry.h b/src/src/shared/directoryentry.h new file mode 100644 index 0000000..06d401e --- /dev/null +++ b/src/src/shared/directoryentry.h @@ -0,0 +1,285 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef MO_REGISTER_DIRECTORYENTRY_INCLUDED +#define MO_REGISTER_DIRECTORYENTRY_INCLUDED + +#include + +#include "fileregister.h" + +namespace env +{ +class DirectoryWalker; +struct Directory; +struct File; +} // namespace env + +namespace std +{ +template <> +struct hash +{ + using argument_type = MOShared::DirectoryEntryFileKey; + using result_type = std::size_t; + + inline result_type operator()(const argument_type& key) const; +}; +} // namespace std + +namespace MOShared +{ + +struct DirCompareByName +{ + bool operator()(const DirectoryEntry* a, const DirectoryEntry* b) const; +}; + +class DirectoryEntry +{ +public: + using SubDirectories = std::set; + + DirectoryEntry(std::wstring name, DirectoryEntry* parent, OriginID originID); + + DirectoryEntry(std::wstring name, DirectoryEntry* parent, OriginID originID, + boost::shared_ptr fileRegister, + boost::shared_ptr originConnection); + + ~DirectoryEntry(); + + // noncopyable + DirectoryEntry(const DirectoryEntry&) = delete; + DirectoryEntry& operator=(const DirectoryEntry&) = delete; + + void clear(); + + bool isPopulated() const { return m_Populated; } + + bool isTopLevel() const { return m_TopLevel; } + + bool isEmpty() const { return m_Files.empty() && m_SubDirectories.empty(); } + + bool hasFiles() const { return !m_Files.empty(); } + + const DirectoryEntry* getParent() const { return m_Parent; } + + // add files to this directory (and subdirectories) from the specified origin. + // That origin may exist or not + void addFromOrigin(const std::wstring& originName, const std::wstring& directory, + int priority, DirectoryStats& stats); + + void addFromOrigin(env::DirectoryWalker& walker, const std::wstring& originName, + const std::wstring& directory, int priority, + DirectoryStats& stats); + + void addFromAllBSAs(const std::wstring& originName, const std::wstring& directory, + int priority, const std::vector& archives, + const std::set& enabledArchives, + const std::vector& loadOrder, + DirectoryStats& stats); + + void addFromBSA(const std::wstring& originName, const std::wstring& directory, + const std::wstring& archivePath, int priority, int order, + DirectoryStats& stats); + + void addFromList(const std::wstring& originName, const std::wstring& directory, + env::Directory& root, int priority, DirectoryStats& stats); + + void propagateOrigin(OriginID origin); + + const std::wstring& getName() const { return m_Name; } + + boost::shared_ptr getFileRegister() { return m_FileRegister; } + + bool originExists(const std::wstring& name) const; + FilesOrigin& getOriginByID(OriginID ID) const; + FilesOrigin& getOriginByName(const std::wstring& name) const; + const FilesOrigin* findOriginByID(OriginID ID) const; + + OriginID anyOrigin() const; + + std::vector getFiles() const; + + const SubDirectories& getSubDirectories() const { return m_SubDirectories; } + + template + void forEachDirectory(F&& f) const + { + for (auto&& d : m_SubDirectories) { + if (!f(*d)) { + break; + } + } + } + + template + void forEachFile(F&& f) const + { + for (auto&& p : m_Files) { + if (auto file = m_FileRegister->getFile(p.second)) { + if (!f(*file)) { + break; + } + } + } + } + + template + void forEachFileIndex(F&& f) const + { + for (auto&& p : m_Files) { + if (!f(p.second)) { + break; + } + } + } + + FileEntryPtr getFileByIndex(FileIndex index) const + { + return m_FileRegister->getFile(index); + } + + DirectoryEntry* findSubDirectory(const std::wstring& name, + bool alreadyLowerCase = false) const; + + DirectoryEntry* findSubDirectoryRecursive(const std::wstring& path); + + /** retrieve a file in this directory by name. + * @param name name of the file + * @return fileentry object for the file or nullptr if no file matches + */ + const FileEntryPtr findFile(const std::wstring& name, + bool alreadyLowerCase = false) const; + const FileEntryPtr findFile(const DirectoryEntryFileKey& key) const; + + bool hasFile(const std::wstring& name) const; + bool containsArchive(std::wstring archiveName); + + // search through this directory and all subdirectories for a file by the + // specified name (relative path). + // + // if directory is not nullptr, the referenced variable will be set to the + // path containing the file + // + const FileEntryPtr searchFile(const std::wstring& path, + const DirectoryEntry** directory = nullptr) const; + + void removeFile(FileIndex index); + + // remove the specified file from the tree. This can be a path leading to a + // file in a subdirectory + bool removeFile(const std::wstring& filePath, OriginID* origin = nullptr); + + /** + * @brief remove the specified directory + * @param path directory to remove + */ + void removeDir(const std::wstring& path); + + bool remove(const std::wstring& fileName, OriginID* origin); + + bool hasContentsFromOrigin(OriginID originID) const; + + FilesOrigin& createOrigin(const std::wstring& originName, + const std::wstring& directory, int priority, + DirectoryStats& stats); + + void removeFiles(const std::set& indices); + + void dump(const std::wstring& file) const; + +private: + using FilesMap = std::map; + using FilesLookup = std::unordered_map; + using SubDirectoriesLookup = std::unordered_map; + + boost::shared_ptr m_FileRegister; + boost::shared_ptr m_OriginConnection; + + std::wstring m_Name; + FilesMap m_Files; + FilesLookup m_FilesLookup; + SubDirectories m_SubDirectories; + SubDirectoriesLookup m_SubDirectoriesLookup; + + DirectoryEntry* m_Parent; + std::set m_Origins; + bool m_Populated; + bool m_TopLevel; + mutable std::mutex m_SubDirMutex; + mutable std::mutex m_FilesMutex; + mutable std::mutex m_OriginsMutex; + + FileEntryPtr insert(std::wstring_view fileName, FilesOrigin& origin, + FILETIME fileTime, std::wstring_view archive, int order, + DirectoryStats& stats); + + FileEntryPtr insert(env::File& file, FilesOrigin& origin, std::wstring_view archive, + int order, DirectoryStats& stats); + + void addFiles(env::DirectoryWalker& walker, FilesOrigin& origin, + const std::wstring& path, DirectoryStats& stats); + + void addFiles(FilesOrigin& origin, BSA::Folder::Ptr archiveFolder, FILETIME fileTime, + const std::wstring& archiveName, int order, DirectoryStats& stats); + + void addDir(FilesOrigin& origin, env::Directory& d, DirectoryStats& stats); + + DirectoryEntry* getSubDirectory(std::wstring_view name, bool create, + DirectoryStats& stats, + OriginID originID = InvalidOriginID); + + DirectoryEntry* getSubDirectory(env::Directory& dir, bool create, + DirectoryStats& stats, + OriginID originID = InvalidOriginID); + + DirectoryEntry* getSubDirectoryRecursive(const std::wstring& path, bool create, + DirectoryStats& stats, + OriginID originID = InvalidOriginID); + + void removeDirRecursive(); + + void addDirectoryToList(DirectoryEntry* e, std::wstring nameLc); + void removeDirectoryFromList(SubDirectories::iterator itor); + + void addFileToList(std::wstring fileNameLower, FileIndex index); + void removeFileFromList(FileIndex index); + void removeFilesFromList(const std::set& indices); + + struct Context; + static void onDirectoryStart(Context* cx, std::wstring_view path); + static void onDirectoryEnd(Context* cx, std::wstring_view path); + static void onFile(Context* cx, std::wstring_view path, FILETIME ft); + + void dump(std::FILE* f, const std::wstring& parentPath) const; +}; + +} // namespace MOShared + +namespace std +{ +hash::result_type +hash::operator()(const argument_type& key) const +{ + return key.hash; +} +} // namespace std + +#endif // MO_REGISTER_DIRECTORYENTRY_INCLUDED diff --git a/src/src/shared/fileentry.cpp b/src/src/shared/fileentry.cpp new file mode 100644 index 0000000..f05a859 --- /dev/null +++ b/src/src/shared/fileentry.cpp @@ -0,0 +1,256 @@ +#include "fileentry.h" +#include "directoryentry.h" +#include "filesorigin.h" +#include "util.h" + +namespace MOShared +{ + +FileEntry::FileEntry() + : m_Index(InvalidFileIndex), m_Name(), m_Origin(-1), m_Parent(nullptr), + m_FileSize(NoFileSize), m_CompressedFileSize(NoFileSize) +{} + +FileEntry::FileEntry(FileIndex index, std::wstring name, DirectoryEntry* parent) + : m_Index(index), m_Name(std::move(name)), m_Origin(-1), m_Archive(L"", -1), + m_Parent(parent), m_FileSize(NoFileSize), m_CompressedFileSize(NoFileSize) +{} + +void FileEntry::addOrigin(OriginID origin, FILETIME fileTime, std::wstring_view archive, + int order) +{ + std::scoped_lock lock(m_OriginsMutex); + + if (m_Parent != nullptr) { + m_Parent->propagateOrigin(origin); + } + + if (m_Origin == -1) { + // If this file has no previous origin, this mod is now the origin with no + // alternatives + m_Origin = origin; + m_FileTime = fileTime; + m_Archive = DataArchiveOrigin(std::wstring(archive.begin(), archive.end()), order); + } else if ((m_Parent != nullptr) && + ((m_Parent->getOriginByID(origin).getPriority() > + m_Parent->getOriginByID(m_Origin).getPriority()) || + (archive.size() == 0 && m_Archive.isValid()))) { + // If this mod has a higher priority than the origin mod OR + // this mod has a loose file and the origin mod has an archived file, + // this mod is now the origin and the previous origin is the first alternative + + auto itor = + std::find_if(m_Alternatives.begin(), m_Alternatives.end(), [&](auto&& i) { + return i.originID() == m_Origin; + }); + + if (itor == m_Alternatives.end()) { + m_Alternatives.push_back({m_Origin, m_Archive}); + } + + m_Origin = origin; + m_FileTime = fileTime; + m_Archive = DataArchiveOrigin(std::wstring(archive.begin(), archive.end()), order); + } else { + // This mod is just an alternative + bool found = false; + + if (m_Origin == origin) { + // already an origin + return; + } + + for (auto iter = m_Alternatives.begin(); iter != m_Alternatives.end(); ++iter) { + if (iter->originID() == origin) { + // already an origin + return; + } + + if ((m_Parent != nullptr) && + (m_Parent->getOriginByID(iter->originID()).getPriority() < + m_Parent->getOriginByID(origin).getPriority())) { + m_Alternatives.insert( + iter, {origin, {std::wstring(archive.begin(), archive.end()), order}}); + found = true; + break; + } + } + + if (!found) { + m_Alternatives.push_back( + {origin, {std::wstring(archive.begin(), archive.end()), order}}); + } + } +} + +bool FileEntry::removeOrigin(OriginID origin) +{ + std::scoped_lock lock(m_OriginsMutex); + + if (m_Origin == origin) { + if (!m_Alternatives.empty()) { + // find alternative with the highest priority + auto currentIter = m_Alternatives.begin(); + for (auto iter = m_Alternatives.begin(); iter != m_Alternatives.end(); ++iter) { + if (iter->originID() != origin) { + // Both files are not from archives. + if (!iter->isFromArchive() && !currentIter->isFromArchive()) { + if ((m_Parent->getOriginByID(iter->originID()).getPriority() > + m_Parent->getOriginByID(currentIter->originID()).getPriority())) { + currentIter = iter; + } + } else { + // Both files are from archives + if (iter->isFromArchive() && currentIter->isFromArchive()) { + if (iter->archive().order() > currentIter->archive().order()) { + currentIter = iter; + } + } else { + // Only one of the two is an archive, so we change currentIter only if he + // is the archive one. + if (currentIter->isFromArchive()) { + currentIter = iter; + } + } + } + } + } + + OriginID currentID = currentIter->originID(); + m_Archive = currentIter->archive(); + m_Alternatives.erase(currentIter); + + m_Origin = currentID; + } else { + m_Origin = -1; + m_Archive = DataArchiveOrigin(L"", -1); + return true; + } + } else { + auto newEnd = + std::remove_if(m_Alternatives.begin(), m_Alternatives.end(), [&](auto& i) { + return i.originID() == origin; + }); + + if (newEnd != m_Alternatives.end()) { + m_Alternatives.erase(newEnd, m_Alternatives.end()); + } + } + return false; +} + +void FileEntry::sortOrigins() +{ + std::scoped_lock lock(m_OriginsMutex); + + m_Alternatives.push_back({m_Origin, m_Archive}); + + std::sort(m_Alternatives.begin(), m_Alternatives.end(), [&](auto&& LHS, auto&& RHS) { + if (!LHS.isFromArchive() && !RHS.isFromArchive()) { + int l = m_Parent->getOriginByID(LHS.originID()).getPriority(); + if (l < 0) { + l = INT_MAX; + } + + int r = m_Parent->getOriginByID(RHS.originID()).getPriority(); + if (r < 0) { + r = INT_MAX; + } + + return l < r; + } + + if (LHS.isFromArchive() && RHS.isFromArchive()) { + int l = LHS.archive().order(); + if (l < 0) + l = INT_MAX; + int r = RHS.archive().order(); + if (r < 0) + r = INT_MAX; + + return l < r; + } + + if (RHS.isFromArchive()) { + return false; + } + + return true; + }); + + if (!m_Alternatives.empty()) { + m_Origin = m_Alternatives.back().originID(); + m_Archive = m_Alternatives.back().archive(); + m_Alternatives.pop_back(); + } +} + +bool FileEntry::isFromArchive(std::wstring archiveName) const +{ + std::scoped_lock lock(m_OriginsMutex); + + if (archiveName.length() == 0) { + return m_Archive.isValid(); + } + + if (m_Archive.name().compare(archiveName) == 0) { + return true; + } + + for (const auto& alternative : m_Alternatives) { + if (alternative.archive().name().compare(archiveName) == 0) { + return true; + } + } + + return false; +} + +std::wstring FileEntry::getFullPath(OriginID originID) const +{ + std::scoped_lock lock(m_OriginsMutex); + + if (originID == InvalidOriginID) { + bool ignore = false; + originID = getOrigin(ignore); + } + + // base directory for origin + const auto* o = m_Parent->findOriginByID(originID); + if (!o) { + return {}; + } + + std::wstring result = o->getPath(); + + // all intermediate directories + recurseParents(result, m_Parent); + + return result + NativeWPathSep + m_Name; +} + +std::wstring FileEntry::getRelativePath() const +{ + std::wstring result; + + // all intermediate directories + recurseParents(result, m_Parent); + + return result + NativeWPathSep + m_Name; +} + +bool FileEntry::recurseParents(std::wstring& path, const DirectoryEntry* parent) const +{ + if (parent == nullptr) { + return false; + } else { + // don't append the topmost parent because it is the virtual data-root + if (recurseParents(path, parent->getParent())) { + path.append(1, NativeWPathSep).append(parent->getName()); + } + + return true; + } +} + +} // namespace MOShared diff --git a/src/src/shared/fileentry.h b/src/src/shared/fileentry.h new file mode 100644 index 0000000..787550e --- /dev/null +++ b/src/src/shared/fileentry.h @@ -0,0 +1,91 @@ +#ifndef MO_REGISTER_FILEENTRY_INCLUDED +#define MO_REGISTER_FILEENTRY_INCLUDED + +#include "fileregisterfwd.h" + +namespace MOShared +{ + +class FileEntry +{ +public: + static constexpr uint64_t NoFileSize = std::numeric_limits::max(); + + FileEntry(); + FileEntry(FileIndex index, std::wstring name, DirectoryEntry* parent); + + // noncopyable + FileEntry(const FileEntry&) = delete; + FileEntry& operator=(const FileEntry&) = delete; + + FileIndex getIndex() const { return m_Index; } + + void addOrigin(OriginID origin, FILETIME fileTime, std::wstring_view archive, + int order); + + // remove the specified origin from the list of origins that contain this + // file. if no origin is left, the file is effectively deleted and true is + // returned. otherwise, false is returned + bool removeOrigin(OriginID origin); + + void sortOrigins(); + + // gets the list of alternative origins (origins with lower priority than + // the primary one). if sortOrigins has been called, it is sorted by priority + // (ascending) + const AlternativesVector& getAlternatives() const { return m_Alternatives; } + + const std::wstring& getName() const { return m_Name; } + + OriginID getOrigin() const { return m_Origin; } + + OriginID getOrigin(bool& archive) const + { + archive = m_Archive.isValid(); + return m_Origin; + } + + const DataArchiveOrigin& getArchive() const { return m_Archive; } + + bool isFromArchive(std::wstring archiveName = L"") const; + + // if originID is -1, uses the main origin; if this file doesn't exist in the + // given origin, returns an empty string + // + std::wstring getFullPath(OriginID originID = InvalidOriginID) const; + + std::wstring getRelativePath() const; + + DirectoryEntry* getParent() { return m_Parent; } + + void setFileTime(FILETIME fileTime) const { m_FileTime = fileTime; } + + FILETIME getFileTime() const { return m_FileTime; } + + void setFileSize(uint64_t size, uint64_t compressedSize) + { + m_FileSize = size; + m_CompressedFileSize = compressedSize; + } + + uint64_t getFileSize() const { return m_FileSize; } + + uint64_t getCompressedFileSize() const { return m_CompressedFileSize; } + +private: + FileIndex m_Index; + std::wstring m_Name; + OriginID m_Origin; + DataArchiveOrigin m_Archive; + AlternativesVector m_Alternatives; + DirectoryEntry* m_Parent; + mutable FILETIME m_FileTime; + uint64_t m_FileSize, m_CompressedFileSize; + mutable std::mutex m_OriginsMutex; + + bool recurseParents(std::wstring& path, const DirectoryEntry* parent) const; +}; + +} // namespace MOShared + +#endif // MO_REGISTER_FILEENTRY_INCLUDED diff --git a/src/src/shared/fileregister.cpp b/src/src/shared/fileregister.cpp new file mode 100644 index 0000000..c62a358 --- /dev/null +++ b/src/src/shared/fileregister.cpp @@ -0,0 +1,183 @@ +#include "fileregister.h" +#include "directoryentry.h" +#include "fileentry.h" +#include "filesorigin.h" +#include "originconnection.h" +#include + +namespace MOShared +{ + +using namespace MOBase; + +FileRegister::FileRegister(boost::shared_ptr originConnection) + : m_OriginConnection(originConnection), m_NextIndex(0) +{} + +bool FileRegister::indexValid(FileIndex index) const +{ + std::scoped_lock lock(m_Mutex); + + if (index < m_Files.size()) { + return (m_Files[index].get() != nullptr); + } + + return false; +} + +FileEntryPtr FileRegister::createFile(std::wstring name, DirectoryEntry* parent, + DirectoryStats& stats) +{ + const auto index = generateIndex(); + auto p = FileEntryPtr(new FileEntry(index, std::move(name), parent)); + + { + std::scoped_lock lock(m_Mutex); + + if (index >= m_Files.size()) { + m_Files.resize(index + 1); + } + + m_Files[index] = p; + } + + return p; +} + +FileIndex FileRegister::generateIndex() +{ + return m_NextIndex++; +} + +FileEntryPtr FileRegister::getFile(FileIndex index) const +{ + std::scoped_lock lock(m_Mutex); + + if (index < m_Files.size()) { + return m_Files[index]; + } else { + return {}; + } +} + +bool FileRegister::removeFile(FileIndex index) +{ + std::scoped_lock lock(m_Mutex); + + if (index < m_Files.size()) { + FileEntryPtr p; + m_Files[index].swap(p); + + if (p) { + unregisterFile(p); + return true; + } + } + + log::error("{}: {}", QObject::tr("invalid file index for remove"), index); + return false; +} + +void FileRegister::removeOrigin(FileIndex index, OriginID originID) +{ + std::unique_lock lock(m_Mutex); + + if (index < m_Files.size()) { + FileEntryPtr& p = m_Files[index]; + + if (p) { + if (p->removeOrigin(originID)) { + m_Files[index] = {}; + lock.unlock(); + unregisterFile(p); + return; + } + } + } + + log::error("{}: {}", QObject::tr("invalid file index for remove (for origin)"), + index); +} + +void FileRegister::removeOriginMulti(std::set indices, OriginID originID) +{ + std::vector removedFiles; + + { + std::scoped_lock lock(m_Mutex); + + for (auto iter = indices.begin(); iter != indices.end();) { + const auto index = *iter; + + if (index < m_Files.size()) { + const auto& p = m_Files[index]; + + if (p && p->removeOrigin(originID)) { + removedFiles.push_back(p); + m_Files[index] = {}; + ++iter; + continue; + } + } + + iter = indices.erase(iter); + } + } + + // optimization: this is only called when disabling an origin and in this case + // we don't have to remove the file from the origin + + // need to remove files from their parent directories. multiple ways to go + // about this: + // a) for each file, search its parents file-list (preferably by name) and + // remove what is found + // b) gather the parent directories, go through the file list for each once + // and remove all files that have been removed + // + // the latter should be faster when there are many files in few directories. + // since this is called only when disabling an origin that is probably + // frequently the case + + std::set parents; + for (const FileEntryPtr& file : removedFiles) { + if (file->getParent() != nullptr) { + parents.insert(file->getParent()); + } + } + + for (DirectoryEntry* parent : parents) { + parent->removeFiles(indices); + } +} + +void FileRegister::sortOrigins() +{ + std::scoped_lock lock(m_Mutex); + + for (auto&& p : m_Files) { + if (p) { + p->sortOrigins(); + } + } +} + +void FileRegister::unregisterFile(FileEntryPtr file) +{ + bool ignore; + + // unregister from origin + OriginID originID = file->getOrigin(ignore); + m_OriginConnection->getByID(originID).removeFile(file->getIndex()); + const auto& alternatives = file->getAlternatives(); + + for (const auto& alt : alternatives) { + m_OriginConnection->getByID(alt.originID()).removeFile(file->getIndex()); + } + + // unregister from directory + if (file->getParent() != nullptr) { + file->getParent()->removeFile(file->getIndex()); + } +} + +} // namespace MOShared diff --git a/src/src/shared/fileregister.h b/src/src/shared/fileregister.h new file mode 100644 index 0000000..020a255 --- /dev/null +++ b/src/src/shared/fileregister.h @@ -0,0 +1,53 @@ +#ifndef MO_REGISTER_FILESREGISTER_INCLUDED +#define MO_REGISTER_FILESREGISTER_INCLUDED + +#include "fileregisterfwd.h" +#include +#include + +namespace MOShared +{ + +class FileRegister +{ +public: + FileRegister(boost::shared_ptr originConnection); + + // noncopyable + FileRegister(const FileRegister&) = delete; + FileRegister& operator=(const FileRegister&) = delete; + + bool indexValid(FileIndex index) const; + + FileEntryPtr createFile(std::wstring name, DirectoryEntry* parent, + DirectoryStats& stats); + + FileEntryPtr getFile(FileIndex index) const; + + size_t highestCount() const + { + std::scoped_lock lock(m_Mutex); + return m_Files.size(); + } + + bool removeFile(FileIndex index); + void removeOrigin(FileIndex index, OriginID originID); + void removeOriginMulti(std::set indices, OriginID originID); + + void sortOrigins(); + +private: + using FileMap = std::deque; + + mutable std::mutex m_Mutex; + FileMap m_Files; + boost::shared_ptr m_OriginConnection; + std::atomic m_NextIndex; + + void unregisterFile(FileEntryPtr file); + FileIndex generateIndex(); +}; + +} // namespace MOShared + +#endif // MO_REGISTER_FILESREGISTER_INCLUDED diff --git a/src/src/shared/fileregisterfwd.h b/src/src/shared/fileregisterfwd.h new file mode 100644 index 0000000..cc1e2e9 --- /dev/null +++ b/src/src/shared/fileregisterfwd.h @@ -0,0 +1,121 @@ +#ifndef MO_REGISTER_FILEREGISTERFWD_INCLUDED +#define MO_REGISTER_FILEREGISTERFWD_INCLUDED + +class DirectoryRefreshProgress; + +namespace MOShared +{ + +struct DirectoryEntryFileKey +{ + DirectoryEntryFileKey(std::wstring v) : value(std::move(v)), hash(getHash(value)) {} + + bool operator==(const DirectoryEntryFileKey& o) const { return (value == o.value); } + + static std::size_t getHash(const std::wstring& value) + { + return std::hash()(value); + } + + std::wstring value; + const std::size_t hash; +}; + +class DirectoryEntry; +class OriginConnection; +class FileRegister; +class FilesOrigin; +class FileEntry; +struct DirectoryStats; + +using FileEntryPtr = boost::shared_ptr; +using FileIndex = unsigned int; +using OriginID = int; + +constexpr FileIndex InvalidFileIndex = UINT_MAX; +constexpr OriginID InvalidOriginID = -1; + +// if a file is in an archive, name is the name of the bsa and order +// is the order of the associated plugin in the plugins list +// is a file is not in an archive, archiveName is empty and order is usually +// -1 +class DataArchiveOrigin +{ + std::wstring name_ = L""; + int order_ = -1; + +public: + int order() const { return order_; } + const std::wstring& name() const { return name_; } + + bool isValid() const { return name_.size() > 0; } + + DataArchiveOrigin(std::wstring name, int order) + : name_(std::move(name)), order_(order) + {} + + DataArchiveOrigin() = default; +}; + +class FileAlternative +{ + OriginID originID_ = -1; + DataArchiveOrigin archive_; + +public: + OriginID originID() const { return originID_; } + const DataArchiveOrigin& archive() const { return archive_; } + + bool isFromArchive() const { return archive_.isValid(); } + + FileAlternative() = default; + + FileAlternative(OriginID originID, DataArchiveOrigin archive) + : originID_(originID), archive_(std::move(archive)) + {} +}; + +using AlternativesVector = std::vector; + +struct DirectoryStats +{ + static constexpr bool EnableInstrumentation = false; + + std::string mod; + + std::chrono::nanoseconds dirTimes; + std::chrono::nanoseconds fileTimes; + std::chrono::nanoseconds sortTimes; + + std::chrono::nanoseconds subdirLookupTimes; + std::chrono::nanoseconds addDirectoryTimes; + + std::chrono::nanoseconds filesLookupTimes; + std::chrono::nanoseconds addFileTimes; + std::chrono::nanoseconds addOriginToFileTimes; + std::chrono::nanoseconds addFileToOriginTimes; + std::chrono::nanoseconds addFileToRegisterTimes; + + int64_t originExists; + int64_t originCreate; + int64_t originsNeededEnabled; + + int64_t subdirExists; + int64_t subdirCreate; + + int64_t fileExists; + int64_t fileCreate; + int64_t filesInsertedInRegister; + int64_t filesAssignedInRegister; + + DirectoryStats(); + + DirectoryStats& operator+=(const DirectoryStats& o); + + static std::string csvHeader(); + std::string toCsv() const; +}; + +} // namespace MOShared + +#endif // MO_REGISTER_FILEREGISTERFWD_INCLUDED diff --git a/src/src/shared/filesorigin.cpp b/src/src/shared/filesorigin.cpp new file mode 100644 index 0000000..7fbcd4d --- /dev/null +++ b/src/src/shared/filesorigin.cpp @@ -0,0 +1,120 @@ +#include "filesorigin.h" +#include "fileentry.h" +#include "fileregister.h" +#include "originconnection.h" + +namespace MOShared +{ + +std::wstring tail(const std::wstring& source, const size_t count) +{ + if (count >= source.length()) { + return source; + } + + return source.substr(source.length() - count); +} + +FilesOrigin::FilesOrigin() + : m_ID(0), m_Disabled(false), m_Name(), m_Path(), m_Priority(0) +{} + +FilesOrigin::FilesOrigin(OriginID ID, const std::wstring& name, + const std::wstring& path, int priority, + boost::shared_ptr fileRegister, + boost::shared_ptr originConnection) + : m_ID(ID), m_Disabled(false), m_Name(name), m_Path(path), m_Priority(priority), + m_FileRegister(fileRegister), m_OriginConnection(originConnection) +{} + +void FilesOrigin::setPriority(int priority) +{ + m_Priority = priority; +} + +void FilesOrigin::setName(const std::wstring& name) +{ + m_OriginConnection.lock()->changeNameLookup(m_Name, name); + + // change path too + if (tail(m_Path, m_Name.length()) == m_Name) { + m_Path = m_Path.substr(0, m_Path.length() - m_Name.length()).append(name); + } + + m_Name = name; +} + +std::vector FilesOrigin::getFiles() const +{ + std::vector result; + + { + std::scoped_lock lock(m_Mutex); + + for (FileIndex fileIdx : m_Files) { + if (FileEntryPtr p = m_FileRegister.lock()->getFile(fileIdx)) { + result.push_back(p); + } + } + } + + return result; +} + +FileEntryPtr FilesOrigin::findFile(FileIndex index) const +{ + return m_FileRegister.lock()->getFile(index); +} + +void FilesOrigin::enable(bool enabled) +{ + DirectoryStats dummy; + enable(enabled, dummy); +} + +void FilesOrigin::enable(bool enabled, DirectoryStats& stats) +{ + if (!enabled) { + ++stats.originsNeededEnabled; + + std::set copy; + + { + std::scoped_lock lock(m_Mutex); + copy = m_Files; + m_Files.clear(); + } + + m_FileRegister.lock()->removeOriginMulti(copy, m_ID); + } + + m_Disabled = !enabled; +} + +void FilesOrigin::removeFile(FileIndex index) +{ + std::scoped_lock lock(m_Mutex); + + auto iter = m_Files.find(index); + + if (iter != m_Files.end()) { + m_Files.erase(iter); + } +} + +bool FilesOrigin::containsArchive(std::wstring archiveName) +{ + std::scoped_lock lock(m_Mutex); + + for (FileIndex fileIdx : m_Files) { + if (FileEntryPtr p = m_FileRegister.lock()->getFile(fileIdx)) { + if (p->isFromArchive(archiveName)) { + return true; + } + } + } + + return false; +} + +} // namespace MOShared diff --git a/src/src/shared/filesorigin.h b/src/src/shared/filesorigin.h new file mode 100644 index 0000000..05914d1 --- /dev/null +++ b/src/src/shared/filesorigin.h @@ -0,0 +1,68 @@ +#ifndef MO_REGISTER_FILESORIGIN_INCLUDED +#define MO_REGISTER_FILESORIGIN_INCLUDED + +#include "fileregisterfwd.h" + +namespace MOShared +{ + +// represents a mod or the data directory, providing files to the tree +class FilesOrigin +{ +public: + FilesOrigin(); + + FilesOrigin(OriginID ID, const std::wstring& name, const std::wstring& path, + int priority, boost::shared_ptr fileRegister, + boost::shared_ptr originConnection); + + // noncopyable + FilesOrigin(const FilesOrigin&) = delete; + FilesOrigin& operator=(const FilesOrigin&) = delete; + + // sets priority for this origin (does not automatically refresh + // the structure) + void setPriority(int priority); + + int getPriority() const { return m_Priority; } + + void setName(const std::wstring& name); + const std::wstring& getName() const { return m_Name; } + + OriginID getID() const { return m_ID; } + + const std::wstring& getPath() const { return m_Path; } + + std::vector getFiles() const; + FileEntryPtr findFile(FileIndex index) const; + + void enable(bool enabled, DirectoryStats& stats); + void enable(bool enabled); + + bool isDisabled() const { return m_Disabled; } + + void addFile(FileIndex index) + { + std::scoped_lock lock(m_Mutex); + m_Files.insert(index); + } + + void removeFile(FileIndex index); + + bool containsArchive(std::wstring archiveName); + +private: + OriginID m_ID; + bool m_Disabled; + std::set m_Files; + std::wstring m_Name; + std::wstring m_Path; + int m_Priority; + boost::weak_ptr m_FileRegister; + boost::weak_ptr m_OriginConnection; + mutable std::mutex m_Mutex; +}; + +} // namespace MOShared + +#endif // MO_REGISTER_FILESORIGIN_INCLUDED diff --git a/src/src/shared/originconnection.cpp b/src/src/shared/originconnection.cpp new file mode 100644 index 0000000..0eadc8c --- /dev/null +++ b/src/src/shared/originconnection.cpp @@ -0,0 +1,129 @@ +#include "originconnection.h" +#include "filesorigin.h" +#include "util.h" +#include + +namespace MOShared +{ + +using namespace MOBase; + +OriginConnection::OriginConnection() : m_NextID(0) {} + +std::pair OriginConnection::getOrCreate( + const std::wstring& originName, const std::wstring& directory, int priority, + const boost::shared_ptr& fileRegister, + const boost::shared_ptr& originConnection, DirectoryStats& stats) +{ + std::unique_lock lock(m_Mutex); + + auto itor = m_OriginsNameMap.find(originName); + + if (itor == m_OriginsNameMap.end()) { + FilesOrigin& origin = createOriginNoLock(originName, directory, priority, + fileRegister, originConnection); + + return {origin, true}; + } else { + FilesOrigin& origin = m_Origins[itor->second]; + lock.unlock(); + + origin.enable(true, stats); + return {origin, false}; + } +} + +FilesOrigin& +OriginConnection::createOrigin(const std::wstring& originName, + const std::wstring& directory, int priority, + boost::shared_ptr fileRegister, + boost::shared_ptr originConnection) +{ + std::scoped_lock lock(m_Mutex); + + return createOriginNoLock(originName, directory, priority, fileRegister, + originConnection); +} + +bool OriginConnection::exists(const std::wstring& name) +{ + std::scoped_lock lock(m_Mutex); + return m_OriginsNameMap.find(name) != m_OriginsNameMap.end(); +} + +FilesOrigin& OriginConnection::getByID(OriginID ID) +{ + std::scoped_lock lock(m_Mutex); + return m_Origins[ID]; +} + +const FilesOrigin* OriginConnection::findByID(OriginID ID) const +{ + std::scoped_lock lock(m_Mutex); + + auto itor = m_Origins.find(ID); + + if (itor == m_Origins.end()) { + return nullptr; + } else { + return &itor->second; + } +} + +FilesOrigin& OriginConnection::getByName(const std::wstring& name) +{ + std::scoped_lock lock(m_Mutex); + + auto iter = m_OriginsNameMap.find(name); + + if (iter != m_OriginsNameMap.end()) { + return m_Origins[iter->second]; + } else { + std::ostringstream stream; + stream << QObject::tr("invalid origin name: ").toStdString() + << ToString(name, true); + throw std::runtime_error(stream.str()); + } +} + +void OriginConnection::changeNameLookup(const std::wstring& oldName, + const std::wstring& newName) +{ + std::scoped_lock lock(m_Mutex); + + auto iter = m_OriginsNameMap.find(oldName); + + if (iter != m_OriginsNameMap.end()) { + OriginID idx = iter->second; + m_OriginsNameMap.erase(iter); + m_OriginsNameMap[newName] = idx; + } else { + log::error(QObject::tr("failed to change name lookup from {0} to {1}"), oldName, + newName); + } +} + +OriginID OriginConnection::createID() +{ + return m_NextID++; +} + +FilesOrigin& OriginConnection::createOriginNoLock( + const std::wstring& originName, const std::wstring& directory, int priority, + boost::shared_ptr fileRegister, + boost::shared_ptr originConnection) +{ + OriginID newID = createID(); + + auto itor = m_Origins + .emplace(std::piecewise_construct, std::forward_as_tuple(newID), + std::forward_as_tuple(newID, originName, directory, priority, + fileRegister, originConnection)) + .first; + + m_OriginsNameMap.insert({originName, newID}); + + return itor->second; +} + +} // namespace MOShared diff --git a/src/src/shared/originconnection.h b/src/src/shared/originconnection.h new file mode 100644 index 0000000..a392f2a --- /dev/null +++ b/src/src/shared/originconnection.h @@ -0,0 +1,55 @@ +#ifndef MO_REGISTER_ORIGINCONNECTION_INCLUDED +#define MO_REGISTER_ORIGINCONNECTION_INCLUDED + +#include "fileregisterfwd.h" + +namespace MOShared +{ + +class OriginConnection +{ +public: + OriginConnection(); + + // noncopyable + OriginConnection(const OriginConnection&) = delete; + OriginConnection& operator=(const OriginConnection&) = delete; + + std::pair + getOrCreate(const std::wstring& originName, const std::wstring& directory, + int priority, const boost::shared_ptr& fileRegister, + const boost::shared_ptr& originConnection, + DirectoryStats& stats); + + FilesOrigin& createOrigin(const std::wstring& originName, + const std::wstring& directory, int priority, + boost::shared_ptr fileRegister, + boost::shared_ptr originConnection); + + bool exists(const std::wstring& name); + + FilesOrigin& getByID(OriginID ID); + const FilesOrigin* findByID(OriginID ID) const; + FilesOrigin& getByName(const std::wstring& name); + + void changePriorityLookup(int oldPriority, int newPriority); + + void changeNameLookup(const std::wstring& oldName, const std::wstring& newName); + +private: + std::atomic m_NextID; + std::map m_Origins; + std::map m_OriginsNameMap; + mutable std::mutex m_Mutex; + + OriginID createID(); + + FilesOrigin& createOriginNoLock(const std::wstring& originName, + const std::wstring& directory, int priority, + boost::shared_ptr fileRegister, + boost::shared_ptr originConnection); +}; + +} // namespace MOShared + +#endif // MO_REGISTER_ORIGINCONNECTION_INCLUDED diff --git a/src/src/shared/util.cpp b/src/src/shared/util.cpp new file mode 100644 index 0000000..cdcfc47 --- /dev/null +++ b/src/src/shared/util.cpp @@ -0,0 +1,517 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "util.h" +#include "../env.h" +#include "../mainwindow.h" +#include "windows_error.h" +#include +#ifdef _WIN32 +#include +#include +#else +#include +#include +#include +#include +#endif + +using namespace MOBase; + +namespace MOShared +{ + +#ifdef _WIN32 +bool FileExists(const std::string& filename) +{ + DWORD dwAttrib = ::GetFileAttributesA(filename.c_str()); + + return (dwAttrib != INVALID_FILE_ATTRIBUTES); +} + +bool FileExists(const std::wstring& filename) +{ + DWORD dwAttrib = ::GetFileAttributesW(filename.c_str()); + + return (dwAttrib != INVALID_FILE_ATTRIBUTES); +} +#else +bool FileExists(const std::string& filename) +{ + return std::filesystem::exists(filename); +} + +bool FileExists(const std::wstring& filename) +{ + return std::filesystem::exists(std::filesystem::path(filename)); +} +#endif + +bool FileExists(const std::wstring& searchPath, const std::wstring& filename) +{ + std::wstringstream stream; + stream << searchPath << "\\" << filename; + return FileExists(stream.str()); +} + +#ifdef _WIN32 +std::string ToString(const std::wstring& source, bool utf8) +{ + std::string result; + if (source.length() > 0) { + UINT codepage = CP_UTF8; + if (!utf8) { + codepage = AreFileApisANSI() ? GetACP() : GetOEMCP(); + } + int sizeRequired = ::WideCharToMultiByte(codepage, 0, &source[0], -1, nullptr, 0, + nullptr, nullptr); + if (sizeRequired == 0) { + throw windows_error("failed to convert string to multibyte"); + } + // the size returned by WideCharToMultiByte contains zero termination IF -1 is + // specified for the length. we don't want that \0 in the string because then the + // length field would be wrong. Because madness + result.resize(sizeRequired - 1, '\0'); + ::WideCharToMultiByte(codepage, 0, &source[0], (int)source.size(), &result[0], + sizeRequired, nullptr, nullptr); + } + + return result; +} + +std::wstring ToWString(const std::string& source, bool utf8) +{ + std::wstring result; + if (source.length() > 0) { + UINT codepage = CP_UTF8; + if (!utf8) { + codepage = AreFileApisANSI() ? GetACP() : GetOEMCP(); + } + int sizeRequired = ::MultiByteToWideChar( + codepage, 0, source.c_str(), static_cast(source.length()), nullptr, 0); + if (sizeRequired == 0) { + throw windows_error("failed to convert string to wide character"); + } + result.resize(sizeRequired, L'\0'); + ::MultiByteToWideChar(codepage, 0, source.c_str(), + static_cast(source.length()), &result[0], sizeRequired); + } + + return result; +} +#else +std::string ToString(const std::wstring& source, bool utf8) +{ + Q_UNUSED(utf8); + // On Linux, use Qt for wstring -> string conversion (always UTF-8) + return QString::fromStdWString(source).toStdString(); +} + +std::wstring ToWString(const std::string& source, bool utf8) +{ + Q_UNUSED(utf8); + // On Linux, use Qt for string -> wstring conversion (always UTF-8) + return QString::fromStdString(source).toStdWString(); +} +#endif + +static std::locale loc(""); +static auto locToLowerW = [](wchar_t in) -> wchar_t { + return std::tolower(in, loc); +}; + +static auto locToLower = [](char in) -> char { + return std::tolower(in, loc); +}; + +#ifdef _WIN32 +std::string& ToLowerInPlace(std::string& text) +{ + CharLowerBuffA(const_cast(text.c_str()), static_cast(text.size())); + return text; +} + +std::string ToLowerCopy(const std::string& text) +{ + std::string result(text); + CharLowerBuffA(const_cast(result.c_str()), static_cast(result.size())); + return result; +} + +std::wstring& ToLowerInPlace(std::wstring& text) +{ + CharLowerBuffW(const_cast(text.c_str()), static_cast(text.size())); + return text; +} + +std::wstring ToLowerCopy(const std::wstring& text) +{ + std::wstring result(text); + CharLowerBuffW(const_cast(result.c_str()), static_cast(result.size())); + return result; +} +#else +std::string& ToLowerInPlace(std::string& text) +{ + std::transform(text.begin(), text.end(), text.begin(), [](char c) { + return std::tolower(static_cast(c)); + }); + return text; +} + +std::string ToLowerCopy(const std::string& text) +{ + std::string result(text); + return ToLowerInPlace(result); +} + +std::wstring& ToLowerInPlace(std::wstring& text) +{ + std::transform(text.begin(), text.end(), text.begin(), [](wchar_t c) { + return std::towlower(c); + }); + return text; +} + +std::wstring ToLowerCopy(const std::wstring& text) +{ + std::wstring result(text); + return ToLowerInPlace(result); +} +#endif + +std::wstring ToLowerCopy(std::wstring_view text) +{ + std::wstring result(text.begin(), text.end()); + ToLowerInPlace(result); + return result; +} + +bool CaseInsenstiveComparePred(wchar_t lhs, wchar_t rhs) +{ + return std::tolower(lhs, loc) == std::tolower(rhs, loc); +} + +bool CaseInsensitiveEqual(const std::wstring& lhs, const std::wstring& rhs) +{ + return (lhs.length() == rhs.length()) && + std::equal(lhs.begin(), lhs.end(), rhs.begin(), + [](wchar_t lhs, wchar_t rhs) -> bool { + return std::tolower(lhs, loc) == std::tolower(rhs, loc); + }); +} + +#ifdef _WIN32 +VS_FIXEDFILEINFO GetFileVersion(const std::wstring& fileName) +{ + DWORD handle = 0UL; + DWORD size = ::GetFileVersionInfoSizeW(fileName.c_str(), &handle); + if (size == 0) { + throw windows_error("failed to determine file version info size"); + } + + boost::scoped_array buffer(new char[size]); + try { + handle = 0UL; + if (!::GetFileVersionInfoW(fileName.c_str(), handle, size, buffer.get())) { + throw windows_error("failed to determine file version info"); + } + + void* versionInfoPtr = nullptr; + UINT versionInfoLength = 0; + if (!::VerQueryValue(buffer.get(), L"\\", &versionInfoPtr, &versionInfoLength)) { + throw windows_error("failed to determine file version"); + } + + VS_FIXEDFILEINFO result = *(VS_FIXEDFILEINFO*)versionInfoPtr; + return result; + } catch (...) { + throw; + } +} + +std::wstring GetFileVersionString(const std::wstring& fileName) +{ + DWORD handle = 0UL; + DWORD size = ::GetFileVersionInfoSizeW(fileName.c_str(), &handle); + if (size == 0) { + throw windows_error("failed to determine file version info size"); + } + + boost::scoped_array buffer(new char[size]); + try { + handle = 0UL; + if (!::GetFileVersionInfoW(fileName.c_str(), handle, size, buffer.get())) { + throw windows_error("failed to determine file version info"); + } + + LPVOID strBuffer = nullptr; + UINT strLength = 0; + if (!::VerQueryValue(buffer.get(), L"\\StringFileInfo\\040904B0\\ProductVersion", + &strBuffer, &strLength)) { + throw windows_error("failed to determine file version"); + } + + return std::wstring((LPCTSTR)strBuffer); + } catch (...) { + throw; + } +} + +Version createVersionInfo() +{ + VS_FIXEDFILEINFO version = GetFileVersion(env::thisProcessPath().native()); + + std::optional releaseType; + + if (version.dwFileFlags & VS_FF_PRERELEASE) { + // Pre-release builds need annotating + QString versionString = + QString::fromStdWString(GetFileVersionString(env::thisProcessPath().native())); + + // The pre-release flag can be set without the string specifying what type of + // pre-release + bool noLetters = true; + for (QChar character : versionString) { + if (character.isLetter()) { + noLetters = false; + break; + } + } + + if (!noLetters) { + // trust the string to make sense + return Version::parse(versionString, Version::ParseMode::MO2); + } + + if (noLetters) { + // default to development when release type is unspecified + releaseType = Version::Development; + } else { + } + } + + const int major = version.dwFileVersionMS >> 16, + minor = version.dwFileVersionMS & 0xFFFF, + patch = version.dwFileVersionLS >> 16, + subpatch = version.dwFileVersionLS & 0xFFFF; + + std::vector> prereleases; + if (releaseType) { + prereleases.push_back(*releaseType); + } + + return Version(major, minor, patch, subpatch, std::move(prereleases)); +} +#else +Version createVersionInfo() +{ + // On Linux, return a development version stub + std::vector> prereleases; + prereleases.push_back(Version::Development); + return Version(2, 5, 0, 0, std::move(prereleases)); +} +#endif + +#ifdef _WIN32 +QString getUsvfsDLLVersion() +{ + QString s = usvfsVersionString(); + if (s.isEmpty()) { + s = "?"; + } + return s; +} + +QString getUsvfsVersionString() +{ + const QString dll = getUsvfsDLLVersion(); + const QString header = USVFS_VERSION_STRING; + + QString usvfsVersion; + + if (dll == header) { + return dll; + } else { + return "dll is " + dll + ", compiled against " + header; + } +} +#else +QString getUsvfsDLLVersion() +{ + return "N/A (Linux)"; +} + +QString getUsvfsVersionString() +{ + return "N/A (Linux - using FUSE VFS)"; +} +#endif + +#ifdef _WIN32 +void SetThisThreadName(const QString& s) +{ + using SetThreadDescriptionType = HRESULT(HANDLE hThread, PCWSTR lpThreadDescription); + + static SetThreadDescriptionType* SetThreadDescription = [] { + SetThreadDescriptionType* p = nullptr; + + env::LibraryPtr kernel32(LoadLibraryW(L"kernel32.dll")); + if (!kernel32) { + return p; + } + + p = reinterpret_cast( + GetProcAddress(kernel32.get(), "SetThreadDescription")); + + return p; + }(); + + if (SetThreadDescription) { + SetThreadDescription(GetCurrentThread(), s.toStdWString().c_str()); + } +} +#else +void SetThisThreadName(const QString& s) +{ + // On Linux, use pthread_setname_np (limited to 16 chars including null) + std::string name = s.toStdString(); + if (name.size() > 15) { + name.resize(15); + } + pthread_setname_np(pthread_self(), name.c_str()); +} +#endif + +char shortcutChar(const QAction* a) +{ + const auto text = a->text(); + char shortcut = 0; + + for (int i = 0; i < text.size(); ++i) { + const auto c = text[i]; + if (c == '&') { + if (i >= (text.size() - 1)) { + log::error("ampersand at the end"); + return 0; + } + + return text[i + 1].toLatin1(); + } + } + + log::error("action {} has no shortcut", text); + return 0; +} + +void checkDuplicateShortcuts(const QMenu& m) +{ + const auto actions = m.actions(); + + for (int i = 0; i < actions.size(); ++i) { + const auto* action1 = actions[i]; + if (action1->isSeparator()) { + continue; + } + + const char shortcut1 = shortcutChar(action1); + if (shortcut1 == 0) { + continue; + } + + for (int j = i + 1; j < actions.size(); ++j) { + const auto* action2 = actions[j]; + if (action2->isSeparator()) { + continue; + } + + const char shortcut2 = shortcutChar(action2); + + if (shortcut1 == shortcut2) { + log::error("duplicate shortcut {} for {} and {}", shortcut1, action1->text(), + action2->text()); + + break; + } + } + } +} + +} // namespace MOShared + +static bool g_exiting = false; +static bool g_canClose = false; + +MainWindow* findMainWindow() +{ + for (auto* tl : qApp->topLevelWidgets()) { + if (auto* mw = dynamic_cast(tl)) { + return mw; + } + } + + return nullptr; +} + +bool ExitModOrganizer(ExitFlags e) +{ + if (g_exiting) { + return true; + } + + g_exiting = true; + Guard g([&] { + g_exiting = false; + }); + + if (!e.testFlag(Exit::Force)) { + if (auto* mw = findMainWindow()) { + if (!mw->canExit()) { + return false; + } + } + } + + g_canClose = true; + + const int code = (e.testFlag(Exit::Restart) ? RestartExitCode : 0); + qApp->exit(code); + + return true; +} + +bool ModOrganizerCanCloseNow() +{ + return g_canClose; +} + +bool ModOrganizerExiting() +{ + return g_exiting; +} + +void ResetExitFlag() +{ + g_exiting = false; +} + +bool isNxmLink(const QString& link) +{ + return link.startsWith("nxm://", Qt::CaseInsensitive); +} diff --git a/src/src/shared/util.h b/src/src/shared/util.h new file mode 100644 index 0000000..c0dce75 --- /dev/null +++ b/src/src/shared/util.h @@ -0,0 +1,96 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef UTIL_H +#define UTIL_H + +#include +#include + +#include +#include + +class Executable; + +namespace MOShared +{ + +#ifdef _WIN32 +inline constexpr wchar_t NativeWPathSep = L'\\'; +#else +inline constexpr wchar_t NativeWPathSep = L'/'; +#endif + +/// Test if a file (or directory) by the specified name exists +bool FileExists(const std::string& filename); +bool FileExists(const std::wstring& filename); + +bool FileExists(const std::wstring& searchPath, const std::wstring& filename); + +std::string ToString(const std::wstring& source, bool utf8); +std::wstring ToWString(const std::string& source, bool utf8); + +std::string& ToLowerInPlace(std::string& text); +std::string ToLowerCopy(const std::string& text); + +std::wstring& ToLowerInPlace(std::wstring& text); +std::wstring ToLowerCopy(const std::wstring& text); +std::wstring ToLowerCopy(std::wstring_view text); + +bool CaseInsensitiveEqual(const std::wstring& lhs, const std::wstring& rhs); + +MOBase::Version createVersionInfo(); +QString getUsvfsVersionString(); + +void SetThisThreadName(const QString& s); +void checkDuplicateShortcuts(const QMenu& m); + +inline FILETIME ToFILETIME(std::filesystem::file_time_type t) +{ + FILETIME ft; + static_assert(sizeof(t) == sizeof(ft)); + + std::memcpy(&ft, &t, sizeof(FILETIME)); + return ft; +} + +} // namespace MOShared + +enum class Exit +{ + None = 0x00, + Normal = 0x01, + Restart = 0x02, + Force = 0x04 +}; + +const int RestartExitCode = INT_MAX; +const int ReselectExitCode = INT_MAX - 1; + +using ExitFlags = QFlags; +Q_DECLARE_OPERATORS_FOR_FLAGS(ExitFlags); + +bool ExitModOrganizer(ExitFlags e = Exit::Normal); +bool ModOrganizerExiting(); +bool ModOrganizerCanCloseNow(); +void ResetExitFlag(); + +bool isNxmLink(const QString& link); + +#endif // UTIL_H diff --git a/src/src/shared/windows_compat.h b/src/src/shared/windows_compat.h new file mode 100644 index 0000000..1a62823 --- /dev/null +++ b/src/src/shared/windows_compat.h @@ -0,0 +1,88 @@ +#ifndef WINDOWS_COMPAT_H +#define WINDOWS_COMPAT_H + +// Compatibility typedefs for Windows types used throughout the organizer codebase. +// On Windows these come from ; on Linux we provide minimal stubs. +// +// This header is included by the organizer PCH and also by uibase/utility.h +// to provide a single source of truth for Windows type compatibility on Linux. + +#ifndef _WIN32 + +#include +#include + +// Core Windows types (also used by uibase) +using DWORD = uint32_t; +using HANDLE = void*; +using LPDWORD = DWORD*; +using HRESULT = int32_t; +using HMODULE = void*; + +// Additional types used by organizer +using UINT = unsigned int; +using UINT32 = uint32_t; +using BOOL = int; +using BYTE = uint8_t; +using WORD = uint16_t; +using LONG = int32_t; +using ULONG = uint32_t; +using LONGLONG = int64_t; +using ULONGLONG = uint64_t; +using LPCSTR = const char*; +using LPCWSTR = const wchar_t*; +using LPSTR = char*; +using LPWSTR = wchar_t*; +using NTSTATUS = int32_t; +using KNOWNFOLDERID = int; + +// INVALID_HANDLE_VALUE +inline HANDLE INVALID_HANDLE_VALUE = reinterpret_cast(static_cast(-1)); + +// FILETIME - 64-bit timestamp, used heavily in the file register +struct FILETIME { + DWORD dwLowDateTime; + DWORD dwHighDateTime; +}; + +// Error helpers +inline DWORD GetLastError() { return static_cast(errno); } + +// Windows error code constants +constexpr DWORD ERROR_SUCCESS = 0; +constexpr DWORD ERROR_FILE_NOT_FOUND = 2; +constexpr DWORD ERROR_PATH_NOT_FOUND = 3; +constexpr DWORD ERROR_ACCESS_DENIED = 5; +constexpr DWORD ERROR_INVALID_PARAMETER = 87; +constexpr DWORD ERROR_ALREADY_EXISTS = 183; +constexpr DWORD ERROR_CANCELLED = 1223; +constexpr DWORD ERROR_NOT_SAME_DEVICE = 17; +constexpr DWORD ERROR_BAD_PATHNAME = 161; +constexpr DWORD ERROR_BUFFER_OVERFLOW = 111; +constexpr DWORD ERROR_WRITE_PROTECT = 19; +constexpr DWORD ERROR_DISK_FULL = 112; +constexpr DWORD ERROR_GEN_FAILURE = 31; +constexpr DWORD ERROR_BAD_FORMAT = 11; + +// ShellExecute error codes +constexpr int SE_ERR_ACCESSDENIED = 5; +constexpr int SE_ERR_ASSOCINCOMPLETE = 27; +constexpr int SE_ERR_DDEBUSY = 30; +constexpr int SE_ERR_DDEFAIL = 29; +constexpr int SE_ERR_DDETIMEOUT = 28; +constexpr int SE_ERR_DLLNOTFOUND = 32; +constexpr int SE_ERR_NOASSOC = 31; +constexpr int SE_ERR_OOM = 8; +constexpr int SE_ERR_SHARE = 26; + +// MAX_PATH +#ifndef MAX_PATH +#define MAX_PATH 260 +#endif + +#ifndef INVALID_FILE_ATTRIBUTES +#define INVALID_FILE_ATTRIBUTES ((DWORD)-1) +#endif + +#endif // !_WIN32 +#endif // WINDOWS_COMPAT_H diff --git a/src/src/shared/windows_error.cpp b/src/src/shared/windows_error.cpp new file mode 100644 index 0000000..3e81e51 --- /dev/null +++ b/src/src/shared/windows_error.cpp @@ -0,0 +1,63 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "windows_error.h" +#include + +namespace MOShared +{ + +std::string windows_error::constructMessage(const std::string& input, int inErrorCode) +{ + std::ostringstream finalMessage; + finalMessage << input; + +#ifdef _WIN32 + LPSTR buffer = nullptr; + + DWORD errorCode = inErrorCode != -1 ? inErrorCode : ::GetLastError(); + + // TODO: the message is not english? + if (FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM, + nullptr, errorCode, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + (LPSTR)&buffer, 0, nullptr) == 0) { + finalMessage << " (errorcode " << errorCode << ")"; + } else { + LPSTR lastChar = buffer + strlen(buffer) - 2; + *lastChar = '\0'; + finalMessage << " (" << buffer << " [" << errorCode << "])"; + LocalFree(buffer); // allocated by FormatMessage + } + + ::SetLastError( + errorCode); // restore error code because FormatMessage might have modified it +#else + int errorCode = inErrorCode != -1 ? inErrorCode : errno; + const char* errStr = std::strerror(errorCode); + if (errStr) { + finalMessage << " (" << errStr << " [" << errorCode << "])"; + } else { + finalMessage << " (errorcode " << errorCode << ")"; + } +#endif + + return finalMessage.str(); +} + +} // namespace MOShared diff --git a/src/src/shared/windows_error.h b/src/src/shared/windows_error.h new file mode 100644 index 0000000..4d19c5e --- /dev/null +++ b/src/src/shared/windows_error.h @@ -0,0 +1,57 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef WINDOWS_ERROR_H +#define WINDOWS_ERROR_H + +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include +#endif + +namespace MOShared +{ + +class windows_error : public std::runtime_error +{ +public: +#ifdef _WIN32 + windows_error(const std::string& message, int errorcode = ::GetLastError()) +#else + windows_error(const std::string& message, int errorcode = errno) +#endif + : runtime_error(constructMessage(message, errorcode)), m_ErrorCode(errorcode) + {} + int getErrorCode() const { return m_ErrorCode; } + +private: + std::string constructMessage(const std::string& input, int errorcode); + +private: + int m_ErrorCode; +}; + +} // namespace MOShared + +#endif // WINDOWS_ERROR_H diff --git a/src/src/spawn.cpp b/src/src/spawn.cpp new file mode 100644 index 0000000..b18282d --- /dev/null +++ b/src/src/spawn.cpp @@ -0,0 +1,1323 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "spawn.h" + +#include "env.h" +#include "envmodule.h" +#include "fluorineconfig.h" +#include "protonlauncher.h" +#include "settings.h" +#include "settingsdialogworkarounds.h" +#include "shared/appconfig.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include "envsecurity.h" +#include "envwindows.h" +#include "shared/windows_error.h" +#include +#include +#else +#include +#include +#include +#include +#include +#include +#endif + +using namespace MOBase; +using namespace MOShared; + +namespace spawn::dialogs +{ + +#ifdef _WIN32 +std::wstring makeRightsDetails(const env::FileSecurity& fs) +{ + if (fs.rights.normalRights) { + return L"(normal rights)"; + } + + if (fs.rights.list.isEmpty()) { + return L"(none)"; + } + + std::wstring s = fs.rights.list.join("|").toStdWString(); + if (!fs.rights.hasExecute) { + s += L" (execute is missing)"; + } + + return s; +} + +QString makeDetails(const SpawnParameters& sp, DWORD code, const QString& more = {}) +{ + std::wstring owner, rights; + + if (sp.binary.isFile()) { + const auto fs = env::getFileSecurity(sp.binary.absoluteFilePath()); + + if (fs.error.isEmpty()) { + owner = fs.owner.toStdWString(); + rights = makeRightsDetails(fs); + } else { + owner = fs.error.toStdWString(); + rights = fs.error.toStdWString(); + } + } else { + owner = L"(file not found)"; + rights = L"(file not found)"; + } + + const bool cwdExists = + (sp.currentDirectory.isEmpty() ? true : sp.currentDirectory.exists()); + + const auto appDir = QCoreApplication::applicationDirPath(); + const auto sep = QDir::separator(); + + const std::wstring usvfs_x86_dll = + QFileInfo(appDir + sep + "usvfs_x86.dll").isFile() ? L"ok" : L"not found"; + + const std::wstring usvfs_x64_dll = + QFileInfo(appDir + sep + "usvfs_x64.dll").isFile() ? L"ok" : L"not found"; + + const std::wstring usvfs_x86_proxy = + QFileInfo(appDir + sep + "usvfs_proxy_x86.exe").isFile() ? L"ok" : L"not found"; + + const std::wstring usvfs_x64_proxy = + QFileInfo(appDir + sep + "usvfs_proxy_x64.exe").isFile() ? L"ok" : L"not found"; + + std::wstring elevated; + if (auto b = env::Environment().windowsInfo().isElevated()) { + elevated = (*b ? L"yes" : L"no"); + } else { + elevated = L"(not available)"; + } + + auto s = std::format(L"Error {} {}{}: {}\n" + L" . binary: '{}'\n" + L" . owner: {}\n" + L" . rights: {}\n" + L" . arguments: '{}'\n" + L" . cwd: '{}'{}\n" + L" . stdout: {}, stderr: {}, hooked: {}\n" + L" . MO elevated: {}", + code, errorCodeName(code), (more.isEmpty() ? more : ", " + more), + formatSystemMessage(code), + QDir::toNativeSeparators(sp.binary.absoluteFilePath()), owner, + rights, sp.arguments, + QDir::toNativeSeparators(sp.currentDirectory.absolutePath()), + (cwdExists ? L"" : L" (not found)"), + (sp.stdOut == INVALID_HANDLE_VALUE ? L"no" : L"yes"), + (sp.stdErr == INVALID_HANDLE_VALUE ? L"no" : L"yes"), + (sp.hooked ? L"yes" : L"no"), elevated); + + if (sp.hooked) { + s += std::format(L"\n . usvfs x86:{} x64:{} proxy_x86:{} proxy_x64:{}", + usvfs_x86_dll, usvfs_x64_dll, usvfs_x86_proxy, usvfs_x64_proxy); + } + + return QString::fromStdWString(s); +} +#else +QString makeDetails(const SpawnParameters& sp, int code, const QString& more = {}) +{ + const bool cwdExists = + (sp.currentDirectory.isEmpty() ? true : sp.currentDirectory.exists()); + + QString s = QString("Error %1%2: %3\n" + " . binary: '%4'\n" + " . arguments: '%5'\n" + " . cwd: '%6'%7\n" + " . stdout: %8, stderr: %9, hooked: %10") + .arg(code) + .arg(more.isEmpty() ? more : ", " + more) + .arg(QString::fromUtf8(strerror(code))) + .arg(sp.binary.absoluteFilePath()) + .arg(sp.arguments) + .arg(sp.currentDirectory.absolutePath()) + .arg(cwdExists ? "" : " (not found)") + .arg(sp.stdOut == -1 ? "no" : "yes") + .arg(sp.stdErr == -1 ? "no" : "yes") + .arg(sp.hooked ? "yes" : "no"); + + return s; +} +#endif + +#ifdef _WIN32 +QString makeContent(const SpawnParameters& sp, DWORD code) +{ + if (code == ERROR_INVALID_PARAMETER) { + return QObject::tr( + "This error typically happens because an antivirus has deleted critical " + "files from Mod Organizer's installation folder or has made them " + "generally inaccessible. Add an exclusion for Mod Organizer's " + "installation folder in your antivirus, reinstall Mod Organizer and try " + "again."); + } else if (code == ERROR_ACCESS_DENIED) { + return QObject::tr( + "This error typically happens because an antivirus is preventing Mod " + "Organizer from starting programs. Add an exclusion for Mod Organizer's " + "installation folder in your antivirus and try again."); + } else if (code == ERROR_FILE_NOT_FOUND) { + return QObject::tr("The file '%1' does not exist.") + .arg(QDir::toNativeSeparators(sp.binary.absoluteFilePath())); + } else if (code == ERROR_DIRECTORY) { + if (!sp.currentDirectory.exists()) { + return QObject::tr("The working directory '%1' does not exist.") + .arg(QDir::toNativeSeparators(sp.currentDirectory.absolutePath())); + } + } + + return QString::fromStdWString(formatSystemMessage(code)); +} +#else +QString makeContent(const SpawnParameters& sp, int code) +{ + if (code == ENOENT) { + return QObject::tr("The file '%1' does not exist.") + .arg(sp.binary.absoluteFilePath()); + } else if (code == EACCES) { + return QObject::tr( + "Permission denied when trying to start '%1'. " + "Check that the file is executable.") + .arg(sp.binary.absoluteFilePath()); + } + + if (!sp.currentDirectory.exists()) { + return QObject::tr("The working directory '%1' does not exist.") + .arg(sp.currentDirectory.absolutePath()); + } + + return QString::fromUtf8(strerror(code)); +} +#endif + +#ifdef _WIN32 +QMessageBox::StandardButton badSteamReg(QWidget* parent, const QString& keyName, + const QString& valueName) +{ + const auto details = + QString("can't start steam, registry value at '%1' is empty or doesn't exist") + .arg(keyName + "\\" + valueName); + + log::error("{}", details); + + return MOBase::TaskDialog(parent, QObject::tr("Cannot start Steam")) + .main(QObject::tr("Cannot start Steam")) + .content( + QObject::tr("The path to the Steam executable cannot be found. You might try " + "reinstalling Steam.")) + .details(details) + .icon(QMessageBox::Critical) + .button({QObject::tr("Continue without starting Steam"), + QObject::tr("The program may fail to launch."), QMessageBox::Yes}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); +} +#endif + +#ifdef _WIN32 +QMessageBox::StandardButton startSteamFailed(QWidget* parent, const QString& keyName, + const QString& valueName, + const QString& exe, + const SpawnParameters& sp, DWORD e) +{ + auto details = QString("a steam install was found in the registry at '%1': '%2'\n\n") + .arg(keyName + "\\" + valueName) + .arg(exe); + + details += makeDetails(sp, e); + + log::error("{}", details); + + return MOBase::TaskDialog(parent, QObject::tr("Cannot start Steam")) + .main(QObject::tr("Cannot start Steam")) + .content(makeContent(sp, e)) + .details(details) + .icon(QMessageBox::Critical) + .button({QObject::tr("Continue without starting Steam"), + QObject::tr("The program may fail to launch."), QMessageBox::Yes}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); +} +#endif + +void spawnFailed(QWidget* parent, const SpawnParameters& sp, +#ifdef _WIN32 + DWORD code +#else + int code +#endif +) +{ + const auto details = makeDetails(sp, code); + log::error("{}", details); + + const auto title = QObject::tr("Cannot launch program"); + + const auto mainText = QObject::tr("Cannot start %1").arg(sp.binary.fileName()); + + MOBase::TaskDialog(parent, title) + .main(mainText) + .content(makeContent(sp, code)) + .details(details) + .icon(QMessageBox::Critical) + .exec(); +} + +#ifdef _WIN32 +void helperFailed(QWidget* parent, DWORD code, const QString& why, + const std::wstring& binary, const std::wstring& cwd, + const std::wstring& args) +{ + SpawnParameters sp; + sp.binary = QFileInfo(QString::fromStdWString(binary)); + sp.currentDirectory.setPath(QString::fromStdWString(cwd)); + sp.arguments = QString::fromStdWString(args); + + const auto details = makeDetails(sp, code, "in " + why); + log::error("{}", details); + + const auto title = QObject::tr("Cannot launch helper"); + + const auto mainText = QObject::tr("Cannot start %1").arg(sp.binary.fileName()); + + MOBase::TaskDialog(parent, title) + .main(mainText) + .content(makeContent(sp, code)) + .details(details) + .icon(QMessageBox::Critical) + .exec(); +} + +bool confirmRestartAsAdmin(QWidget* parent, const SpawnParameters& sp) +{ + const auto details = makeDetails(sp, ERROR_ELEVATION_REQUIRED); + + log::error("{}", details); + + const auto title = QObject::tr("Elevation required"); + + const auto mainText = QObject::tr("Cannot start %1").arg(sp.binary.fileName()); + + const auto content = QObject::tr( + "This program is requesting to run as administrator but Mod Organizer " + "itself is not running as administrator. Running programs as administrator " + "is typically unnecessary as long as the game and Mod Organizer have been " + "installed outside \"Program Files\".\r\n\r\n" + "You can restart Mod Organizer as administrator and try launching the " + "program again."); + + log::debug("asking user to restart MO as administrator"); + + const auto r = + MOBase::TaskDialog(parent, title) + .main(mainText) + .content(content) + .details(details) + .icon(QMessageBox::Question) + .button({QObject::tr("Restart Mod Organizer as administrator"), + QObject::tr( + "You must allow \"helper.exe\" to make changes to the system."), + QMessageBox::Yes}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + return (r == QMessageBox::Yes); +} +#endif // _WIN32 + +QMessageBox::StandardButton +confirmStartSteam(QWidget* parent, const SpawnParameters& sp, const QString& details) +{ + const auto title = QObject::tr("Launch Steam"); + const auto mainText = QObject::tr("This program requires Steam"); + const auto content = QObject::tr( + "Mod Organizer has detected that this program likely requires Steam to be " + "running to function properly."); + + return MOBase::TaskDialog(parent, title) + .main(mainText) + .content(content) + .details(details) + .icon(QMessageBox::Question) + .button({QObject::tr("Start Steam"), QMessageBox::Yes}) + .button({QObject::tr("Continue without starting Steam"), + QObject::tr("The program might fail to run."), QMessageBox::No}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .remember("steamQuery", sp.binary.fileName()) + .exec(); +} + +#ifdef _WIN32 +QMessageBox::StandardButton confirmRestartAsAdminForSteam(QWidget* parent, + const SpawnParameters& sp) +{ + const auto title = QObject::tr("Elevation required"); + const auto mainText = QObject::tr("Steam is running as administrator"); + const auto content = QObject::tr( + "Running Steam as administrator is typically unnecessary and can cause " + "problems when Mod Organizer itself is not running as administrator." + "\r\n\r\n" + "You can restart Mod Organizer as administrator and try launching the " + "program again."); + + return MOBase::TaskDialog(parent, title) + .main(mainText) + .content(content) + .icon(QMessageBox::Question) + .button( + {QObject::tr("Restart Mod Organizer as administrator"), + QObject::tr("You must allow \"helper.exe\" to make changes to the system."), + QMessageBox::Yes}) + .button({QObject::tr("Continue"), QObject::tr("The program might fail to run."), + QMessageBox::No}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .remember("steamAdminQuery", sp.binary.fileName()) + .exec(); +} + +bool eventLogNotRunning(QWidget* parent, const env::Service& s, + const SpawnParameters& sp) +{ + const auto title = QObject::tr("Event Log not running"); + const auto mainText = QObject::tr("The Event Log service is not running"); + const auto content = QObject::tr( + "The Windows Event Log service is not running. This can prevent USVFS from " + "running properly and your mods may not be recognized by the program being " + "launched."); + + const auto r = + MOBase::TaskDialog(parent, title) + .main(mainText) + .content(content) + .details(s.toString()) + .icon(QMessageBox::Question) + .remember("eventLogService", sp.binary.fileName()) + .button({QObject::tr("Continue"), QObject::tr("Your mods might not work."), + QMessageBox::Yes}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + return (r == QMessageBox::Yes); +} +#endif // _WIN32 + +QMessageBox::StandardButton +confirmBlacklisted(QWidget* parent, const SpawnParameters& sp, Settings& settings) +{ + const auto title = QObject::tr("Blacklisted program"); + const auto mainText = + QObject::tr("The program %1 is blacklisted").arg(sp.binary.fileName()); + const auto content = QObject::tr( + "The program you are attempting to launch is blacklisted in the virtual " + "filesystem. This will likely prevent it from seeing any mods, INI files " + "or any other virtualized files."); + + const auto details = "Executable: " + sp.binary.fileName() + + "\n" + "Current blacklist: " + + settings.executablesBlacklist(); + + auto r = MOBase::TaskDialog(parent, title) + .main(mainText) + .content(content) + .details(details) + .icon(QMessageBox::Question) + .remember("blacklistedExecutable", sp.binary.fileName()) + .button({QObject::tr("Continue"), + QObject::tr("Your mods might not work."), QMessageBox::Yes}) + .button({QObject::tr("Change the blacklist"), QMessageBox::Retry}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + if (r == QMessageBox::Retry) { + if (!WorkaroundsSettingsTab::changeBlacklistNow(parent, settings)) { + r = QMessageBox::Cancel; + } + } + + return r; +} + +} // namespace spawn::dialogs + +namespace spawn +{ + +void logSpawning(const SpawnParameters& sp, const QString& realCmd) +{ + log::debug("spawning binary:\n" + " . exe: '{}'\n" + " . args: '{}'\n" + " . cwd: '{}'\n" + " . steam id: '{}'\n" + " . hooked: {}\n" + " . stdout: {}\n" + " . stderr: {}\n" + " . real cmd: '{}'", + sp.binary.absoluteFilePath(), sp.arguments, + sp.currentDirectory.absolutePath(), sp.steamAppID, sp.hooked, +#ifdef _WIN32 + (sp.stdOut == INVALID_HANDLE_VALUE ? "no" : "yes"), + (sp.stdErr == INVALID_HANDLE_VALUE ? "no" : "yes"), +#else + (sp.stdOut == -1 ? "no" : "yes"), + (sp.stdErr == -1 ? "no" : "yes"), +#endif + realCmd); +} + +#ifndef _WIN32 +uint32_t parseSteamAppId(const QString& steamAppId) +{ + bool ok = false; + const auto n = steamAppId.toUInt(&ok); + return (ok ? n : 0u); +} + +QString firstExistingSetting(const QSettings& settings, const QStringList& keys) +{ + for (const QString& key : keys) { + const QString value = settings.value(key).toString().trimmed(); + if (!value.isEmpty()) { + return value; + } + } + + return {}; +} + +QString resolvePrefixPath() +{ + if (auto cfg = FluorineConfig::load(); cfg.has_value() && cfg->prefixExists()) { + return cfg->prefix_path.trimmed(); + } + + const Settings* settings = Settings::maybeInstance(); + if (settings == nullptr) { + return {}; + } + + const QSettings instanceSettings(settings->filename(), QSettings::IniFormat); + return firstExistingSetting( + instanceSettings, {"Settings/proton_prefix_path", "Settings/prefix_path", + "Proton/prefix_path", "fluorine/prefix_path"}); +} + +QString resolveProtonPath() +{ + if (auto cfg = FluorineConfig::load(); cfg.has_value()) { + const QString protonPath = cfg->proton_path.trimmed(); + if (!protonPath.isEmpty()) { + return protonPath; + } + } + + const Settings* settings = Settings::maybeInstance(); + if (settings == nullptr) { + return {}; + } + + const QSettings instanceSettings(settings->filename(), QSettings::IniFormat); + return firstExistingSetting(instanceSettings, + {"Settings/proton_path", "Proton/path", + "fluorine/proton_path"}); +} +#endif + +#ifdef _WIN32 +DWORD spawn(const SpawnParameters& sp, HANDLE& processHandle) +{ + BOOL inheritHandles = FALSE; + + STARTUPINFO si = {}; + si.cb = sizeof(si); + + // inherit handles if we plan to use stdout or stderr reroute + if (sp.stdOut != INVALID_HANDLE_VALUE) { + si.hStdOutput = sp.stdOut; + inheritHandles = TRUE; + si.dwFlags |= STARTF_USESTDHANDLES; + } + + if (sp.stdErr != INVALID_HANDLE_VALUE) { + si.hStdError = sp.stdErr; + inheritHandles = TRUE; + si.dwFlags |= STARTF_USESTDHANDLES; + } + + const auto bin = QDir::toNativeSeparators(sp.binary.absoluteFilePath()); + const auto cwd = QDir::toNativeSeparators(sp.currentDirectory.absolutePath()); + + QString commandLine = "\"" + bin + "\""; + if (!sp.arguments.isEmpty()) { + commandLine += " " + sp.arguments; + } + + const QString moPath = QCoreApplication::applicationDirPath(); + const auto oldPath = env::appendToPath(QDir::toNativeSeparators(moPath)); + + PROCESS_INFORMATION pi = {}; + BOOL success = FALSE; + + logSpawning(sp, commandLine); + + const auto wcommandLine = commandLine.toStdWString(); + const auto wcwd = cwd.toStdWString(); + + const DWORD flags = CREATE_BREAKAWAY_FROM_JOB; + + if (sp.hooked) { + success = ::usvfsCreateProcessHooked( + nullptr, const_cast(wcommandLine.c_str()), nullptr, nullptr, + inheritHandles, flags, nullptr, wcwd.c_str(), &si, &pi); + } else { + success = ::CreateProcess(nullptr, const_cast(wcommandLine.c_str()), + nullptr, nullptr, inheritHandles, flags, nullptr, + wcwd.c_str(), &si, &pi); + } + + const auto e = GetLastError(); + env::setPath(oldPath); + + if (!success) { + return e; + } + + processHandle = pi.hProcess; + ::CloseHandle(pi.hThread); + + return ERROR_SUCCESS; +} +#else +int spawn(const SpawnParameters& sp, pid_t& processId) +{ + const QString bin = MOBase::normalizePathForHost(sp.binary.absoluteFilePath()); + QString cwd = MOBase::normalizePathForHost(sp.currentDirectory.absolutePath()); + + QStringList argList; + if (!sp.arguments.isEmpty()) { + argList = QProcess::splitCommand(sp.arguments); + } + + if (cwd.isEmpty()) { + cwd = QFileInfo(bin).absolutePath(); + } + + logSpawning(sp, bin + " " + sp.arguments); + + ProtonLauncher launcher; + launcher.setBinary(bin) + .setArguments(argList) + .setWorkingDir(cwd) + .setSteamAppId(parseSteamAppId(sp.steamAppID)) + .setUmu(QSettings().value("fluorine/use_umu", true).toBool()) + .setPreferSystemUmu( + QSettings().value("fluorine/prefer_system_umu", false).toBool()) + .setUseSteamRun( + QSettings().value("fluorine/use_steam_run", false).toBool()); + + const QString prefixPath = resolvePrefixPath(); + if (prefixPath.isEmpty()) { + MOBase::log::warn("No Wine prefix configured - games may not launch correctly. " + "Configure a prefix in Settings > Proton or via fluorine-manager."); + } else if (!QDir(QDir(prefixPath).filePath("drive_c")).exists()) { + MOBase::log::warn("Wine prefix '{}' does not contain drive_c/ - prefix may be invalid", prefixPath); + } else { + MOBase::log::info("Using Wine prefix: {}", prefixPath); + launcher.setPrefix(prefixPath); + } + + const QString protonPath = resolveProtonPath(); + if (!protonPath.isEmpty()) { + launcher.setProtonPath(protonPath); + } + + const QString wrapper = QSettings().value("fluorine/launch_wrapper").toString().trimmed(); + if (!wrapper.isEmpty()) { + launcher.setWrapper(wrapper); + } + + const auto [ok, pid] = launcher.launch(); + if (!ok) { + return (errno != 0 ? errno : EIO); + } + + processId = static_cast(pid); + return 0; +} +#endif + +#ifdef _WIN32 +bool restartAsAdmin(QWidget* parent) +{ + WCHAR cwd[MAX_PATH] = {}; + if (!GetCurrentDirectory(MAX_PATH, cwd)) { + cwd[0] = L'\0'; + } + + if (!helper::adminLaunch(parent, qApp->applicationDirPath().toStdWString(), + qApp->applicationFilePath().toStdWString(), + std::wstring(cwd))) { + log::error("admin launch failed"); + return false; + } + + log::debug("exiting MO"); + ExitModOrganizer(Exit::Force); + + return true; +} + +void startBinaryAdmin(QWidget* parent, const SpawnParameters& sp) +{ + if (!dialogs::confirmRestartAsAdmin(parent, sp)) { + log::debug("user declined"); + return; + } + + log::info("restarting MO as administrator"); + restartAsAdmin(parent); +} +#endif // _WIN32 + +struct SteamStatus +{ + bool running = false; + bool accessible = false; +}; + +SteamStatus getSteamStatus() +{ + SteamStatus ss; + +#ifdef _WIN32 + const auto ps = env::Environment().runningProcesses(); + + for (const auto& p : ps) { + if ((p.name().compare("Steam.exe", Qt::CaseInsensitive) == 0) || + (p.name().compare("SteamService.exe", Qt::CaseInsensitive) == 0)) { + ss.running = true; + ss.accessible = p.canAccess(); + + log::debug("'{}' is running, accessible={}", p.name(), + (ss.accessible ? "yes" : "no")); + + break; + } + } +#else + // On Linux, check for steam process via /proc + QProcess pgrep; + pgrep.start("pgrep", QStringList() << "-x" << "steam"); + pgrep.waitForFinished(3000); + + if (pgrep.exitCode() == 0) { + ss.running = true; + ss.accessible = true; + log::debug("steam is running"); + } +#endif + + return ss; +} + +QString makeSteamArguments(const QString& username, const QString& password) +{ + QString args; + + if (username != "") { + args += "-login " + username; + + if (password != "") { + args += " " + password; + } + } + + return args; +} + +bool startSteam(QWidget* parent) +{ +#ifdef _WIN32 + const QString keyName = "HKEY_CURRENT_USER\\Software\\Valve\\Steam"; + const QString valueName = "SteamExe"; + + const QSettings steamSettings(keyName, QSettings::NativeFormat); + const QString exe = steamSettings.value(valueName, "").toString(); + + if (exe.isEmpty()) { + return (dialogs::badSteamReg(parent, keyName, valueName) == QMessageBox::Yes); + } + + SpawnParameters sp; + sp.binary = QFileInfo(exe); + + // See if username and password supplied. If so, pass them into steam. + QString username, password; + if (Settings::instance().steam().login(username, password)) { + if (username.length() > 0) + MOBase::log::getDefault().addToBlacklist(username.toStdString(), + "STEAM_USERNAME"); + if (password.length() > 0) + MOBase::log::getDefault().addToBlacklist(password.toStdString(), + "STEAM_PASSWORD"); + sp.arguments = makeSteamArguments(username, password); + } + + log::debug("starting steam process:\n" + " . program: '{}'\n" + " . username={}, password={}", + sp.binary.filePath().toStdString(), (username.isEmpty() ? "no" : "yes"), + (password.isEmpty() ? "no" : "yes")); + + HANDLE ph = INVALID_HANDLE_VALUE; + const auto e = spawn(sp, ph); + ::CloseHandle(ph); + + if (e != ERROR_SUCCESS) { + // make sure username and passwords are not shown + sp.arguments = makeSteamArguments((username.isEmpty() ? "" : "USERNAME"), + (password.isEmpty() ? "" : "PASSWORD")); + + const auto r = dialogs::startSteamFailed(parent, keyName, valueName, exe, sp, e); + + return (r == QMessageBox::Yes); + } + + QMessageBox::information( + parent, QObject::tr("Waiting"), + QObject::tr("Please press OK once you're logged into steam.")); + + return true; +#else + // On Linux, find steam via common locations or PATH + QString steamPath; + + // Try ~/.steam/root/steam.sh first + const QString homeDir = QDir::homePath(); + const QString steamSh = homeDir + "/.steam/root/steam.sh"; + if (QFileInfo::exists(steamSh)) { + steamPath = steamSh; + } else { + // Try finding steam in PATH + steamPath = QStandardPaths::findExecutable("steam"); + } + + if (steamPath.isEmpty()) { + log::error("could not find steam installation"); + + const auto title = QObject::tr("Cannot start Steam"); + MOBase::TaskDialog(parent, title) + .main(title) + .content(QObject::tr("The Steam executable could not be found. " + "Make sure Steam is installed.")) + .icon(QMessageBox::Critical) + .button({QObject::tr("Continue without starting Steam"), + QObject::tr("The program may fail to launch."), QMessageBox::Yes}) + .button({QObject::tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + return false; + } + + SpawnParameters sp; + sp.binary = QFileInfo(steamPath); + + pid_t pid = -1; + const auto e = spawn(sp, pid); + + if (e != 0) { + log::error("failed to start steam"); + return false; + } + + QMessageBox::information( + parent, QObject::tr("Waiting"), + QObject::tr("Please press OK once you're logged into steam.")); + + return true; +#endif +} + +bool checkSteam(QWidget* parent, const SpawnParameters& sp, const QDir& gameDirectory, + const QString& steamAppID, const Settings& settings) +{ +#ifdef _WIN32 + static const std::vector steamFiles = {"steam_api.dll", "steam_api64.dll"}; +#else + static const std::vector steamFiles = {"libsteam_api.so"}; +#endif + + log::debug("checking steam"); + + if (!steamAppID.isEmpty()) { + env::set("SteamAPPId", steamAppID); + } else { + env::set("SteamAPPId", settings.steam().appID()); + } + + bool steamRequired = false; + QString details; + + for (const auto& file : steamFiles) { + const QFileInfo fi(gameDirectory.absoluteFilePath(file)); + if (fi.exists()) { + details = QString("managed game is located at '%1' and file '%2' exists") + .arg(gameDirectory.absolutePath()) + .arg(fi.absoluteFilePath()); + + log::debug("{}", details); + steamRequired = true; + + break; + } + } + + if (!steamRequired) { + log::debug("program doesn't seem to require steam"); + return true; + } + + auto ss = getSteamStatus(); + + if (!ss.running) { + log::debug("steam isn't running, asking to start steam"); + + const auto c = dialogs::confirmStartSteam(parent, sp, details); + + if (c == QDialogButtonBox::Yes) { + log::debug("user wants to start steam"); + + if (!startSteam(parent)) { + // cancel + return false; + } + + // double-check that Steam is started + ss = getSteamStatus(); + if (!ss.running) { + log::error("steam is still not running, hoping for the best"); + return true; + } + } else if (c == QDialogButtonBox::No) { + log::debug("user declined to start steam"); + return true; + } else { + log::debug("user cancelled"); + return false; + } + } + +#ifdef _WIN32 + if (ss.running && !ss.accessible) { + log::debug("steam is running but is not accessible, asking to restart MO"); + const auto c = dialogs::confirmRestartAsAdminForSteam(parent, sp); + + if (c == QDialogButtonBox::Yes) { + restartAsAdmin(parent); + return false; + } else if (c == QDialogButtonBox::No) { + log::debug("user declined to restart MO, continuing"); + return true; + } else { + log::debug("user cancelled"); + return false; + } + } +#endif + + return true; +} + +bool checkBlacklist(QWidget* parent, const SpawnParameters& sp, Settings& settings) +{ + for (;;) { + if (!settings.isExecutableBlacklisted(sp.binary.fileName())) { + return true; + } + + const auto r = dialogs::confirmBlacklisted(parent, sp, settings); + + if (r != QMessageBox::Retry) { + return (r == QMessageBox::Yes); + } + } +} + +#ifdef _WIN32 +HANDLE startBinary(QWidget* parent, const SpawnParameters& sp) +{ + HANDLE handle = INVALID_HANDLE_VALUE; + const auto e = spawn::spawn(sp, handle); + + switch (e) { + case ERROR_SUCCESS: { + return handle; + } + + case ERROR_ELEVATION_REQUIRED: { + startBinaryAdmin(parent, sp); + return INVALID_HANDLE_VALUE; + } + + default: { + dialogs::spawnFailed(parent, sp, e); + return INVALID_HANDLE_VALUE; + } + } +} +#else +pid_t startBinary(QWidget* parent, const SpawnParameters& sp) +{ + pid_t pid = -1; + const auto e = spawn::spawn(sp, pid); + + if (e != 0) { + dialogs::spawnFailed(parent, sp, e); + return -1; + } + + return pid; +} +#endif + +#ifdef _WIN32 +QString getExecutableForJarFile(const QString& jarFile) +{ + const std::wstring jarFileW = jarFile.toStdWString(); + + WCHAR buffer[MAX_PATH]; + + const auto hinst = ::FindExecutableW(jarFileW.c_str(), nullptr, buffer); + const auto r = static_cast(reinterpret_cast(hinst)); + + // anything <= 32 signals failure + if (r <= 32) { + log::warn("failed to find executable associated with file '{}', {}", jarFile, + shell::formatError(r)); + + return {}; + } + + DWORD binaryType = 0; + + if (!::GetBinaryTypeW(buffer, &binaryType)) { + const auto e = ::GetLastError(); + + log::warn("failed to determine binary type of '{}', {}", + QString::fromWCharArray(buffer), formatSystemMessage(e)); + + return {}; + } + + if (binaryType != SCS_32BIT_BINARY && binaryType != SCS_64BIT_BINARY) { + log::warn("unexpected binary type {} for file '{}'", binaryType, + QString::fromWCharArray(buffer)); + + return {}; + } + + return QString::fromWCharArray(buffer); +} + +QString getJavaHome() +{ + const QString key = + "HKEY_LOCAL_MACHINE\\Software\\JavaSoft\\Java Runtime Environment"; + const QString value = "CurrentVersion"; + + QSettings reg(key, QSettings::NativeFormat); + + if (!reg.contains(value)) { + log::warn("key '{}\\{}' doesn't exist", key, value); + return {}; + } + + const QString currentVersion = reg.value("CurrentVersion").toString(); + const QString javaHome = QString("%1/JavaHome").arg(currentVersion); + + if (!reg.contains(javaHome)) { + log::warn("java version '{}' was found at '{}\\{}', but '{}\\{}' doesn't exist", + currentVersion, key, value, key, javaHome); + + return {}; + } + + const auto path = reg.value(javaHome).toString(); + return path + "\\bin\\javaw.exe"; +} +#endif // _WIN32 + +QString findJavaInstallation(const QString& jarFile) +{ +#ifdef _WIN32 + // try to find java automatically based on the given jar file + if (!jarFile.isEmpty()) { + const auto s = getExecutableForJarFile(jarFile); + if (!s.isEmpty()) { + return s; + } + } + + // second attempt: look to the registry + const auto s = getJavaHome(); + if (!s.isEmpty()) { + return s; + } +#else + Q_UNUSED(jarFile); + + // On Linux, find java via PATH + const auto javaPath = QStandardPaths::findExecutable("java"); + if (!javaPath.isEmpty()) { + return javaPath; + } +#endif + + // not found + return {}; +} + +bool isBatchFile(const QFileInfo& target) +{ +#ifdef _WIN32 + const auto batchExtensions = {"cmd", "bat"}; +#else + const auto batchExtensions = {"sh"}; +#endif + + const QString extension = target.suffix(); + for (auto&& e : batchExtensions) { + if (extension.compare(e, Qt::CaseInsensitive) == 0) { + return true; + } + } + + return false; +} + +bool isExeFile(const QFileInfo& target) +{ +#ifdef _WIN32 + return (target.suffix().compare("exe", Qt::CaseInsensitive) == 0); +#else + // On Linux, check if file is executable + return target.isExecutable() && target.isFile(); +#endif +} + +bool isJavaFile(const QFileInfo& target) +{ + return (target.suffix().compare("jar", Qt::CaseInsensitive) == 0); +} + +QFileInfo getCmdPath() +{ +#ifdef _WIN32 + const auto p = env::get("COMSPEC"); + if (!p.isEmpty()) { + return QFileInfo(p); + } + + QString systemDirectory; + + const std::size_t buffer_size = 1000; + wchar_t buffer[buffer_size + 1] = {}; + + const auto length = ::GetSystemDirectoryW(buffer, buffer_size); + if (length != 0) { + systemDirectory = QString::fromWCharArray(buffer, length); + + if (!systemDirectory.endsWith("\\")) { + systemDirectory += "\\"; + } + } else { + systemDirectory = "C:\\Windows\\System32\\"; + } + + return QFileInfo(systemDirectory + "cmd.exe"); +#else + const auto p = env::get("SHELL"); + if (!p.isEmpty()) { + return QFileInfo(p); + } + + return QFileInfo("/bin/bash"); +#endif +} + +FileExecutionTypes getFileExecutionType(const QFileInfo& target) +{ + if (isExeFile(target) || isBatchFile(target) || isJavaFile(target)) { + return FileExecutionTypes::Executable; + } + + return FileExecutionTypes::Other; +} + +FileExecutionContext getFileExecutionContext(QWidget* parent, const QFileInfo& target) +{ + if (isExeFile(target)) { + return {target, "", FileExecutionTypes::Executable}; + } + + if (isBatchFile(target)) { +#ifdef _WIN32 + return { + getCmdPath(), + QString("/C \"%1\"").arg(QDir::toNativeSeparators(target.absoluteFilePath())), + FileExecutionTypes::Executable}; +#else + return { + getCmdPath(), + QString("\"%1\"").arg(target.absoluteFilePath()), + FileExecutionTypes::Executable}; +#endif + } + + if (isJavaFile(target)) { + auto java = findJavaInstallation(target.absoluteFilePath()); + + if (java.isEmpty()) { +#ifdef _WIN32 + java = + QFileDialog::getOpenFileName(parent, QObject::tr("Select binary"), QString(), + QObject::tr("Binary") + " (*.exe)"); +#else + java = + QFileDialog::getOpenFileName(parent, QObject::tr("Select binary"), QString(), + QObject::tr("Binary") + " (*)"); +#endif + } + + if (!java.isEmpty()) { + return {QFileInfo(java), + QString("-jar \"%1\"") + .arg(QDir::toNativeSeparators(target.absoluteFilePath())), + FileExecutionTypes::Executable}; + } + } + + return {{}, {}, FileExecutionTypes::Other}; +} + +} // namespace spawn + +#ifdef _WIN32 +namespace helper +{ + +bool helperExec(QWidget* parent, const std::wstring& moDirectory, + const std::wstring& commandLine, BOOL async) +{ + const std::wstring fileName = moDirectory + L"\\helper.exe"; + + env::HandlePtr process; + + { + SHELLEXECUTEINFOW execInfo = {}; + + ULONG flags = SEE_MASK_FLAG_NO_UI; + if (!async) + flags |= SEE_MASK_NOCLOSEPROCESS; + + execInfo.cbSize = sizeof(SHELLEXECUTEINFOW); + execInfo.fMask = flags; + execInfo.hwnd = 0; + execInfo.lpVerb = L"runas"; + execInfo.lpFile = fileName.c_str(); + execInfo.lpParameters = commandLine.c_str(); + execInfo.lpDirectory = moDirectory.c_str(); + execInfo.nShow = SW_SHOW; + + if (!::ShellExecuteExW(&execInfo) && execInfo.hProcess == 0) { + const auto e = GetLastError(); + + spawn::dialogs::helperFailed(parent, e, "ShellExecuteExW()", fileName, + moDirectory, commandLine); + + return false; + } + + if (async) { + return true; + } + + process.reset(execInfo.hProcess); + } + + const auto r = ::WaitForSingleObject(process.get(), INFINITE); + + if (r != WAIT_OBJECT_0) { + // for WAIT_ABANDONED, the documentation doesn't mention that GetLastError() + // returns something meaningful, but code ERROR_ABANDONED_WAIT_0 exists, so + // use that instead + const auto code = (r == WAIT_ABANDONED ? ERROR_ABANDONED_WAIT_0 : GetLastError()); + + spawn::dialogs::helperFailed(parent, code, "WaitForSingleObject()", fileName, + moDirectory, commandLine); + + return false; + } + + DWORD exitCode = 0; + if (!GetExitCodeProcess(process.get(), &exitCode)) { + const auto e = GetLastError(); + + spawn::dialogs::helperFailed(parent, e, "GetExitCodeProcess()", fileName, + moDirectory, commandLine); + + return false; + } + + return (exitCode == 0); +} + +bool backdateBSAs(QWidget* parent, const std::wstring& moPath, + const std::wstring& dataPath) +{ + const std::wstring commandLine = std::format(L"backdateBSA \"{}\"", dataPath); + + return helperExec(parent, moPath, commandLine, FALSE); +} + +bool adminLaunch(QWidget* parent, const std::wstring& moPath, + const std::wstring& moFile, const std::wstring& workingDir) +{ + const std::wstring commandLine = std::format( + L"adminLaunch {} \"{}\" \"{}\"", ::GetCurrentProcessId(), moFile, workingDir); + + return helperExec(parent, moPath, commandLine, true); +} + +} // namespace helper +#endif // _WIN32 diff --git a/src/src/spawn.h b/src/src/spawn.h new file mode 100644 index 0000000..48aaa05 --- /dev/null +++ b/src/src/spawn.h @@ -0,0 +1,134 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef SPAWN_H +#define SPAWN_H + +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#include +#else +#include +#include +#endif + +class Settings; + +namespace spawn +{ + +/* + * @param binary the binary to spawn + * @param arguments arguments to pass to the binary + * @param profileName name of the active profile + * @param currentDirectory the directory to use as the working directory to run in + * @param logLevel log level to be used by the hook library. Ignored if hooked is false + * @param hooked if set, the binary is started with mo injected + * @param stdout if not equal to INVALID_HANDLE_VALUE, this is used as stdout for the + * process + * @param stderr if not equal to INVALID_HANDLE_VALUE, this is used as stderr for the + * process + */ +struct SpawnParameters +{ + QFileInfo binary; + QString arguments; + QDir currentDirectory; + QString steamAppID; + bool hooked = false; +#ifdef _WIN32 + HANDLE stdOut = INVALID_HANDLE_VALUE; + HANDLE stdErr = INVALID_HANDLE_VALUE; +#else + int stdOut = -1; + int stdErr = -1; +#endif +}; + +bool checkSteam(QWidget* parent, const SpawnParameters& sp, const QDir& gameDirectory, + const QString& steamAppID, const Settings& settings); + +bool checkBlacklist(QWidget* parent, const SpawnParameters& sp, Settings& settings); + +/** + * @brief spawn a binary with Mod Organizer injected + * @return the process handle + **/ +#ifdef _WIN32 +HANDLE startBinary(QWidget* parent, const SpawnParameters& sp); +#else +pid_t startBinary(QWidget* parent, const SpawnParameters& sp); +#endif + +enum class FileExecutionTypes +{ + Executable = 1, + Other +}; + +struct FileExecutionContext +{ + QFileInfo binary; + QString arguments; + FileExecutionTypes type; +}; + +QString findJavaInstallation(const QString& jarFile); + +FileExecutionContext getFileExecutionContext(QWidget* parent, const QFileInfo& target); + +FileExecutionTypes getFileExecutionType(const QFileInfo& target); + +} // namespace spawn + +#ifdef _WIN32 +// convenience functions to work with the external helper program, which is used +// to make changes on the system that require administrative rights, so that +// ModOrganizer itself can run without special privileges +// +namespace helper +{ + +/** + * @brief sets the last modified time for all .bsa-files in the target directory well + *into the past + * @param moPath absolute path to the modOrganizer base directory + * @param dataPath the path taht contains the .bsa-files, usually the data directory of + *the game + **/ +bool backdateBSAs(QWidget* parent, const std::wstring& moPath, + const std::wstring& dataPath); + +/** + * @brief waits for the current process to exit and restarts it as an administrator + * @param moPath absolute path to the modOrganizer base directory + * @param moFile file name of modOrganizer + * @param workingDir current working directory + **/ +bool adminLaunch(QWidget* parent, const std::wstring& moPath, + const std::wstring& moFile, const std::wstring& workingDir); + +} // namespace helper +#endif // _WIN32 + +#endif // SPAWN_H diff --git a/src/src/splash.png b/src/src/splash.png new file mode 100644 index 0000000..d29a671 Binary files /dev/null and b/src/src/splash.png differ diff --git a/src/src/statusbar.cpp b/src/src/statusbar.cpp new file mode 100644 index 0000000..e77e720 --- /dev/null +++ b/src/src/statusbar.cpp @@ -0,0 +1,234 @@ +#include "statusbar.h" +#include "instancemanager.h" +#include "nexusinterface.h" +#include "organizercore.h" +#include "settings.h" +#include "ui_mainwindow.h" + +StatusBar::StatusBar(QWidget* parent) + : QStatusBar(parent), ui(nullptr), m_normal(new QLabel), + m_progress(new QProgressBar), m_progressSpacer1(new QWidget), + m_progressSpacer2(new QWidget), m_notifications(nullptr), m_update(nullptr), + m_api(new QLabel) +{} + +void StatusBar::setup(Ui::MainWindow* mainWindowUI, const Settings& settings) +{ + ui = mainWindowUI; + m_notifications = new StatusBarAction(ui->actionNotifications); + m_update = new StatusBarAction(ui->actionUpdate); + + addWidget(m_normal); + + m_progressSpacer1->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + addPermanentWidget(m_progressSpacer1, 0); + addPermanentWidget(m_progress); + + m_progressSpacer2->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + addPermanentWidget(m_progressSpacer2, 0); + + addPermanentWidget(m_notifications); + addPermanentWidget(m_update); + addPermanentWidget(m_api); + + m_progress->setTextVisible(true); + m_progress->setRange(0, 100); + m_progress->setMaximumWidth(300); + m_progress->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + + m_update->set(false); + m_notifications->set(false); + + m_api->setObjectName("apistats"); + m_api->setToolTip(QObject::tr( + "This tracks the number of queued Nexus API requests, as well as the " + "remaining daily and hourly requests. The Nexus API limits you to a pool " + "of requests per day and requests per hour. It is dynamically updated " + "every time a request is completed. If you run out of requests, you will " + "be unable to queue downloads, check updates, parse mod info, or even log " + "in. Both pools must be consumed before this happens.")); + + clearMessage(); + setProgress(-1); + setAPI({}, {}); + + checkSettings(settings); +} + +void StatusBar::setProgress(int percent) +{ + bool visible = true; + + if (percent < 0 || percent >= 100) { + clearMessage(); + visible = false; + } else { + showMessage(QObject::tr("Loading...")); + m_progress->setValue(percent); + } + + m_progress->setVisible(visible); + m_progressSpacer1->setVisible(visible); + m_progressSpacer2->setVisible(visible); +} + +void StatusBar::setNotifications(bool hasNotifications) +{ + if (m_notifications) { + m_notifications->set(hasNotifications); + } +} + +void StatusBar::setAPI(const APIStats& stats, const APIUserAccount& user) +{ + QString text; + QString textColor; + QString backgroundColor; + + if (user.type() == APIUserAccountTypes::None) { + text = "API: not logged in"; + textColor = ""; + backgroundColor = ""; + } else { + text = QString("API: Queued: %1 | Daily: %2 | Hourly: %3") + .arg(stats.requestsQueued) + .arg(user.limits().remainingDailyRequests) + .arg(user.limits().remainingHourlyRequests); + + if (user.remainingRequests() > 500) { + textColor = "white"; + backgroundColor = "darkgreen"; + } else if (user.remainingRequests() > 200) { + textColor = "black"; + backgroundColor = "rgb(226, 192, 0)"; // yellow + } else { + textColor = "white"; + backgroundColor = "darkred"; + } + } + + m_api->setText(text); + + QString ss(R"( + QLabel + { + padding-left: 0.1em; + padding-right: 0.1em; + padding-top: 0; + padding-bottom: 0;)"); + + if (!textColor.isEmpty()) { + ss += QString("\ncolor: %1;").arg(textColor); + } + + if (!backgroundColor.isEmpty()) { + ss += QString("\nbackground-color: %2;").arg(backgroundColor); + } + + ss += "\n}"; + + m_api->setStyleSheet(ss); + m_api->setAutoFillBackground(true); +} + +void StatusBar::setUpdateAvailable(bool b) +{ + m_update->set(b); +} + +void StatusBar::checkSettings(const Settings& settings) +{ + m_api->setVisible(!settings.interface().hideAPICounter()); +} + +void StatusBar::updateNormalMessage(OrganizerCore& core) +{ + QString game; + + if (core.managedGame()) { + game = core.managedGame()->displayGameName(); + } else { + game = tr("Unknown game"); + } + + QString instance = "?"; + if (auto i = InstanceManager::singleton().currentInstance()) + instance = i->displayName(); + + QString profile = core.profileName(); + + const auto s = QString("%1 - %2 - %3").arg(game).arg(instance).arg(profile); + + m_normal->setText(s); +} + +void StatusBar::showEvent(QShowEvent*) +{ + visibilityChanged(true); +} + +void StatusBar::hideEvent(QHideEvent*) +{ + visibilityChanged(false); +} + +void StatusBar::visibilityChanged(bool visible) +{ + // the central widget typically has no bottom padding because the status bar + // is more than enough, but when it's hidden, the bottom widget (currently + // the log) touches the bottom border of the window, which looks ugly + // + // when hiding the statusbar, the central widget is given the same border + // margin as it has on the top (which is typically 6, as it's the default from + // the qt designer) + + auto m = ui->centralWidget->layout()->contentsMargins(); + + if (visible) { + m.setBottom(0); + } else { + m.setBottom(m.top()); + } + + ui->centralWidget->layout()->setContentsMargins(m); +} + +StatusBarAction::StatusBarAction(QAction* action) + : m_action(action), m_icon(new QLabel), m_text(new QLabel) +{ + setLayout(new QHBoxLayout); + layout()->setContentsMargins(0, 0, 0, 0); + layout()->addWidget(m_icon); + layout()->addWidget(m_text); +} + +void StatusBarAction::set(bool visible) +{ + if (visible) { + m_icon->setPixmap(m_action->icon().pixmap(16, 16)); + m_text->setText(cleanupActionText(m_action->text())); + } + + setVisible(visible); +} + +void StatusBarAction::mouseDoubleClickEvent(QMouseEvent* e) +{ + if (m_action->isEnabled()) { + m_action->trigger(); + } +} + +QString StatusBarAction::cleanupActionText(const QString& original) const +{ + QString s = original; + + s.replace(QRegularExpression("\\&([^&])"), "\\1"); // &Item -> Item + s.replace("&&", "&"); // &&Item -> &Item + + if (s.endsWith("...")) { + s = s.left(s.size() - 3); + } + + return s; +} diff --git a/src/src/statusbar.h b/src/src/statusbar.h new file mode 100644 index 0000000..8e085fd --- /dev/null +++ b/src/src/statusbar.h @@ -0,0 +1,68 @@ +#ifndef MO_STATUSBAR_H +#define MO_STATUSBAR_H + +#include +#include + +struct APIStats; +class APIUserAccount; +class Settings; +class OrganizerCore; + +namespace Ui +{ +class MainWindow; +} + +class StatusBarAction : public QWidget +{ +public: + StatusBarAction(QAction* action); + + void set(bool visible); + +protected: + void mouseDoubleClickEvent(QMouseEvent* e) override; + +private: + QAction* m_action; + QLabel* m_icon; + QLabel* m_text; + + QString cleanupActionText(const QString& s) const; +}; + +class StatusBar : public QStatusBar +{ + Q_OBJECT; + +public: + StatusBar(QWidget* parent = nullptr); + + void setup(Ui::MainWindow* ui, const Settings& settings); + + void setProgress(int percent); + void setNotifications(bool hasNotifications); + void setAPI(const APIStats& stats, const APIUserAccount& user); + void setUpdateAvailable(bool b); + void checkSettings(const Settings& settings); + void updateNormalMessage(OrganizerCore& core); + +protected: + void showEvent(QShowEvent* e); + void hideEvent(QHideEvent* e); + +private: + Ui::MainWindow* ui; + QLabel* m_normal; + QProgressBar* m_progress; + QWidget* m_progressSpacer1; + QWidget* m_progressSpacer2; + StatusBarAction* m_notifications; + StatusBarAction* m_update; + QLabel* m_api; + + void visibilityChanged(bool visible); +}; + +#endif // MO_STATUSBAR_H diff --git a/src/src/stylesheet_resource.qrc b/src/src/stylesheet_resource.qrc new file mode 100644 index 0000000..75de976 --- /dev/null +++ b/src/src/stylesheet_resource.qrc @@ -0,0 +1,7 @@ + + + resources/combobox-down.png + resources/branch-closed.png + resources/branch-open.png + + diff --git a/src/src/stylesheets/Night Eyes.qss b/src/src/stylesheets/Night Eyes.qss new file mode 100644 index 0000000..f5e37a1 --- /dev/null +++ b/src/src/stylesheets/Night Eyes.qss @@ -0,0 +1,702 @@ +/* Night Eyes theme v1.2.0 for Mod Organizer 2 by Ciathyza */ +/* https://github.com/ciathyza/mo2-themes */ + +/* Main Window ---------------------------------------------------------------- */ + +QWidget +{ + background: #181818; + color: #AAAAAA; +} + +QWidget:disabled +{ + background: #181818; + color: #808080; +} + +QMainWindow::separator +{ + border: 0px; +} + +QAbstractItemView +{ + background: #141414; + alternate-background-color: #141414; + show-decoration-selected: 1; + selection-background-color: #001133; + selection-color: #0099EE; +} + +QAbstractItemView::item:hover +{ + color: #FFFFFF; +} + +QAbstractItemView::item:selected +{ + background: #001133; + color: #0099EE; +} + +QAbstractScrollArea::corner +{ + background: #141414; + border: 2px solid #181818; + border-bottom-right-radius: 6px; + margin: 0px -2px -2px 0px; +} + +LinkLabel +{ + qproperty-linkColor: #3399FF; +} + + +/* Toolbar -------------------------------------------------------------------- */ + +QToolBar +{ + background: #181818; + border: 1px solid #181818; +} + +QToolBar::separator +{ + background: #181818; +} + +QToolButton +{ + padding: 4px 1px; + border-radius: 2px; + margin: 4px 1px 0px 1px; +} + +QToolButton:hover +{ + background: #282828; +} + +QToolButton:pressed +{ + background: #181818; +} + +QToolButton::menu-indicator +{ + width: 8px; +} + + +/* Left Pane & File Trees ----------------------------------------------------- */ + +QTreeView +{ + border-radius: 6px; +} + +QTreeView::branch:hover +{ + background: #181818; + color: #FFFFFF; +} + +QTreeView::branch:selected +{ + background: #001133; + color: #0099EE; +} + +QTreeView::item:selected +{ + background: #001133; + color: #0099EE; +} + +QTreeView::branch:has-children:!has-siblings:closed, +QTreeView::branch:closed:has-children:has-siblings +{ + image: url(:/stylesheet/branch-closed.png); + border: 0px; +} + +QTreeView::branch:open:has-children:!has-siblings, +QTreeView::branch:open:has-children:has-siblings +{ + image: url(:/stylesheet/branch-open.png); + border: 0px; +} + +QListView +{ + border-radius: 6px; +} + +QListView::item:hover +{ + background: #242424; + color: #FFCC88; +} + +QListView::item:selected +{ + background: #001133; + color: #0099EE; +} + +QTextEdit +{ + background: #141414; + border-radius: 6px; +} + +QWebView +{ + background: #141414; + border-radius: 6px; +} + + +/* Group Boxes ---------------------------------------------------------------- */ + +QGroupBox +{ + padding: 24px 4px; + border: 2px solid #141414; + border-radius: 10px; +} + +QGroupBox::title +{ + subcontrol-origin: padding; + subcontrol-position: top left; + padding: 8px; +} + + +/* Search Boxes --------------------------------------------------------------- */ + +QLineEdit +{ + background: #141414; + min-height: 14px; + padding: 2px; + border: 2px solid #141414; + border-radius: 6px; + margin-top: 3px; +} + +QLineEdit:hover +{ + border: 2px solid #242424; +} + + +/* Most Dropdowns ------------------------------------------------------------- */ + +QComboBox +{ + background: #141414; + min-height: 20px; + padding-left: 5px; + border: 2px solid #141414; + border-radius: 6px; + margin: 3px 0px 1px 0px; +} + +QComboBox:hover +{ + border: 2px solid #242424; +} + +QComboBox:on +{ + background: #181818; + color: #FFCC88; + border: 2px solid #181818; +} + +QComboBox::drop-down +{ + width: 20px; + subcontrol-origin: padding; + subcontrol-position: top right; + border: none; +} + +QComboBox QAbstractItemView +{ + border: 0px; +} + +QComboBox::down-arrow +{ + image: url(:/stylesheet/combobox-down.png); +} + + +/* Most Buttons --------------------------------------------------------------- */ + +QPushButton +{ + background: #141414; + color: #FFCC88; + min-height: 18px; + padding: 2px 12px; + border-radius: 6px; +} + +QPushButton:hover +{ + background: #242424; + color: #FFCC88; +} + +QPushButton:pressed +{ + background: #181818; + color: #FFCC88; +} + +QPushButton:checked +{ + background: #181818; + color: #FFCC88; + margin: 4px; +} + + +/* Scroll Bars ---------------------------------------------------------------- */ + +/* Horizontal */ + +QScrollBar:horizontal +{ + background: #141414; + height: 16px; + border: 2px solid #181818; + margin: 0px 23px -2px 23px; +} + +QScrollBar::handle:horizontal +{ + background: #222222; + min-width: 32px; + border-radius: 6px; + margin: 2px; +} + +QScrollBar::add-line:horizontal +{ + background: #141414; + width: 23px; + subcontrol-position: right; + subcontrol-origin: margin; + border: 2px solid #181818; + margin: 0px -2px -2px 0px; +} + +QScrollBar::sub-line:horizontal +{ + background: #141414; + width: 23px; + subcontrol-position: left; + subcontrol-origin: margin; + border: 2px solid #181818; + border-bottom-left-radius: 6px; + margin: 0px 0px -2px -2px; +} + +/* Vertical */ + +QScrollBar:vertical +{ + background: #141414; + width: 16px; + border: 2px solid #181818; + margin: 23px -2px 23px 0px; +} + +QScrollBar::handle:vertical +{ + background: #222222; + min-height: 32px; + border-radius: 6px; + margin: 2px; +} + +QScrollBar::add-line:vertical +{ + background: #141414; + height: 23px; + subcontrol-position: bottom; + subcontrol-origin: margin; + border: 2px solid #181818; + border-bottom-right-radius: 6px; + margin: 0px -2px -2px 0px; +} + +QScrollBar::sub-line:vertical +{ + background: #141414; + height: 23px; + subcontrol-position: top; + subcontrol-origin: margin; + border: 2px solid #181818; + border-top-right-radius: 6px; + margin: -2px -2px 0px 0px; +} + +/* Combined */ + +QScrollBar::handle:horizontal:hover, +QScrollBar::handle:vertical:hover, +QScrollBar::add-line:horizontal:hover, +QScrollBar::sub-line:horizontal:hover, +QScrollBar::add-line:vertical:hover, +QScrollBar::sub-line:vertical:hover +{ + background: #242424; +} + +QScrollBar::handle:horizontal:pressed, +QScrollBar::handle:vertical:pressed, +QScrollBar::add-line:horizontal:pressed, +QScrollBar::sub-line:horizontal:pressed, +QScrollBar::add-line:vertical:pressed, +QScrollBar::sub-line:vertical:pressed +{ + background: #181818; +} + +QScrollBar::add-page:horizontal, +QScrollBar::sub-page:horizontal, +QScrollBar::add-page:vertical, +QScrollBar::sub-page:vertical +{ + background: transparent; +} + +QScrollBar::up-arrow:vertical, +QScrollBar::right-arrow:horizontal, +QScrollBar::down-arrow:vertical, +QScrollBar::left-arrow:horizontal +{ + height: 1px; + width: 1px; + border: 1px solid #181818; +} + + +/* Header Rows ---------------------------------------------------------------- */ + +QHeaderView +{ + background: #181818; +} + +/* Table View Tab Headers */ +QHeaderView::section +{ + background: #141414; + color: #D3D3D3; + height: 22px; + padding: 0px 5px; + border: 0px; + border-bottom: 2px solid #181818; + border-right: 2px solid #181818; +} + +QHeaderView::section:first +{ + border-top-left-radius: 6px; +} + +QHeaderView::section:last +{ + border-right: 0px; + border-top-right-radius: 6px; +} + +QHeaderView::section:hover +{ + background: #242424; + color: #FFCC88; +} + +QHeaderView::down-arrow +{ + padding-right: 4px; + height: 10px; + width: 10px; +} + + +/* Context Menus, Toolbar Dropdowns, & Tooltips ------------------------------- */ + +QMenuBar +{ + background: #181818; + border: 1px solid #181818; +} + +QMenuBar::item:selected +{ + background: #242424; + color: #FFCC88; +} + +QMenu +{ + background: #141414; + selection-color: #FFCC88; + border: 0px; +} + +QMenu::item +{ + background: #141414; + selection-background-color: #242424; + padding: 4px 20px; +} + +QMenu::item:selected +{ + background: #242424; + color: #FFCC88; +} + +QMenu::item:disabled +{ + background: #242424; + color: #808080; +} + +QMenu::separator +{ + background: #181818; + height: 2px; +} + +QMenu::icon +{ + margin: 1px; +} + +QToolTip +{ + background: #181818; + color: #FFCC88; + padding: 1px; + border: 0px; +} + +QStatusBar::item {border: None;} + + +/* Progress Bars (Downloads) -------------------------------------------------- */ + +QProgressBar +{ + background: #141414; + text-align: center; + border: 0px; + border-radius: 6px; + margin: 0px 10px; +} + +QProgressBar::chunk +{ + background: #242424; + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; +} + + +/* Right Pane and Tab Bars ---------------------------------------------------- */ + +QTabWidget::pane +{ + top: 1px; + padding: 2px 2px 10px 2px; + border: 2px solid #141414; + border-radius: 10px; +} + +QTabWidget::tab-bar +{ + alignment: center; +} + +QTabBar::tab +{ + background: #141414; + color: #141414; + padding: 4px 1em; + border: 1px solid #181818; + border-top: 0px; + border-bottom: 0px; +} + +QTabBar::tab:!selected +{ + background: #141414; + color: #D3D3D3; +} + +QTabBar::tab:disabled +{ + background: #181818; + color: #808080; +} + +QTabBar::tab:selected +{ + background: #181818; + color: #FFCC88; + +} + +QTabBar::tab:!selected:hover +{ + background: #242424; + color: #FFCC88; +} + +QTabBar::tab:first +{ + border-top-left-radius: 10px; + border-bottom-left-radius: 10px; +} + +QTabBar::tab:last +{ + border-top-right-radius: 10px; + border-bottom-right-radius: 10px; +} + +QTabBar QToolButton +{ + background: #242424; + padding: 1px; + border-radius: 6px; + margin: 1px; +} + +QTabBar QToolButton:disabled +{ + background: transparent; +} + + +/* Sliders (Configurator) ----------------------------------------------------- */ + +/* QSlider::groove:horizontal +{ + background: #FFCC88; + height: 1px; + border: 1px solid #FFCC88; +} + +QSlider::handle:horizontal +{ + background: #242424; + width: 10px; + border: 2px solid #242424; + border-radius: 6px; + margin: -10px 0px; +} + +QSlider::handle:horizontal:hover +{ + background: #181818; + border: 2px solid #181818; +} */ + + +/* Tables (Configure Mod Categories) ------------------------------------------ */ + +QTableView +{ + gridline-color: #181818; + border: 0px; +} + +QListWidget::item#executablesListBox +{ + /* fixes the black text problem on the Modify Executables window */ + color: #D3D3D3; +} + + +/* Downloads tab -------------------------------------------------------------- */ + +QWidget#downloadTab QAbstractScrollArea +{ + /* background of the entire downloads tab */ + background: #141414; +} + +DownloadListView QFrame +{ + /* an entry on the Downloads tab */ + background: #181818; +} + +DownloadListView QFrame#frame +{ + /* outer box of an entry on the Downloads tab */ + border: 2px solid #141414; +} + +DownloadListView QLabel#installLabel +{ + color: none; +} + +DownloadListView QFrame:clicked +{ + background: #242424; +} + +/* compact downloads view */ + +DownloadListView[downloadView=standard]::item +{ + padding: 16px; +} + +DownloadListView[downloadView=compact]::item +{ + padding: 4px; +} + +DownloadListView::item:hover +{ + padding: 0px; +} + +DownloadListView::item:selected +{ + padding: 0px; +} + +QProgressBar +{ + border: 2px solid grey; + border-radius: 5px; + text-align: center; + margin: 0px; +} + +QAbstractItemView[filtered=true] +{ + border: 2px solid #f00 !important; +} + +QLineEdit[valid-filter=false] +{ + background-color: #661111 !important; +} diff --git a/src/src/stylesheets/Parchment v1.1 by Bob.qss b/src/src/stylesheets/Parchment v1.1 by Bob.qss new file mode 100644 index 0000000..269869a --- /dev/null +++ b/src/src/stylesheets/Parchment v1.1 by Bob.qss @@ -0,0 +1,607 @@ +/* v1.1 Parchment by Bob */ + + +/* Special Thanks */ + +/* Parchment is based on Paper Themes for Mod Organizer 2 by 6788 +https://www.nexusmods.com/skyrim/mods/64439 +https://6788-00.tumblr.com/ + +and + +************************************* + Skyrim +**************************************** + Author: chintsu_kun + Version: 1.1.2 + Licence: GNU General Public License v3.0 (https://www.gnu.org/licenses/gpl-3.0.en.html) + Url: http://www.nexusmods.com/skyrim/mods/73817/? + +**************************************** + +For the Checkbox Coding and for the Picture Files which I Edited in Paint 3D */ + +/* Main Window */ + +QWidget { + background: #DBD399; + color: #000000; +} + +QWidget:disabled { + background: #DBD399; + color: #baae94; +} + +QMainWindow::separator { + border: 0px; +} + +QAbstractItemView { + background: #F7F6CF; + alternate-background-color: #EFE9BA; + show-decoration-selected: 1; + selection-background-color: #0CA6FF; + selection-color: #F7F6CF; +} + +QAbstractItemView::item:hover { + background: #008F8F; + color: #F7F6CF; +} + +QAbstractItemView::item:selected { + background: #0CA6FF; + color: #F7F6CF; +} + +QAbstractScrollArea::corner { + background: #F7F6CF; + border: 2px solid #DBD399; + border-bottom-right-radius: 6px; + margin: 0px -2px -2px 0px; +} + +/* Toolbar */ + +QToolBar { + background: #DBD399; + border: 1px solid #DBD399; +} + +QToolBar::separator { + background: #DBD399; +} + +QToolButton { + background: #F7F6CF; + padding: 4px 6px; + border-radius: 6px; + margin: 4px 4px 0px 4px; +} + +QToolButton:hover { + background: #008F8F; +} + +QToolButton:pressed { + background: #0CA6FF; +} + +/* Left Pane & File Trees */ + +QTreeView { + border-radius: 6px; +} + +QTreeView::branch:hover { + background: #008F8F; + color: #F7F6CF; +} + +QTreeView::branch:selected { + background: #0CA6FF; + color: #F7F6CF; +} + +QTreeView::item:selected { + background: #0CA6FF; + color: #F7F6CF; +} + +QTreeView::branch:has-children:!has-siblings:closed, +QTreeView::branch:closed:has-children:has-siblings { + image: url(:/stylesheet/branch-closed.png); + border: 0px; +} + +QTreeView::branch:open:has-children:!has-siblings, +QTreeView::branch:open:has-children:has-siblings { + image: url(:/stylesheet/branch-open.png); + border: 0px; +} + +QListView { + border-radius: 6px; +} + +QListView::item:hover { + background: #008F8F; + color: #F7F6CF; +} + +QListView::item:selected { + background: #0CA6FF; + color: #F7F6CF; +} + +QTextEdit { + background: #F7F6CF; + border-radius: 6px; +} + +QWebView { + background: #F7F6CF; + border-radius: 6px; +} + +QAbstractItemView[filtered=true] { + border: 2px solid #f00 !important; +} + +/* Group Boxes */ + +QGroupBox { + padding: 24px 4px; + border: 2px solid #F7F6CF; + border-radius: 10px; +} + +QGroupBox::title { + subcontrol-origin: padding; + subcontrol-position: top left; + padding: 8px; +} + +/* Checkboxes and Radio buttons common #QCheckBox, #QRadioButton */ +/*common*/ +QGroupBox::indicator, +QTreeView::indicator, + +QCheckBox::indicator { + background-color: transparent; + border: none; + width: 14px; + height: 14px; +} + +QGroupBox::indicator:checked, QGroupBox::indicator:indeterminate, +QTreeView::indicator:checked, +QTreeView::indicator:indeterminate, +QCheckBox::indicator:checked, + +QCheckBox::indicator:indeterminate { + image: url(./Parchment/checkbox-checked.png); +} + +QGroupBox::indicator:checked:hover, QGroupBox::indicator:indeterminate:hover, +QTreeView::indicator:checked:hover, +QTreeView::indicator:indeterminate:hover, +QCheckBox::indicator:checked:hover, + +QCheckBox::indicator:indeterminate:hover { + image: url(./Parchment/checkbox-checked-hover.png); +} + + QGroupBox::indicator:checked:disabled, QGroupBox::indicator:indeterminate:disabled, + QTreeView::indicator:checked:disabled, + QTreeView::indicator:indeterminate:disabled, + QCheckBox::indicator:checked:disabled, + + QCheckBox::indicator:indeterminate:disabled { + image: url(./Parchment/checkbox-checked-disabled.png); +} + + QGroupBox::indicator:unchecked, + QTreeView::indicator:unchecked, + + QCheckBox::indicator:unchecked { + image: url(./Parchment/checkbox.png); +} + + QGroupBox::indicator:unchecked:hover, + QTreeView::indicator:unchecked:hover, + + QCheckBox::indicator:unchecked:hover { + image: url(./Parchment/checkbox-hover.png); +} + + QGroupBox::indicator:unchecked:disabled, + QTreeView::indicator:unchecked:disabled, + + QCheckBox::indicator:unchecked:disabled { + image: url(./Parchment/checkbox-disabled.png); +} + +/* Search Boxes */ + +QLineEdit { + background: #F7F6CF; + min-height: 14px; + padding: 2px; + border: 2px solid #F7F6CF; + border-radius: 6px; + margin-top: 3px; +} + +QLineEdit:hover { + border: 2px solid #008F8F; +} + +/* Most Dropdowns */ + +QComboBox { + background: #F7F6CF; + min-height: 20px; + padding-left: 5px; + border: 2px solid #F7F6CF; + border-radius: 6px; + margin: 3px 0px 1px 0px; +} + +QComboBox:hover { + border: 2px solid #008F8F; +} + +QComboBox:on { + background: #0CA6FF; + color: #F7F6CF; + border: 2px solid #0CA6FF; +} + +QComboBox::drop-down { + width: 20px; + subcontrol-origin: padding; + subcontrol-position: top right; + border: none; +} + +QComboBox QAbstractItemView { + border: 0px; +} + +QComboBox::down-arrow { + image: url(:/stylesheet/combobox-down.png); +} + +/* Most Buttons */ + +QPushButton { + background: #F7F6CF; + color: #000000; + min-height: 28px; + padding: 4px 22px; + border-radius: 9px; +} + +QPushButton:hover { + background: #008F8F; + color: #F7F6CF; +} + +QPushButton:pressed { + background: #0CA6FF; + color: #F7F6CF; +} + +QPushButton:checked { + background: #0CA6FF; + color: #0CA6FF; + margin: 4px; +} + +/* Scroll Bars */ + +/* Horizontal */ + +QScrollBar:horizontal { + background: #F7F6CF; + height: 20px; + border: 2px solid #DBD399; + margin: 0px 23px -2px 23px; +} + +QScrollBar::handle:horizontal { + background: #DBD399; + min-width: 32px; + border-radius: 6px; + margin: 2px; +} + +QScrollBar::add-line:horizontal { + background: #F7F6CF; + width: 23px; + subcontrol-position: right; + subcontrol-origin: margin; + border: 2px solid #DBD399; + margin: 0px -2px -2px 0px; +} + +QScrollBar::sub-line:horizontal { + background: #F7F6CF; + width: 23px; + subcontrol-position: left; + subcontrol-origin: margin; + border: 2px solid #DBD399; + border-bottom-left-radius: 6px; + margin: 0px 0px -2px -2px; +} + +/* Vertical */ + +QScrollBar:vertical { + background: #F7F6CF; + width: 20px; + border: 2px solid #DBD399; + margin: 23px -2px 23px 0px; +} + +QScrollBar::handle:vertical { + background: #DBD399; + min-height: 32px; + border-radius: 6px; + margin: 2px; +} + +QScrollBar::add-line:vertical { + background: #F7F6CF; + height: 23px; + subcontrol-position: bottom; + subcontrol-origin: margin; + border: 2px solid #DBD399; + border-bottom-right-radius: 6px; + margin: 0px -2px -2px 0px; +} + +QScrollBar::sub-line:vertical { + background: #F7F6CF; + height: 23px; + subcontrol-position: top; + subcontrol-origin: margin; + border: 2px solid #DBD399; + border-top-right-radius: 6px; + margin: -2px -2px 0px 0px; +} + +/* Combined */ + +QScrollBar::handle:horizontal:hover, +QScrollBar::handle:vertical:hover, +QScrollBar::add-line:horizontal:hover, +QScrollBar::sub-line:horizontal:hover, +QScrollBar::add-line:vertical:hover, +QScrollBar::sub-line:vertical:hover { + background: #008F8F; +} + +QScrollBar::handle:horizontal:pressed, +QScrollBar::handle:vertical:pressed, +QScrollBar::add-line:horizontal:pressed, +QScrollBar::sub-line:horizontal:pressed, +QScrollBar::add-line:vertical:pressed, +QScrollBar::sub-line:vertical:pressed { + background: #0CA6FF; +} + +QScrollBar::add-page:horizontal, +QScrollBar::sub-page:horizontal, +QScrollBar::add-page:vertical, +QScrollBar::sub-page:vertical { + background: transparent; +} + +QScrollBar::up-arrow:vertical, +QScrollBar::right-arrow:horizontal, +QScrollBar::down-arrow:vertical, +QScrollBar::left-arrow:horizontal { + height: 1px; + width: 1px; + border: 1px solid #DBD399; +} + +/* Header Rows */ + +QHeaderView { + background: #DBD399; +} + +QHeaderView::section { + background: #F7F6CF; + color: #000000; + height: 22px; + padding: 0px 5px; + border: 0px; + border-bottom: 2px solid #DBD399; + border-right: 2px solid #DBD399; +} + +QHeaderView::section:first { + border-top-left-radius: 6px; +} + +QHeaderView::section:last { + border-right: 0px; + border-top-right-radius: 6px; +} + +QHeaderView::section:hover { + background: #008F8F; + color: #F7F6CF; +} + +QHeaderView::down-arrow { + padding-right: 4px; + height: 10px; + width: 10px; +} + +/* Context Menus, Toolbar Dropdowns, & Tooltips */ + +QMenuBar { + background: #DBD399; + border: 1px solid #DBD399; +} + +QMenuBar::item:selected { + background: #008F8F; + color: #F7F6CF; +} + +QMenu { + background: #F7F6CF; + selection-color: #F7F6CF; + border: 0px; +} + +QMenu::item { + background: #F7F6CF; + selection-background-color: #008F8F; + padding: 4px 20px; +} + +QMenu::item:selected { + background: #008F8F; + color: #F7F6CF; +} + +QMenu::item:disabled { + background: #F7F6CF; + color: #444444; +} + +QMenu::separator { + background: #DBD399; + height: 2px; +} + +QMenu::icon { + margin: 1px; +} + +QToolTip { + background: #F7F6CF; + color: #000000; + padding: 1px; + border: 0px; +} + +QStatusBar::item {border: None;} + +/* Progress Bars (Downloads) */ + +QProgressBar { + background: #F7F6CF; + text-align: center; + border: 0px; + border-radius: 6px; + margin: 0px 10px; +} + +QProgressBar::chunk { + background: #008F8F; +} + +/* Right Pane and Tab Bars */ + +QTabWidget::pane { + top: 1px; + padding: 2px 2px 10px 2px; + border: 2px solid #F7F6CF; + border-radius: 10px; +} + +QTabWidget::tab-bar { + alignment: center; +} + +QTabBar::tab { + background: #F7F6CF; + color: #000000; + padding: 4px 1em; + border: 1px solid #DBD399; + border-top: 0px; + border-bottom: 0px; +} + +QTabBar::tab:!selected { + background: #F7F6CF; + color: #000000; +} + +QTabBar::tab:disabled { + background: #DBD399; + color: #444444; +} + +QTabBar::tab:selected { + color: #0CA6FF; + +} + +QTabBar::tab:!selected:hover { + background: #0CA6FF; + color: #F7F6CF; +} + +QTabBar::tab:first { + border-top-left-radius: 10px; + border-bottom-left-radius: 10px; +} + +QTabBar::tab:last { + border-top-right-radius: 10px; + border-bottom-right-radius: 10px; +} + +QTabBar QToolButton { + background: #008F8F; + padding: 1px; + border-radius: 6px; + margin: 1px; +} + +QTabBar QToolButton:disabled { + background: transparent; +} + +/* Sliders (Configurator) */ + +/* QSlider::groove:horizontal { + background: #F7F6CF; + height: 1px; + border: 1px solid #F7F6CF; +} + +QSlider::handle:horizontal { + background: #008F8F; + width: 10px; + border: 2px solid #008F8F; + border-radius: 6px; + margin: -10px 0px; +} + +QSlider::handle:horizontal:hover { + background: #0CA6FF; + border: 2px solid #0CA6FF; +} */ + +/* Tables (Configure Mod Categories) */ + +QTableView { + gridline-color: #DBD399; + border: 0px; +} + +QLineEdit[valid-filter=false] { + background-color: #661111 !important; +} diff --git a/src/src/stylesheets/Parchment/checkbox-alt-checked.png b/src/src/stylesheets/Parchment/checkbox-alt-checked.png new file mode 100644 index 0000000..b2f3295 Binary files /dev/null and b/src/src/stylesheets/Parchment/checkbox-alt-checked.png differ diff --git a/src/src/stylesheets/Parchment/checkbox-alt-unchecked-hover.png b/src/src/stylesheets/Parchment/checkbox-alt-unchecked-hover.png new file mode 100644 index 0000000..bf4cb02 Binary files /dev/null and b/src/src/stylesheets/Parchment/checkbox-alt-unchecked-hover.png differ diff --git a/src/src/stylesheets/Parchment/checkbox-alt-unchecked.png b/src/src/stylesheets/Parchment/checkbox-alt-unchecked.png new file mode 100644 index 0000000..35073ad Binary files /dev/null and b/src/src/stylesheets/Parchment/checkbox-alt-unchecked.png differ diff --git a/src/src/stylesheets/Parchment/checkbox-checked-disabled.png b/src/src/stylesheets/Parchment/checkbox-checked-disabled.png new file mode 100644 index 0000000..21e7e05 Binary files /dev/null and b/src/src/stylesheets/Parchment/checkbox-checked-disabled.png differ diff --git a/src/src/stylesheets/Parchment/checkbox-checked-hover.png b/src/src/stylesheets/Parchment/checkbox-checked-hover.png new file mode 100644 index 0000000..6603a99 Binary files /dev/null and b/src/src/stylesheets/Parchment/checkbox-checked-hover.png differ diff --git a/src/src/stylesheets/Parchment/checkbox-checked.png b/src/src/stylesheets/Parchment/checkbox-checked.png new file mode 100644 index 0000000..0f09813 Binary files /dev/null and b/src/src/stylesheets/Parchment/checkbox-checked.png differ diff --git a/src/src/stylesheets/Parchment/checkbox-disabled.png b/src/src/stylesheets/Parchment/checkbox-disabled.png new file mode 100644 index 0000000..d1d5a88 Binary files /dev/null and b/src/src/stylesheets/Parchment/checkbox-disabled.png differ diff --git a/src/src/stylesheets/Parchment/checkbox-hover.png b/src/src/stylesheets/Parchment/checkbox-hover.png new file mode 100644 index 0000000..92df6af Binary files /dev/null and b/src/src/stylesheets/Parchment/checkbox-hover.png differ diff --git a/src/src/stylesheets/Parchment/checkbox.png b/src/src/stylesheets/Parchment/checkbox.png new file mode 100644 index 0000000..e02aa21 Binary files /dev/null and b/src/src/stylesheets/Parchment/checkbox.png differ diff --git a/src/src/stylesheets/dark.qss b/src/src/stylesheets/dark.qss new file mode 100644 index 0000000..b7a185e --- /dev/null +++ b/src/src/stylesheets/dark.qss @@ -0,0 +1,395 @@ +QToolTip +{ + border: 1px solid black; + color: #D9E6EA; + background-color: #2F3031; + padding: 1px; + border-radius: 3px; + opacity: 255; +} + +QWidget +{ + color: #E9E6E4; + background-color: #2F3031; +} + +QWidget:disabled +{ + color: #757676; + background-color: #292A2B; +} + +QAbstractItemView +{ + background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #484F53, stop: 0.7 #656666, stop: 1 #484F53); +} + +QLineEdit +{ + background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #2D3330, stop: 0.9 #484F53, stop: 1 #2D3330); + padding: 1px; + border-style: solid; + border: 1px solid #1e1e1e; + border-radius: 5; +} + +QPushButton +{ + color: #D9E6EA; + background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #697670, stop: 1 #484F53); + border-width: 2px; + border-color: #1F2021; + border-style: solid; + border-radius: 6; + padding: 3px; + padding-left: 15px; + padding-right: 15px; +} + +QPushButton:pressed +{ + background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #484F53, stop: 1 #697670); +} + +QPushButton:checked +{ + border-width: 1px; + border-color: #3EA0CA; +} + +QComboBox +{ + selection-background-color: #D9E6EA; + background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #9AA6A4, stop: 1 #484F53); + border: 2px solid #1D2320; + height: 20px; + border-radius: 5px; +} + +QComboBox:hover,QPushButton:hover +{ + border: 2px solid #3EA0CA; +} + +QComboBox:on +{ + padding-top: 3px; + padding-left: 4px; + background-color: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #697670, stop: 1 #484F53); + selection-background-color: #80B5C3; +} + +QComboBox::drop-down +{ + subcontrol-origin: padding; + subcontrol-position: top right; + width: 15px; + + border-left-width: 0px; + border-left-color: darkgray; + border-left-style: solid; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} + +QComboBox::down-arrow +{ + image: url(:/stylesheet/combobox-down.png); +} + +QScrollBar:horizontal +{ + border: 1px solid #1F2021; + background: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0.0 #1D2320, stop: 0.2 #1F2021, stop: 1 #484F53); + height: 14px; + margin: 1px 16px 1px 16px; +} + +QScrollBar::handle:horizontal +{ + background: QLinearGradient( x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #3EA0CA, stop: 0.5 #427683, stop: 1 #3EA0CA); + min-height: 20px; + border-radius: 4px; +} + +QScrollBar::add-line:horizontal +{ + border: 1px solid #1b1b19; + border-radius: 2px; + background: QLinearGradient( x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #3EA0CA, stop: 1 #427683); + width: 14px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal +{ + border: 1px solid #1b1b19; + border-radius: 2px; + background: QLinearGradient( x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #3EA0CA, stop: 1 #427683); + width: 14px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +QScrollBar::right-arrow:horizontal, QScrollBar::left-arrow:horizontal +{ + border: 1px solid black; + width: 1px; + height: 1px; + background: white; +} + +QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal +{ + background: none; +} + +QScrollBar:vertical +{ + border: 1px solid #1F2021; + background: QLinearGradient( x1: 0, y1: 0, x2: 1, y2: 0, stop: 0.0 #1D2320, stop: 0.2 #1F2021, stop: 1 #484F53); + width: 14px; + margin: 16px 1px 16px 1px; +} + +QScrollBar::handle:vertical +{ + background: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #3EA0CA, stop: 0.5 #427683, stop: 1 #3EA0CA); + min-height: 20px; + border-radius: 4px; +} + +QScrollBar::add-line:vertical +{ + border: 1px solid #1b1b19; + border-radius: 2px; + background: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #3EA0CA, stop: 1 #427683); + height: 14px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:vertical +{ + border: 1px solid #1b1b19; + border-radius: 2px; + background: QLinearGradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #3EA0CA, stop: 1 #427683); + height: 14px; + subcontrol-position: top; + subcontrol-origin: margin; +} + +QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical +{ + border: 1px solid black; + width: 1px; + height: 1px; + background: white; +} + +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical +{ + background: none; +} + +QTextEdit +{ + background-color: #484F53; +} + +QPlainTextEdit +{ + background-color: #484F53; +} + +QWebView +{ + background-color: #484F53; +} + +QHeaderView::section +{ + background-color: QLinearGradient(x1:0, y1:0, x2:0, y2:1, stop:0 #484F53, stop:0.5 #757676, stop:1 #484F53); + color: white; + padding-left: 4px; + border: 1px solid #2D3330; + border-radius: 2px; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; +} + +QCheckBox:disabled +{ + color: #414141; +} + +QMenu::separator +{ + height: 2px; + background-color: #484F53; + color: white; + padding-left: 4px; + margin-left: 10px; + margin-right: 5px; +} + +QMenu::item +{ + padding: 2px 25px 2px 20px; + border: 1px solid transparent; +} + +QMenu::item:selected +{ + background-color: #3c4b54; + border-color: #3EA0CA; +} + +QMenuBar::item:selected { + background-color: #3c4b54; + border-color: #3EA0CA; +} + +QStatusBar::item {border: None;} + +QProgressBar +{ + border: 2px solid grey; + border-radius: 5px; + text-align: center; +} + +QProgressBar::chunk +{ + background-color: #427683; +} + +QTabBar::tab +{ + color: #E9E6E4; + border: 1px solid #444; + border-bottom-style: none; + background-color: QLinearGradient(x1:0, y1:0, x2:0, y2:1, stop:0.8 #1D2320, stop:0.2 #757676); + padding-left: 10px; + padding-right: 10px; + padding-top: 3px; + padding-bottom: 2px; + margin-right: -1px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +QTabWidget::pane +{ + border: 1px solid #444; + top: 1px; +} + +QTabBar::tab:last +{ + margin-right: 0px; +} + +QTabBar::tab:first +{ + margin-left: 0px; +} + +QTabBar::tab:!selected +{ + color: #E9E6E4; + border-bottom-style: solid; + margin-top: 3px; + background-color: QLinearGradient(x1:0, y1:0, x2:0, y2:1, stop:0.8 #1D2320, stop:0.4 #484F53); +} + +QTabBar::tab:disabled +{ + color: #757676; + border-bottom-style: solid; + margin-top: 3px; + background-color: #484F53; +} +QTabBar::tab:selected +{ + border-top-left-radius: 3px; + border-top-right-radius: 3px; + margin-bottom: 0px; +} + +QTabBar::tab:!selected:hover +{ + border-top-left-radius: 6px; + border-top-right-radius: 6px; + background-color: QLinearGradient(x1:0, y1:0, x2:0, y2:1, stop:1 #212121, stop:0.4 #343434, stop:0.2 #343434, stop:0.1 #3EA0CA); +} + +QToolButton +{ + border:2px ridge #757676; + border-radius: 6px; + margin: 3px; + padding-left: 8px; + padding-right: 8px; + padding-top: 0px; + padding-bottom: 2px; +} + +QToolButton:hover +{ + border: 2px ridge #757676; + background-color: #484F53; + border-radius: 6px; + margin: 3px; + padding-left: 8px; + padding-right: 8px; + padding-top: 0px; + padding-bottom: 2px; +} + +QTreeView, QListView + { + color: #E9E6E4; + background-color: #3F4041; + alternate-background-color: #2F3031; +} + +QAbstractItemView[filtered=true] { + border: 2px solid #f00 !important; +} + +QTreeView::branch:has-children:!has-siblings:closed, +QTreeView::branch:closed:has-children:has-siblings +{ + border-image: none; + image: url(:/stylesheet/branch-closed.png); +} + +QTreeView::branch:open:has-children:!has-siblings, +QTreeView::branch:open:has-children:has-siblings +{ + border-image: none; + image: url(:/stylesheet/branch-open.png); +} + +DownloadListView QLabel#installLabel { + color: none; +} + +DownloadListView[downloadView=standard]::item { + padding: 16px; +} + +DownloadListView[downloadView=compact]::item { + padding: 4px; +} + +LinkLabel { + qproperty-linkColor: #3399FF; +} + +QLineEdit[valid-filter=false] { + background-color: #661111 !important; +} diff --git a/src/src/stylesheets/dracula.qss b/src/src/stylesheets/dracula.qss new file mode 100644 index 0000000..a3197f6 --- /dev/null +++ b/src/src/stylesheets/dracula.qss @@ -0,0 +1,445 @@ +/* + * Drop background color of most widgets + */ + +QWidget, QStackedWidget, +QScrollArea, QAbstractScrollArea { + background-color: #3c3f41; + color: #bbbbbb; + } + +QWidget:disabled { + color: #808080; +} + +QApplication, +QMainWindow, QFrame, QDockWidget, QDialog, QFileDialog, QMessageBox, +QRadioButton, +QGroupBox::title, +QMenuBar, QMenu, QMenu::item, +QComboBox QAbstractItemView, +QTreeView, QListWidget, QListView, QTableWidget, QTableView, +QTextEdit, QTextBrowser { + background-color: #3c3f41; +} + + +QLabel, QTabBar::tab, QMenuBar::item, +QCheckBox, QGroupBox { + background-color: transparent; + selection-color: #bbbbbb; +} + +LinkLabel { + qproperty-linkColor: #3399FF; +} + +/* + * GroupBox and CheckBox + */ +QGroupBox { + border-top: 1px solid #2d2d2d; + margin-top: 0.5em; +} +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 0 3px; +} +QCheckBox::indicator, +QGroupBox::indicator { + width: 13px; + height: 13px; +} + +QCheckBox::indicator:indeterminate:hover, +QGroupBox::indicator:indeterminate:hover { + background-color: #2222bb; +} +QCheckBox::indicator:indeterminate:pressed, +QGroupBox::indicator:indeterminate:pressed { + background-color: #22bb22; +} + +QLineEdit, QAbstractSpinBox, QComboBox, QPushButton { + background-color: #45494a; + + background-origin: border; + border: 1px solid #646464; + color: #bbbbbb; + padding: 0.1em 0.5em 0.1em 0.5em; + selection-background-color: #4b6eaf; +} +:read-only { + background-color: transparent; + border: 1px solid #535353; + color: #999999; +} +:no-frame { + border-width: 0; +} + +/* + * PushButton + * + */ + +QPushButton { + outline: 0; +} +QPushButton { + background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #515658, stop: 1.0 #44494b); + border: 1px solid #5f6161; + border-radius: 2px; + margin: 1px; + padding: 0.2em 0.8em 0.2em 0.8em; +} +QPushButton:hover { + background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop:0 #64686b, stop:1 #494c4e); +} +QPushButton:pressed { + border-style: inset; +} +QPushButton:default { + background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #344a64, stop: 1.0 #263549); +} +QPushButton:disabled { + background-color: transparent; + color: #999999; +} +QPushButton:open { + background-color: #4b6eaf; +} +QPushButton::menu-indicator { + subcontrol-origin: padding; + subcontrol-position: bottom right; +} + +/* + * ComboBox + */ +QComboBox { + border-radius: 2px; + min-width: 3em; + padding: 0px 1px 2px 3px; +} +QComboBox:!editable { + background-color: rgba(255, 255, 255, 0.05); +} +QComboBox::drop-down { + border-left: 1px solid #646464; +} + +QComboBox::down-arrow +{ + image: url(:/stylesheet/combobox-down.png); +} + +QComboBox QAbstractItemView { + border: 1px solid red; + selection-background-color: #d2d2d2; + selection-color: #404040; + show-decoration-selected: 1; +} + + +/* + * TabWidget and TabBar + */ +QTabWidget::pane { + background-color: transparent; + border: 1px solid #323232; +} +QTabWidget::pane:top { + top: -1px; +} +QTabWidget::pane:right { + right: -1px; +} +QTabWidget::pane:bottom { + bottom: -1px; +} +QTabWidget::pane:left { + left: -1px; +} +QTabBar::tab { + border: 1px solid #292b2d; + padding: 0.2em 0.8em 0.2em 0.8em; +} +QTabBar::tab:top { + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} +QTabBar::tab:right { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} +QTabBar::tab:bottom { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} +QTabBar::tab:left { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; +} +QTabBar::tab:selected { + background-color: #41516d; +} +QTabBar::tab:top:selected { + border-bottom-color: #41516d; +} +QTabBar::tab:right:selected { + border-left-color: #41516d; +} +QTabBar::tab:bottom:selected { + border-top-color: #41516d; +} +QTabBar::tab:left:selected { + border-right-color: #41516d; +} +QTabBar::tab:top:!selected { + margin-top: 2px; +} +QTabBar::tab:right:!selected { + margin-right: 2px; +} +QTabBar::tab:bottom:!selected { + margin-bottom: 2px; +} +QTabBar::tab:left:!selected { + margin-left: 2px; +} + +/* + * Table + */ + +QTableWidget, +QTableView { + border: 1px solid #323232; + alternate-background-color: #414547; +} +QHeaderView::section { + background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop:0 #54585b, stop:1 #393c3e); + color: #bbbbbb; + padding: 1px 2px 1px 4px; + + border: 1px solid #323232; + border-top-width: 0; + border-left-color: #5e6163; + border-right-color: #2a2c2d; +} +QHeaderView::section:hover { + background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop:0 #64686b, stop:1 #494c4e); + border-bottom-color: #424242; +} +QHeaderView::section:first { + border-left-width: 0; +} +QHeaderView::section:last { + border-right-width: 0; +} + +/* + * Menu and MenuBar + */ +QMenuBar { + border-bottom: 2px solid qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop:0 #282828, stop:1 #555555); +} +QMenuBar::item:pressed { + background-color: #4b6eaf; +} +QMenuBar::item:selected:!pressed { + background-color: #585b5d; +} +QMenu { + border: 1px solid #2d2d2d; +} +QMenu::item:disabled { + color: #999999; +} +QMenu::item:selected { + background-color: #4b6eaf; +} +QMenu::icon { + border: 0px solid transparent; + background-color: transparent; +} +QMenu::icon:checked { + background-color: blue; + border: 1px inset red; + position: absolute; + top: 1px; + right: 1px; + bottom: 1px; + left: 1px; +} +QMenu::separator { + height: 2px; + background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop:0 #282a2b, stop:1 #45484b); + margin: 0 1px; +} + + +/* + * Scroll bar + */ +QScrollBar:vertical { + background-color: transparent; + margin: 0; + height: 1px; + width: 12px; +} +QScrollBar:horizontal { + background-color: transparent; + margin: 0; + height: 12px; + width: 1px; +} +QScrollBar::handle { + border: 1px solid #555555; + border-radius: 4px; + margin: 1px; +} +QScrollBar::handle:vertical { + background-color: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, stop:0 #5c5c5c, stop:1 #515151); + min-height: 20px; + min-width: 8px; +} +QScrollBar::handle:vertical:hover { + background-color: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, stop:0 #666666, stop:1 #5b5b5b); +} +QScrollBar::handle:horizontal { + background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop:0 #5c5c5c, stop:1 #515151); + min-height: 8px; + min-width: 20px; +} +QScrollBar::handle:horizontal:hover { + background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop:0 #666666, stop:1 #5b5b5b); +} +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical, +QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { + background-color: transparent; +} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical, +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + height: 0; + width: 0; +} + + +/* + * QAbstractItemView modifications + */ +QAbstractItemView { + border: 1px solid #323232; + selection-color: #bbbbbb; + selection-background-color: #4b6eaf; + alternate-background-color: #414547; + show-decoration-selected: 1; + outline: 0; +} +QListWidgetItem, +QTreeWidgetItem { + background-color: red; + color: yellow; + border: 1px solid purple; +} +QAbstractItemView::text { + color: #bbbbbb; +} +QTreeView::branch:hover, +QAbstractItemView::item:hover { + background-color: rgba(255, 255, 255, 0.05); +} +QTreeView::branch:selected:active, +QAbstractItemView::item:selected { + background-color: #4b6eaf; +} + +QTreeView::branch:has-siblings:adjoins-item, +QTreeView::branch:has-siblings:!adjoins-item, +QTreeView::branch:!has-children:!has-siblings:adjoins-item { + border-image: none; +} +QTreeView::branch:has-children:!has-siblings:closed, +QTreeView::branch:closed:has-children:has-siblings { + border-image: none; + image: url(:/stylesheet/branch-closed.png); +} +QTreeView::branch:open:has-children:!has-siblings, +QTreeView::branch:open:has-children:has-siblings { + border-image: none; + image: url(:/stylesheet/branch-open.png); +} + +QAbstractItemView[filtered=true] { + border: 2px solid #f00 !important; +} + +/* + * Tool bar + */ + +QToolBar { + border-width: 0; +} + +/* + * ToolTip + */ + +QToolTip, QTipLabel { + border: 1px solid #9a9a66; + border-radius: 3px; + background: #5c5c42; + color: #bbbbbb; + margin: 0; + padding: 0; +} + +SaveGameInfoWidget { + background-color: qlineargradient(x1: 0, y1: 0.6, x2: 0, y2: 1, stop: 0 rgba(52,74,100,100%), stop: 1.0 rgba(38,53,73,10%)); +} + +/* + * Downloads + */ + + DownloadListView QLabel#installLabel { + color: none; +} + +DownloadListView[downloadView=standard]::item { + padding: 16px; +} + +DownloadListView[downloadView=compact]::item { + padding: 4px; +} + +DownloadListView::item:hover { + padding: 0px; +} + +DownloadListView::item:selected { + padding: 0px; +} + +QStatusBar::item {border: None;} + +QProgressBar +{ + border: 2px solid grey; + border-radius: 5px; + text-align: center; +} + +QProgressBar::chunk +{ + background-color: #427683; +} + +QLineEdit[valid-filter=false] { + background-color: #661111 !important; +} diff --git a/src/src/stylesheets/skyrim.qss b/src/src/stylesheets/skyrim.qss new file mode 100644 index 0000000..71b9360 --- /dev/null +++ b/src/src/stylesheets/skyrim.qss @@ -0,0 +1,670 @@ +/*!************************************* + Skyrim +**************************************** + Author: chintsu_kun + Version: 2.1.0 + Licence: GNU General Public License v3.0 (https://www.gnu.org/licenses/gpl-3.0.en.html) + Url: https://github.com/nikolay-borzov/modorganizer-themes +**************************************** +*/ +/* For some reason applying background-color or border fixes paddings properties */ +QListWidget::item { + border-width: 0; } + +/* Don't override install label on download widget. + MO2 assigns color depending on download state */ +#installLabel { + color: none; } + +/* Make `background-color` work for :hover, :focus and :pressed states */ +QToolButton { + border: none; } + +QWidget { + background-color: #000; + color: #F1F1F1; } + +* { + /* Remove outline */ + outline: 0; + /* Text highlight */ + selection-background-color: #000; + selection-color: white; } + +/* Disabled text */ +*:disabled, +QListView::item:disabled, +*::item:selected:disabled { + color: #656565; } + +/* Line heights */ +QListView::item, +QTreeWidget::item, +QTreeView::item { + padding: 0.3em 0; } + +/* Increase row height to fit inputs */ +#modList::item, +#settingsTree::item, +#pluginSettingsList::item, +#espList::item, +#savegameList::item { + min-height: 34px; + padding: 0; } + #modList::item QComboBox, + #modList::item QLineEdit, + #modList::item QComboBox:editable, + #modList::item QAbstractSpinBox, + #settingsTree::item QComboBox, + #settingsTree::item QLineEdit, + #settingsTree::item QComboBox:editable, + #settingsTree::item QAbstractSpinBox, + #pluginSettingsList::item QComboBox, + #pluginSettingsList::item QLineEdit, + #pluginSettingsList::item QComboBox:editable, + #pluginSettingsList::item QAbstractSpinBox, + #espList::item QComboBox, + #espList::item QLineEdit, + #espList::item QComboBox:editable, + #espList::item QAbstractSpinBox { + margin: 2px; } + +QAbstractItemView { + color: #C0C0C0; + background-color: #000; + alternate-background-color: #000; + border-color: #999; } + +QAbstractItemView, +QListView, +QTreeView { + show-decoration-selected: 1; } + +QAbstractItemView[filtered=true] { + background-color: #f00 !important; +} + +QAbstractItemView::item:hover, +QListView::item:hover, +QTreeView::branch:hover, +QTreeWidget::item:hover { + color: #fff; + background-color: #121212; } + +QAbstractItemView::item:selected:disabled, +QListView::item:selected:disabled, +QTreeView::branch:selected:disabled, +QTreeWidget::item:selected:disabled { + background-color: #121212; } + +QAbstractItemView::item:selected { + background-color: #5F5F5E; + color: #fff; } + +/* Trees and Lists #QTreeView, #QListView*/ +QTreeView::branch:selected { + background-color: #5F5F5E; } + +QTreeView::branch:closed:has-children { + image: url(./skyrim/arrow-right.png); } + +QTreeView::branch:open:has-children { + image: url(./skyrim/branch-opened.png); } + +QListView::item { + color: #C0C0C0; } + +/* Text areas and text fields #QTextEdit, #QLineEdit, #QWebView */ +QLabel { + background-color: transparent; } + +LinkLabel { + qproperty-linkColor: #3399FF; } + +/* Font size */ +QLabel, +QTextEdit, +QLineEdit, +QWebView, +QComboBox, +QComboBox:editable, +QAbstractSpinBox, +QGroupBox, +QCheckBox, +QRadioButton { + font-size: 14px; } + +/* Inputs */ +QTextEdit, +QLineEdit, +QWebView, +QComboBox, +QComboBox:editable, +QAbstractSpinBox { + min-height: 24px; + border: none; + background-color: #141414; + padding: 3px 5px; } + QTextEdit:hover, QTextEdit:focus, + QLineEdit:hover, + QLineEdit:focus, + QWebView:hover, + QWebView:focus, + QComboBox:hover, + QComboBox:focus, + QComboBox:editable:hover, + QComboBox:editable:focus, + QAbstractSpinBox:hover, + QAbstractSpinBox:focus { + background-color: #212121; } + +QLineEdit { + margin-top: 0; + /* Clear button */ + /* Increase version text field width */ + /* Fix margins on tabs pane */ } + QLineEdit QToolButton, + QLineEdit QToolButton:hover { + background: none; + margin-top: 1px; } + QLineEdit#espFilterEdit QToolButton { + margin-top: -2px; + margin-bottom: 1px; } + QLineEdit#modFilterEdit { + margin-top: 2px; } + QLineEdit#versionEdit { + max-width: 90px; } + QLineEdit#espFilterEdit { + margin: 0 0 6px 0; } + QLineEdit#downloadView { + margin: 4px 4px 0 4px; } + QLineEdit#showHiddenBox { + margin: 0 0 4px 4px; } + QLineEdit#downloadFilterEdit { + margin: 0 4px 4px 0; } + +/* Drop-downs #QComboBox*/ +QComboBox { + padding-left: 5px; + margin: 3px 0 1px 0; } + QComboBox:on { + background-color: #212121; } + QComboBox:editable { + padding-left: 3px; } + QComboBox::drop-down { + width: 20px; + subcontrol-origin: padding; + subcontrol-position: top right; + border: none; } + QComboBox::down-arrow { + image: url(./skyrim/arrow-down.png); } + QComboBox QAbstractItemView { + background-color: #000; + selection-background-color: #212121; } + +/* Toolbar */ +QToolBar { + border: none; } + QToolBar::separator { + image: url(./skyrim/separator.png); } + +QToolButton { + margin: 0 4px 0 4px; + padding: 5px; + /* Scrollers */ } + QToolButton:hover, QToolButton:focus { + background-color: #121212; } + QToolButton:pressed { + background-color: #5F5F5E; } + QToolButton::menu-indicator { + image: url(./skyrim/arrow-down.png); + margin: 0 4px 4px 0; } + QToolButton::menu-indicator:pressed, QToolButton::menu-indicator:open { + image: url(./skyrim/arrow-right.png); } + QTabBar QToolButton { + background-color: #000; + padding: 1px; + margin: 0; } + QTabBar QToolButton::right-arrow { + image: url(./skyrim/arrow-right.png); } + QTabBar QToolButton::left-arrow { + image: url(./skyrim/arrow-left.png); } + +/* Group Boxes #QGroupBox */ +QGroupBox { + padding: 1em .3em .3em .3em; + margin-top: .65em; + border: 1px solid #999; } + QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 5px; + left: 10px; } + +/* LCD Count */ +QLCDNumber { + border-color: #999; + border-style: solid; + border-width: 1px; } + +/* Buttons #QPushButton */ +QPushButton { + min-height: 18px; + padding: 2px 5px; + border-style: solid; + background-color: transparent; + border-width: 0 15px 0 15px; + border-color: transparent; + /* Dialog buttons */ + /* Show categories */ + /* Run */ + /* Mod info dialog images tab */ } + QPushButton:hover, QPushButton:checked:hover { + border-image: url(./skyrim/button-border.png) 0 15 no-repeat; } + QPushButton:checked { + border-image: url(./skyrim/button-checked-border.png) 0 15 repeat; } + QPushButton:disabled { + color: #656565; } + QPushButton::menu-indicator { + image: url(./skyrim/arrow-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 5%; } + QDialog QPushButton { + font-weight: bold; + font-size: 15px; + min-width: 1.5em; + padding-left: .5em; + padding-right: .5em; } + QPushButton#displayCategoriesBtn { + min-width: 20px; } + QPushButton#startButton:hover { + border-image: url(./skyrim/button-big-border.png) 0 15 repeat; } + QPushButton#startButton:checked { + border-image: url(./skyrim/button-big-border.png) 0 15 repeat; } + QPushButton#unlockButton { + margin: 10px 20px; } + QPushButton + QPushButton { + margin: 10px 0 0 0; } + #tabImages QPushButton { + background-color: transparent; + border: none; + margin: 0 .3em; + padding: 0; } + #tabImages QPushButton:hover { + background-color: #121212; } + #settingsTree::item QPushButton { + border-image: none; } + +/* Check boxes and Radio buttons common #QCheckBox, #QRadioButton */ +QGroupBox::indicator, +QTreeView::indicator, +QCheckBox::indicator { + background-color: transparent; + border: none; + width: 20px; + height: 20px; } + QGroupBox::indicator:checked, QGroupBox::indicator:indeterminate, + QTreeView::indicator:checked, + QTreeView::indicator:indeterminate, + QCheckBox::indicator:checked, + QCheckBox::indicator:indeterminate { + image: url(./skyrim/checkbox-checked.png); } + QGroupBox::indicator:checked:hover, QGroupBox::indicator:indeterminate:hover, + QTreeView::indicator:checked:hover, + QTreeView::indicator:indeterminate:hover, + QCheckBox::indicator:checked:hover, + QCheckBox::indicator:indeterminate:hover { + image: url(./skyrim/checkbox-checked-hover.png); } + QGroupBox::indicator:checked:disabled, QGroupBox::indicator:indeterminate:disabled, + QTreeView::indicator:checked:disabled, + QTreeView::indicator:indeterminate:disabled, + QCheckBox::indicator:checked:disabled, + QCheckBox::indicator:indeterminate:disabled { + image: url(./skyrim/checkbox-checked-disabled.png); } + QGroupBox::indicator:unchecked, + QTreeView::indicator:unchecked, + QCheckBox::indicator:unchecked { + image: url(./skyrim/checkbox.png); } + QGroupBox::indicator:unchecked:hover, + QTreeView::indicator:unchecked:hover, + QCheckBox::indicator:unchecked:hover { + image: url(./skyrim/checkbox-hover.png); } + QGroupBox::indicator:unchecked:disabled, + QTreeView::indicator:unchecked:disabled, + QCheckBox::indicator:unchecked:disabled { + image: url(./skyrim/checkbox-disabled.png); } + +/* Check boxes special */ +QTreeWidget#bsaList::indicator { + width: 10px; + height: 15px; } + QTreeWidget#bsaList::indicator:checked { + image: url(./skyrim/checkbox-alt-checked.png); } + QTreeWidget#bsaList::indicator:unchecked { + image: url(./skyrim/checkbox-alt-unchecked.png); } + QTreeWidget#bsaList::indicator:unchecked:hover { + image: url(./skyrim/checkbox-alt-unchecked-hover.png); } + +/* Radio buttons #QRadioButton */ +QRadioButton::indicator { + width: 16px; + height: 16px; } + QRadioButton::indicator::checked { + image: url(./skyrim/radio-checked.png); } + QRadioButton::indicator::unchecked { + image: url(./skyrim/radio.png); } + QRadioButton::indicator::unchecked:hover { + image: url(./skyrim/radio-hover.png); } + +/* Spinners #QSpinBox, #QDoubleSpinBox */ +QAbstractSpinBox { + margin: 1px; } + QAbstractSpinBox::up-button, QAbstractSpinBox::down-button { + border-style: solid; + border-width: 1px; + subcontrol-origin: padding; } + QAbstractSpinBox::up-button:hover, QAbstractSpinBox::down-button:hover { + background-color: #141414; } + QAbstractSpinBox::up-button { + subcontrol-position: top right; } + QAbstractSpinBox::down-button { + subcontrol-position: bottom right; } + QAbstractSpinBox::up-arrow { + image: url(./skyrim/arrow-up.png); } + QAbstractSpinBox::down-arrow { + image: url(./skyrim/arrow-down.png); } + +/* Scroll Bars #QAbstractScrollArea, #QScrollBar*/ +/* Assigning background still leaves not filled area */ +QAbstractScrollArea::corner { + background-color: transparent; } + +QScrollBar { + background-color: transparent; + border: none; + /* Since QTextEdit:hover doesn't work set focus input background */ } + QScrollBar:horizontal { + height: 12px; + border: none; + margin: 0 17px 0 17px; } + QScrollBar:vertical { + width: 12px; + border: none; + margin: 17px 0 17px 0; } + QScrollBar::handle { + border: 1px solid rgba(243, 243, 243, 0.88); + background-color: rgba(255, 255, 255, 0.6); } + QScrollBar::handle:horizontal { + min-width: 32px; + margin: 1px 0 1px 0; } + QScrollBar::handle:vertical { + min-height: 32px; + margin: 0 1px 0 1px; } + QScrollBar::add-line, QScrollBar::sub-line { + background-color: transparent; + border: none; + subcontrol-origin: margin; } + QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + width: 17px; } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 17px; } + QScrollBar::add-line:horizontal { + subcontrol-position: right; } + QScrollBar::add-line:vertical { + subcontrol-position: bottom; } + QScrollBar::sub-line:horizontal { + subcontrol-position: left; } + QScrollBar::sub-line:vertical { + subcontrol-position: top; } + QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { + background: url(./skyrim/scrollbar-horizontal.png) center center repeat-x; } + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { + background: url(./skyrim/scrollbar-vertical.png) center center repeat-y; } + QScrollBar::up-arrow:vertical { + image: url(./skyrim/scrollbar-up.png); } + QScrollBar::down-arrow:vertical { + image: url(./skyrim/scrollbar-down.png); } + QScrollBar::right-arrow:horizontal { + image: url(./skyrim/scrollbar-right.png); } + QScrollBar::left-arrow:horizontal { + image: url(./skyrim/scrollbar-left.png); } + QTextEdit QScrollBar::up-arrow:vertical, + QTextEdit QScrollBar::down-arrow:vertical, + QTextEdit QScrollBar::right-arrow:horizontal, + QTextEdit QScrollBar::left-arrow:horizontal { + background-color: #212121; } + +/* Scroll Area #QScrollArea*/ +QScrollArea { + border-color: transparent; + border-style: solid; + border-width: 1px; } + +/* Sliders #QSlider */ +QSlider::groove:horizontal { + background: url(./skyrim/scrollbar-horizontal.png) repeat-x; + border-style: solid; + border-width: 0 17px; + border-color: transparent; + height: 12px; + margin: 0; + border-image: url(./skyrim/slider-border.png) 0 17 round; } + +QSlider::handle:horizontal { + background: url(./skyrim/slider-handle.png); + width: 18px; + height: 20px; + margin: -4px 0; + subcontrol-origin: margin; } + +/* Header Rows and Tables (Configure Mod Categories) #QTableView, #QHeaderView */ +QTableView { + gridline-color: transparent; + selection-background-color: #5F5F5E; + selection-color: #C0C0C0; } + QTableView QTableCornerButton::section { + background-color: transparent; + border-color: #999; + border-style: solid; + border-width: 0 1px 1px 0; } + +QHeaderView { + border: none; } + QHeaderView::section { + background-color: transparent; + border-color: #999; + /* 17px to have some place for sort indicator */ + padding: 3px 17px 3px 5px; + border-style: solid; } + QHeaderView::section:horizontal { + border-width: 0 0 1px 0; } + QHeaderView::section:vertical { + border-width: 0 1px 0 0; } + QHeaderView::section:hover { + background: #121212; + color: #fff; } + QHeaderView::up-arrow, QHeaderView::down-arrow { + subcontrol-origin: content; + subcontrol-position: center right; + width: 7px; + height: 7px; + margin-right: 7px; } + QHeaderView::up-arrow { + image: url(./skyrim/arrow-up.png); } + QHeaderView::down-arrow { + image: url(./skyrim/arrow-down.png); } + +/* Context menus, toolbar drop-downs #QMenu */ +QMenuBar { + background-color: #000; +} + +QMenuBar::item:selected { + background-color: #121212; +} + +QMenu { + background-color: transparent; } + QMenu::item, + QMenu QPushButton { + padding: 6px 20px; } + QMenu::item:selected { + background-color: #121212; } + QMenu::item:disabled { + background-color: transparent; } + QMenu::separator { + height: 3px; + margin: 1px 0; + background: url(./skyrim/context-menu-separator.png) repear-x; } + QMenu::icon { + margin: 1px; } + QMenu::right-arrow { + image: url(./skyrim/arrow-right.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: .5em; } + QMenu QPushButton { + /* Cancel styles */ + border: none; + text-align: left; } + QMenu QPushButton:hover { + background-color: #121212; } + QMenu QCheckBox, + QMenu QRadioButton { + background-color: transparent; + padding: 5px 2px; } + +/* Tool tips #QToolTip, #SaveGameInfoWidget */ +QToolTip { + background-color: transparent; + color: #C0C0C0; + padding: 0; } + +SaveGameInfoWidget { + background-color: #121212; + color: #C0C0C0; } + +QStatusBar::item {border: None;} + +/* Progress Bars (Downloads) #QProgressBar */ +QProgressBar { + background-color: transparent; + color: transparent; + height: 14px; + margin: 0 0px; + border-width: 4px 21px; + border-style: solid; + border-color: transparent; + border-image: url(./skyrim/progress-bar-border.png) 4 21 repeat repeat; } + QProgressBar::chunk { + background: url(./skyrim/progress-bar-chunk.png) center center repeat-x qlineargradient(spread:pad, x1:0, y1:0, x2:1, y2:0, stop:0 #95BED9, stop:0.78781 #6EB9CE); } + +DownloadListView[downloadView=standard]::item { + padding: 15px; +} + +DownloadListView[downloadView=compact]::item { + padding: 4px; +} + +/* Right Pane and Tab Bars #QTabWidget, #QTabBar */ +QTabWidget::pane { + border: none; + top: 0; } + +QTabBar { + text-transform: uppercase; + max-height: 22px; } + QTabBar::tab { + color: #C0C0C0; + background-color: transparent; + padding: 4px .5em; + margin: 0 0 .3em 0; + border-style: solid; + border-width: 0 15px 0 15px; + border-color: transparent; } + QTabBar::tab:hover { + color: #fff; } + QTabBar::tab:selected { + color: #fff; + border-image: url(./skyrim/button-checked-border.png) 0 15 repeat; } + QTabBar::tab:disabled { + color: #656565; } + QTabBar::scroller { + width: 23px; } + +/* Border images */ +ModListView, +QTreeView, +ModListView#modList, +QTreeWidget, +QListView, +QTableView, +QDialog#LockedDialog { + border-width: 20px; + border-color: transparent; + border-image: url(./skyrim/border-image.png) 27 repeat repeat; } + +QMenu, +QToolTip { + border-width: 17px; + border-color: transparent; + border-image: url(./skyrim/border-image.png) 27 repeat repeat; } + +/* Special styles */ +/* Increase categories tab width */ +QTreeWidget#categoriesList { + min-width: 200px; } + QTreeWidget#categoriesList::item { + background-position: center left; + background-repeat: no-repeat; + padding: .35em 10px; } + QTreeWidget#categoriesList::item:has-children { + background-image: url(./skyrim/arrow-right.png); } + QTreeWidget#categoriesList::item:has-children:open { + background-image: url(./skyrim/branch-opened.png); } + +/* Add some space around buttons */ +#QueryOverwriteDialog QPushButton { + margin-left: 0.5em; } + +#SimpleInstallDialog QPushButton { + margin-top: 0.5em; } + +#ProfilesDialog QPushButton { + margin-bottom: 0.5em; } + +#ProfilesDialog QPushButton#closeButton { + margin-bottom: 0; } + +#EditExecutablesDialog QPushButton#addButton, +#EditExecutablesDialog QPushButton#removeButton { + margin-bottom: 0.5em; + margin-top: 1em; } + +/* Increase dialogs width to prevent buttons cutting */ +QDialog#QueryOverwriteDialog { + min-width: 565px; } + +QDialog#ModInfoDialog { + min-width: 940px; } + +QDialog#BainComplexInstallerDialog { + min-width: 500px; } + +QDialog#EditExecutablesDialog { + min-width: 500px; } + +QDialog#SettingsDialog { + min-width: 670px; } + +QDialog#CredentialsDialog { + min-width: 400px; } + +#pluginsList { + min-width: 260px; } + +QLineEdit[valid-filter=false] { + background-color: #661111 !important; +} diff --git a/src/src/stylesheets/skyrim/arrow-down.png b/src/src/stylesheets/skyrim/arrow-down.png new file mode 100644 index 0000000..276fd46 Binary files /dev/null and b/src/src/stylesheets/skyrim/arrow-down.png differ diff --git a/src/src/stylesheets/skyrim/arrow-left.png b/src/src/stylesheets/skyrim/arrow-left.png new file mode 100644 index 0000000..be70722 Binary files /dev/null and b/src/src/stylesheets/skyrim/arrow-left.png differ diff --git a/src/src/stylesheets/skyrim/arrow-right.png b/src/src/stylesheets/skyrim/arrow-right.png new file mode 100644 index 0000000..a5840a4 Binary files /dev/null and b/src/src/stylesheets/skyrim/arrow-right.png differ diff --git a/src/src/stylesheets/skyrim/arrow-up.png b/src/src/stylesheets/skyrim/arrow-up.png new file mode 100644 index 0000000..1e5e3f9 Binary files /dev/null and b/src/src/stylesheets/skyrim/arrow-up.png differ diff --git a/src/src/stylesheets/skyrim/border-image.png b/src/src/stylesheets/skyrim/border-image.png new file mode 100644 index 0000000..cd9450a Binary files /dev/null and b/src/src/stylesheets/skyrim/border-image.png differ diff --git a/src/src/stylesheets/skyrim/border-image1.png b/src/src/stylesheets/skyrim/border-image1.png new file mode 100644 index 0000000..df68a48 Binary files /dev/null and b/src/src/stylesheets/skyrim/border-image1.png differ diff --git a/src/src/stylesheets/skyrim/border-image2.png b/src/src/stylesheets/skyrim/border-image2.png new file mode 100644 index 0000000..cccf739 Binary files /dev/null and b/src/src/stylesheets/skyrim/border-image2.png differ diff --git a/src/src/stylesheets/skyrim/branch-opened.png b/src/src/stylesheets/skyrim/branch-opened.png new file mode 100644 index 0000000..8dc7d01 Binary files /dev/null and b/src/src/stylesheets/skyrim/branch-opened.png differ diff --git a/src/src/stylesheets/skyrim/button-big-border.png b/src/src/stylesheets/skyrim/button-big-border.png new file mode 100644 index 0000000..f5d0ec4 Binary files /dev/null and b/src/src/stylesheets/skyrim/button-big-border.png differ diff --git a/src/src/stylesheets/skyrim/button-border.png b/src/src/stylesheets/skyrim/button-border.png new file mode 100644 index 0000000..158890e Binary files /dev/null and b/src/src/stylesheets/skyrim/button-border.png differ diff --git a/src/src/stylesheets/skyrim/button-checked-border.png b/src/src/stylesheets/skyrim/button-checked-border.png new file mode 100644 index 0000000..7ece413 Binary files /dev/null and b/src/src/stylesheets/skyrim/button-checked-border.png differ diff --git a/src/src/stylesheets/skyrim/checkbox-alt-checked.png b/src/src/stylesheets/skyrim/checkbox-alt-checked.png new file mode 100644 index 0000000..3f4662c Binary files /dev/null and b/src/src/stylesheets/skyrim/checkbox-alt-checked.png differ diff --git a/src/src/stylesheets/skyrim/checkbox-alt-unchecked-hover.png b/src/src/stylesheets/skyrim/checkbox-alt-unchecked-hover.png new file mode 100644 index 0000000..69e11fd Binary files /dev/null and b/src/src/stylesheets/skyrim/checkbox-alt-unchecked-hover.png differ diff --git a/src/src/stylesheets/skyrim/checkbox-alt-unchecked.png b/src/src/stylesheets/skyrim/checkbox-alt-unchecked.png new file mode 100644 index 0000000..a9b5102 Binary files /dev/null and b/src/src/stylesheets/skyrim/checkbox-alt-unchecked.png differ diff --git a/src/src/stylesheets/skyrim/checkbox-checked-disabled.png b/src/src/stylesheets/skyrim/checkbox-checked-disabled.png new file mode 100644 index 0000000..c519263 Binary files /dev/null and b/src/src/stylesheets/skyrim/checkbox-checked-disabled.png differ diff --git a/src/src/stylesheets/skyrim/checkbox-checked-hover.png b/src/src/stylesheets/skyrim/checkbox-checked-hover.png new file mode 100644 index 0000000..0ca3d2b Binary files /dev/null and b/src/src/stylesheets/skyrim/checkbox-checked-hover.png differ diff --git a/src/src/stylesheets/skyrim/checkbox-checked.png b/src/src/stylesheets/skyrim/checkbox-checked.png new file mode 100644 index 0000000..68da200 Binary files /dev/null and b/src/src/stylesheets/skyrim/checkbox-checked.png differ diff --git a/src/src/stylesheets/skyrim/checkbox-disabled.png b/src/src/stylesheets/skyrim/checkbox-disabled.png new file mode 100644 index 0000000..ad264e5 Binary files /dev/null and b/src/src/stylesheets/skyrim/checkbox-disabled.png differ diff --git a/src/src/stylesheets/skyrim/checkbox-hover.png b/src/src/stylesheets/skyrim/checkbox-hover.png new file mode 100644 index 0000000..8e90ba6 Binary files /dev/null and b/src/src/stylesheets/skyrim/checkbox-hover.png differ diff --git a/src/src/stylesheets/skyrim/checkbox.png b/src/src/stylesheets/skyrim/checkbox.png new file mode 100644 index 0000000..3450381 Binary files /dev/null and b/src/src/stylesheets/skyrim/checkbox.png differ diff --git a/src/src/stylesheets/skyrim/context-menu-separator.png b/src/src/stylesheets/skyrim/context-menu-separator.png new file mode 100644 index 0000000..f566ca7 Binary files /dev/null and b/src/src/stylesheets/skyrim/context-menu-separator.png differ diff --git a/src/src/stylesheets/skyrim/progress-bar-border.png b/src/src/stylesheets/skyrim/progress-bar-border.png new file mode 100644 index 0000000..676f0de Binary files /dev/null and b/src/src/stylesheets/skyrim/progress-bar-border.png differ diff --git a/src/src/stylesheets/skyrim/progress-bar-chunk.png b/src/src/stylesheets/skyrim/progress-bar-chunk.png new file mode 100644 index 0000000..b9d3139 Binary files /dev/null and b/src/src/stylesheets/skyrim/progress-bar-chunk.png differ diff --git a/src/src/stylesheets/skyrim/radio-checked.png b/src/src/stylesheets/skyrim/radio-checked.png new file mode 100644 index 0000000..8902283 Binary files /dev/null and b/src/src/stylesheets/skyrim/radio-checked.png differ diff --git a/src/src/stylesheets/skyrim/radio-hover.png b/src/src/stylesheets/skyrim/radio-hover.png new file mode 100644 index 0000000..eda643c Binary files /dev/null and b/src/src/stylesheets/skyrim/radio-hover.png differ diff --git a/src/src/stylesheets/skyrim/radio.png b/src/src/stylesheets/skyrim/radio.png new file mode 100644 index 0000000..57cd398 Binary files /dev/null and b/src/src/stylesheets/skyrim/radio.png differ diff --git a/src/src/stylesheets/skyrim/scrollbar-down.png b/src/src/stylesheets/skyrim/scrollbar-down.png new file mode 100644 index 0000000..76feeea Binary files /dev/null and b/src/src/stylesheets/skyrim/scrollbar-down.png differ diff --git a/src/src/stylesheets/skyrim/scrollbar-horizontal.png b/src/src/stylesheets/skyrim/scrollbar-horizontal.png new file mode 100644 index 0000000..7ebfe65 Binary files /dev/null and b/src/src/stylesheets/skyrim/scrollbar-horizontal.png differ diff --git a/src/src/stylesheets/skyrim/scrollbar-left.png b/src/src/stylesheets/skyrim/scrollbar-left.png new file mode 100644 index 0000000..77a6078 Binary files /dev/null and b/src/src/stylesheets/skyrim/scrollbar-left.png differ diff --git a/src/src/stylesheets/skyrim/scrollbar-right.png b/src/src/stylesheets/skyrim/scrollbar-right.png new file mode 100644 index 0000000..e8a67d4 Binary files /dev/null and b/src/src/stylesheets/skyrim/scrollbar-right.png differ diff --git a/src/src/stylesheets/skyrim/scrollbar-up.png b/src/src/stylesheets/skyrim/scrollbar-up.png new file mode 100644 index 0000000..64c9665 Binary files /dev/null and b/src/src/stylesheets/skyrim/scrollbar-up.png differ diff --git a/src/src/stylesheets/skyrim/scrollbar-vertical.png b/src/src/stylesheets/skyrim/scrollbar-vertical.png new file mode 100644 index 0000000..cdd5b04 Binary files /dev/null and b/src/src/stylesheets/skyrim/scrollbar-vertical.png differ diff --git a/src/src/stylesheets/skyrim/separator.png b/src/src/stylesheets/skyrim/separator.png new file mode 100644 index 0000000..9867bab Binary files /dev/null and b/src/src/stylesheets/skyrim/separator.png differ diff --git a/src/src/stylesheets/skyrim/slider-border.png b/src/src/stylesheets/skyrim/slider-border.png new file mode 100644 index 0000000..64ef3eb Binary files /dev/null and b/src/src/stylesheets/skyrim/slider-border.png differ diff --git a/src/src/stylesheets/skyrim/slider-handle.png b/src/src/stylesheets/skyrim/slider-handle.png new file mode 100644 index 0000000..e400cd3 Binary files /dev/null and b/src/src/stylesheets/skyrim/slider-handle.png differ diff --git a/src/src/stylesheets/vs15 Dark-Green.qss b/src/src/stylesheets/vs15 Dark-Green.qss new file mode 100644 index 0000000..ae0390e --- /dev/null +++ b/src/src/stylesheets/vs15 Dark-Green.qss @@ -0,0 +1,920 @@ +/*!************************************* + VS15 Dark +**************************************** + Author: chintsu_kun, holt59, MO2 Team + Version: 2.5.0 + Licence: GNU General Public License v3.0 (https://www.gnu.org/licenses/gpl-3.0.en.html) + Url: https://github.com/nikolay-borzov/modorganizer-themes +**************************************** +*/ +/* For some reason applying background-color or border fixes paddings properties */ +QListWidget::item { + border-width: 0; +} + +/* Don't override install label on download widget. + MO2 assigns color depending on download state */ +#installLabel { + color: none; +} + +/* Make `background-color` work for :hover, :focus and :pressed states */ +QToolButton { + border: none; +} + +/* Main Window */ +QWidget { + background-color: #2d2d30; + color: #f1f1f1; +} + +QWidget::disabled { + color: #656565; +} + +/* Common */ +/* remove outline */ +* { + outline: 0; +} + +*:disabled, +QListView::item:disabled, +*::item:selected:disabled { + color: #656565; +} + +/* line heights */ +/* QTreeView#fileTree::item - currently have problem with size column vertical + text align */ +#bsaList::item, +#dataTree::item, +#modList::item, +#categoriesTree::item, +#savegameList::item, +#tabConflicts QTreeWidget::item { + padding: 0.3em 0; +} + +QListView::item, +QTreeView#espList::item { + padding: 0.3em 0; +} + +/* to enable border color */ +QTreeView, +QListView, +QTextEdit, +QWebView, +QTableView { + border-style: solid; + border-width: 1px; +} + +QAbstractItemView { + color: #dcdcdc; + background-color: #1e1e1e; + alternate-background-color: #262626; + border-color: #3f3f46; +} + +QAbstractItemView::item:selected, +QAbstractItemView::item:selected:hover, +QAbstractItemView::item:alternate:selected, +QAbstractItemView::item:alternate:selected:hover { + color: #f1f1f1; + background-color: #009933; +} + +QAbstractItemView[filtered=true] { + border: 2px solid #f00 !important; +} + +QAbstractItemView, +QListView, +QTreeView { + show-decoration-selected: 1; +} + +QAbstractItemView::item:hover, +QAbstractItemView::item:alternate:hover, +QAbstractItemView::item:disabled:hover, +QAbstractItemView::item:alternate:disabled:hover QListView::item:hover, +QTreeView::branch:hover, +QTreeWidget::item:hover { + background-color: rgba(0, 153, 51, 0.3); +} + +QAbstractItemView::item:selected:disabled, +QAbstractItemView::item:alternate:selected:disabled, +QListView::item:selected:disabled, +QTreeView::branch:selected:disabled, +QTreeWidget::item:selected:disabled { + background-color: rgba(0, 153, 51, 0.3); +} + +QTreeView::branch:selected, +#bsaList::branch:selected { + background-color: #009933; +} + +QLabel { + background-color: transparent; +} + +LinkLabel { + qproperty-linkColor: #009933; +} + +/* Left Pane & File Trees #QTreeView, #QListView*/ +QTreeView::branch:closed:has-children { + image: url(./vs15/branch-closed.png); +} + +QTreeView::branch:open:has-children { + image: url(./vs15/branch-open.png); +} + +QListView::item { + color: #f1f1f1; +} + +/* Text areas and text fields #QTextEdit, #QLineEdit, #QWebView */ +QTextEdit, +QWebView, +QLineEdit, +QAbstractSpinBox, +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button, +QComboBox { + background-color: #333337; + border-color: #3f3f46; +} + +QLineEdit:hover, +QAbstractSpinBox:hover, +QTextEdit:hover, +QComboBox:hover, +QComboBox:editable:hover { + border-color: #009933; +} + +QLineEdit:focus, +QAbstractSpinBox::focus, +QTextEdit:focus, +QComboBox:focus, +QComboBox:editable:focus, +QComboBox:on { + background-color: #3f3f46; + border-color: #009933; +} + +QComboBox:on { + border-bottom-color: #3f3f46; +} + +QLineEdit, +QAbstractSpinBox { + min-height: 15px; + padding: 2px; + border-style: solid; + border-width: 1px; +} + +QLineEdit { + margin-top: 0; +} + +/* clear button */ +QLineEdit QToolButton, +QLineEdit QToolButton:hover { + background: none; + margin-top: 1px; +} + +QLineEdit#espFilterEdit QToolButton { + margin-top: -2px; + margin-bottom: 1px; +} + +/* Drop-downs #QComboBox*/ +QComboBox { + min-height: 20px; + padding-left: 5px; + margin: 3px 0 1px 0; + border-style: solid; + border-width: 1px; +} + +QComboBox:editable { + padding-left: 3px; + /* to enable hover styles */ + background-color: transparent; +} + +QComboBox::drop-down { + width: 20px; + subcontrol-origin: padding; + subcontrol-position: top right; + border: none; +} + +QComboBox::down-arrow { + image: url(./vs15/combobox-down.png); +} + +QComboBox QAbstractItemView { + background-color: #1b1b1c; + selection-background-color: #3f3f46; + border-color: #009933; + border-style: solid; + border-width: 0 1px 1px 1px; +} + +/* Doesn't work http://stackoverflow.com/questions/13308341/qcombobox-abstractitemviewitem */ +/* QComboBox QAbstractItemView:item { + padding: 10px; + margin: 10px; +} */ +/* Toolbar */ +QToolBar { + border: none; +} + +QToolBar::separator { + border-left-color: #222222; + border-right-color: #46464a; + border-width: 0 1px 0 1px; + border-style: solid; + width: 0; +} + +QToolButton { + padding: 4px; +} + +QToolButton:hover, QToolButton:focus { + background-color: #3e3e40; +} + +QToolButton:pressed { + background-color: #009933; +} + +QToolButton::menu-indicator { + image: url(./vs15/combobox-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-top: 10%; + padding-right: 5%; +} + +/* Group Boxes #QGroupBox */ +QGroupBox { + border-color: #3f3f46; + border-style: solid; + border-width: 1px; + padding: 1em 0.3em 0.3em 0.3em; + margin-top: 0.65em; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 2px; + left: 10px; +} + +/* LCD Count */ +QLCDNumber { + border-color: #3f3f46; + border-style: solid; + border-width: 1px; +} + +/* Buttons #QPushButton */ +QPushButton { + background-color: #333337; + border-color: #3f3f46; + min-height: 18px; + padding: 2px 5px; + border-style: solid; + border-width: 1px; +} + +QPushButton:hover, +QPushButton:checked, +QPushButton:focus, +QAbstractSpinBox::up-button:hover, +QAbstractSpinBox::down-button:hover { + background-color: #009933; +} + +QPushButton:pressed, +QPushButton:checked:hover, +QAbstractSpinBox::up-button:pressed, +QAbstractSpinBox::down-button:pressed { + background-color: #00b33c; +} + +QPushButton:disabled, +QAbstractSpinBox::up-button:disabled, +QAbstractSpinBox::down-button:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QPushButton::menu-indicator { + image: url(./vs15/combobox-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 5%; +} + +/* Dialog buttons */ +QDialog QPushButton, +QSlider::handle:horizontal, +QSlider::handle:vertical { + color: #000000; + background-color: #dddddd; + border-color: #707070; + border-style: solid; + border-width: 1px; +} + +QDialog QPushButton:hover, +QSlider::handle:horizontal:hover, +QSlider::handle:vertical:hover, +QSlider::handle:horizontal:pressed, +QSlider::handle:horizontal:focus:pressed, +QSlider::handle:vertical:pressed, +QSlider::handle:vertical:focus:pressed { + background-color: #BEE6FD; + border-color: #00a838; +} + +QSlider::handle:horizontal:focus, +QSlider::handle:vertical:focus, +QDialog QPushButton:focus, +QDialog QPushButton:checked { + background-color: #dddddd; + border-color: #009933; +} + +QDialog QPushButton:disabled, +QSlider::handle:horizontal:disabled, +QSlider::handle:vertical:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QDialog QPushButton { + min-width: 1.5em; + padding-left: 0.5em; + padding-right: 0.5em; +} + +/* Check boxes and Radio buttons common #QCheckBox, #QRadioButton */ +QGroupBox::indicator, +QTreeView::indicator, +QCheckBox::indicator, +QRadioButton::indicator { + background-color: #2d2d30; + border-color: #3f3f46; + width: 13px; + height: 13px; + border-style: solid; + border-width: 1px; +} + +QGroupBox::indicator:hover, +QTreeView::indicator:hover, +QCheckBox::indicator:hover, +QRadioButton::indicator:hover { + background-color: #3f3f46; + border-color: #009933; +} + +QGroupBox::indicator:checked, +QTreeView::indicator:checked, +QCheckBox::indicator:checked { + image: url(./vs15/checkbox-check.png); +} + +QGroupBox::indicator:disabled, +QTreeView::indicator:checked:disabled, +QCheckBox::indicator:checked:disabled { + image: url(./vs15/checkbox-check-disabled.png); +} + +/* Check boxes special */ +QTreeView#modList::indicator { + width: 15px; + height: 15px; +} + +/* Radio buttons #QRadioButton */ +QRadioButton::indicator { + border-radius: 7px; +} + +QRadioButton::indicator::checked { + background-color: #B9B9BA; + border-width: 2px; + width: 11px; + height: 11px; +} + +QRadioButton::indicator::checked:hover { + border-color: #3f3f46; +} + +/* Spinners #QSpinBox, #QDoubleSpinBox */ +QAbstractSpinBox { + margin: 1px; +} + +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button { + border-style: solid; + border-width: 1px; + subcontrol-origin: padding; +} + +QAbstractSpinBox::up-button { + subcontrol-position: top right; +} + +QAbstractSpinBox::up-arrow { + image: url(./vs15/spinner-up.png); +} + +QAbstractSpinBox::down-button { + subcontrol-position: bottom right; +} + +QAbstractSpinBox::down-arrow { + image: url(./vs15/spinner-down.png); +} + +/* Sliders #QSlider */ +QSlider::groove:horizontal { + background-color: #3f3f46; + border: none; + height: 8px; + margin: 2px 0; +} + +QSlider::handle:horizontal { + width: 0.5em; + height: 2em; + margin: -7px 0; + subcontrol-origin: margin; +} + +/* Scroll Bars #QAbstractScrollArea, #QScrollBar*/ +/* assigning background still leaves not filled area*/ +QAbstractScrollArea::corner { + background-color: transparent; +} + +/* Horizontal */ +QScrollBar:horizontal { + height: 18px; + border: none; + margin: 0 23px 0 23px; +} + +QScrollBar::handle:horizontal { + min-width: 32px; + margin: 4px 2px; +} + +QScrollBar::add-line:horizontal { + width: 23px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal { + width: 23px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +/* Vertical */ +QScrollBar:vertical { + width: 20px; + border: none; + margin: 23px 0 23px 0; +} + +QScrollBar::handle:vertical { + min-height: 32px; + margin: 2px 4px; +} + +QScrollBar::add-line:vertical { + height: 23px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:vertical { + height: 23px; + subcontrol-position: top; + subcontrol-origin: margin; +} + +/* Combined */ +QScrollBar { + background-color: #3e3e42; + border: none; +} + +QScrollBar::handle { + background-color: #686868; +} + +QScrollBar::add-line, +QScrollBar::sub-line { + background-color: #3e3e42; + border: none; +} + +/* QScrollBar::add-line:horizontal:hover, +QScrollBar::sub-line:horizontal:hover, +QScrollBar::add-line:vertical:hover, +QScrollBar::sub-line:vertical:hover, +QScrollBar::add-line:horizontal:pressed, +QScrollBar::sub-line:horizontal:pressed, +QScrollBar::add-line:vertical:pressed, +QScrollBar::sub-line:vertical:pressed { } */ +QScrollBar::handle:hover { + background: #9e9e9e; +} + +QScrollBar::handle:pressed { + background: #efebef; +} + +QScrollBar::handle:disabled { + background: #555558; +} + +QScrollBar::add-page, +QScrollBar::sub-page { + background: transparent; +} + +QScrollBar::up-arrow:vertical { + image: url(./vs15/scrollbar-up.png); +} + +QScrollBar::up-arrow:vertical:hover { + image: url(./vs15/scrollbar-up-hover.png); +} + +QScrollBar::up-arrow:vertical:disabled { + image: url(./vs15/scrollbar-up-disabled.png); +} + +QScrollBar::right-arrow:horizontal { + image: url(./vs15/scrollbar-right.png); +} + +QScrollBar::right-arrow:horizontal:hover { + image: url(./vs15/scrollbar-right-hover.png); +} + +QScrollBar::right-arrow:horizontal:disabled { + image: url(./vs15/scrollbar-right-disabled.png); +} + +QScrollBar::down-arrow:vertical { + image: url(./vs15/scrollbar-down.png); +} + +QScrollBar::down-arrow:vertical:hover { + image: url(./vs15/scrollbar-down-hover.png); +} + +QScrollBar::down-arrow:vertical:disabled { + image: url(./vs15/scrollbar-down-disabled.png); +} + +QScrollBar::left-arrow:horizontal { + image: url(./vs15/scrollbar-left.png); +} + +QScrollBar::left-arrow:horizontal:hover { + image: url(./vs15/scrollbar-left-hover.png); +} + +QScrollBar::left-arrow:horizontal:disabled { + image: url(./vs15/scrollbar-left-disabled.png); +} + +/* Header Rows and Tables (Configure Mod Categories) #QTableView, #QHeaderView */ +QTableView { + gridline-color: #3f3f46; + selection-background-color: #009933; + selection-color: #f1f1f1; +} + +QTableView QTableCornerButton::section { + background: #252526; + border-color: #3f3f46; + border-style: solid; + border-width: 0 1px 1px 0; +} + +QHeaderView { + border: none; +} + +QHeaderView::section { + background: #252526; + border-color: #3f3f46; + padding: 3px 5px; + border-style: solid; + border-width: 0 1px 1px 0; +} + +QHeaderView::section:hover { + background: #3e3e40; + color: #f6f6f6; +} + +QHeaderView::section:last { + border-right: 0; +} + +QHeaderView::up-arrow { + image: url(./vs15/sort-asc.png); + margin-bottom: -37px; +} + +DownloadListView QHeaderView::up-arrow { + margin-bottom: -47px; +} + +QHeaderView::down-arrow { + image: url(./vs15/sort-desc.png); + margin-bottom: 0; +} + +/* Context menus, toolbar drop-downs #QMenu */ +QMenu { + background-color: #1a1a1c; + border-color: #333337; + border-style: solid; + border-width: 1px; + padding: 2px; +} + +QMenu::item { + background: transparent; + padding: 4px 20px; +} + +QMenu::item:selected, +QMenuBar::item:selected { + background-color: #333334; +} + +QMenu::item:disabled { + background-color: transparent; +} + +QMenu::separator { + background-color: #333337; + height: 1px; + margin: 1px 0; +} + +QMenu::icon { + margin: 1px; +} + +QMenu::right-arrow { + image: url(./vs15/sub-menu-arrow.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 0.5em; +} + +QMenu QPushButton { + background-color: transparent; + border-color: #3f3f46; + margin: 1px 0 1px 0; +} + +QMenu QCheckBox, +QMenu QRadioButton { + background-color: transparent; + padding: 5px 2px; +} + +/* Tool tips #QToolTip, #SaveGameInfoWidget */ +QToolTip, +SaveGameInfoWidget { + background-color: #424245; + border-color: #4d4d50; + color: #f1f1f1; + border-style: solid; + border-width: 1px; + padding: 2px; +} + +QStatusBar::item { + border: None; +} + +/* Progress Bars (Downloads) #QProgressBar */ +QProgressBar { + background-color: #e6e6e6; + color: #000; + border-color: #bcbcbc; + text-align: center; + border-style: solid; + border-width: 1px; + margin: 0px; +} + +QProgressBar::chunk { + background: #06b025; +} + +DownloadListView[downloadView=standard]::item { + padding: 16px; +} + +DownloadListView[downloadView=compact]::item { + padding: 4px; +} + +/* Right Pane and Tab Bars #QTabWidget, #QTabBar */ +QTabWidget::pane { + border-color: #3f3f46; + border-top-color: #009933; + top: 0; + border-style: solid; + border-width: 1px; +} + +QTabWidget::pane:disabled { + border-top-color: #3f3f46; +} + +QTabBar::tab { + background-color: transparent; + padding: 4px 1em; + border: none; +} + +QTabBar::tab:hover { + background-color: #00b33c; +} + +QTabBar::tab:selected, +QTabBar::tab:selected:hover { + background-color: #009933; +} + +QTabBar::tab:disabled { + background-color: transparent; + color: #656565; +} + +QTabBar::tab:selected:disabled { + background-color: #3f3f46; +} + +/* Scrollers */ +QTabBar QToolButton { + background-color: #333337; + border-color: #3f3f46; + padding: 1px; + margin: 0; + border-style: solid; + border-width: 1px; +} + +QTabBar QToolButton:hover { + border-color: #009933; + border-width: 1px; + border-style: solid; +} + +QTabBar QToolButton:disabled, +QTabBar QToolButton:pressed:hover { + background-color: #333337; +} + +QTabBar::scroller { + width: 23px; + background-color: red; +} + +QTabBar QToolButton::right-arrow { + image: url(./vs15/scrollbar-right.png); +} + +QTabBar QToolButton::right-arrow:hover { + image: url(./vs15/scrollbar-right-hover.png); +} + +QTabBar QToolButton::left-arrow { + image: url(./vs15/scrollbar-left.png); +} + +QTabBar QToolButton::left-arrow:hover { + image: url(./vs15/scrollbar-left-hover.png); +} + +/* Special styles */ +QWidget#tabImages QPushButton { + background-color: transparent; + margin: 0 0.3em; + padding: 0; +} + +/* like dialog QPushButton*/ +QWidget#tabESPs QToolButton { + color: #000000; + background-color: #dddddd; + border-color: #707070; + border-style: solid; + border-width: 1px; +} + +QWidget#tabESPs QToolButton:hover { + background-color: #BEE6FD; + border-color: #00a838; +} + +QWidget#tabESPs QToolButton:focus { + background-color: #dddddd; + border-color: #009933; +} + +QWidget#tabESPs QToolButton:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QTreeWidget#categoriesList { + /* min-width: 225px; */ +} + +QTreeWidget#categoriesList::item { + background-position: center left; + background-repeat: no-repeat; + padding: 0.35em 10px; +} + +QTreeWidget#categoriesList::item:has-children { + background-image: url(./vs15/branch-closed.png); +} + +QTreeWidget#categoriesList::item:has-children:open { + background-image: url(./vs15/branch-open.png); +} + +QDialog#QueryOverwriteDialog QPushButton { + margin-left: 0.5em; +} + +QDialog#PyCfgDialog QPushButton:hover { + background-color: #BEE6FD; +} + +QLineEdit#modFilterEdit { + margin-top: 2px; +} + +/* highlight unchecked BSAs */ +QWidget#bsaTab QTreeWidget::indicator:unchecked { + background-color: #009933; +} + +/* increase version text field */ +QLineEdit#versionEdit { + max-width: 100px; +} + +/* Dialogs width changes */ +/* increase width to prevent buttons cutting */ +QDialog#QueryOverwriteDialog { + min-width: 565px; +} + +QDialog#ModInfoDialog { + min-width: 850px; +} + +QLineEdit[valid-filter=false] { + background-color: #661111 !important; +} diff --git a/src/src/stylesheets/vs15 Dark-Orange.qss b/src/src/stylesheets/vs15 Dark-Orange.qss new file mode 100644 index 0000000..5e8a3a4 --- /dev/null +++ b/src/src/stylesheets/vs15 Dark-Orange.qss @@ -0,0 +1,920 @@ +/*!************************************* + VS15 Dark +**************************************** + Author: chintsu_kun, holt59, MO2 Team + Version: 2.5.0 + Licence: GNU General Public License v3.0 (https://www.gnu.org/licenses/gpl-3.0.en.html) + Url: https://github.com/nikolay-borzov/modorganizer-themes +**************************************** +*/ +/* For some reason applying background-color or border fixes paddings properties */ +QListWidget::item { + border-width: 0; +} + +/* Don't override install label on download widget. + MO2 assigns color depending on download state */ +#installLabel { + color: none; +} + +/* Make `background-color` work for :hover, :focus and :pressed states */ +QToolButton { + border: none; +} + +/* Main Window */ +QWidget { + background-color: #2d2d30; + color: #f1f1f1; +} + +QWidget::disabled { + color: #656565; +} + +/* Common */ +/* remove outline */ +* { + outline: 0; +} + +*:disabled, +QListView::item:disabled, +*::item:selected:disabled { + color: #656565; +} + +/* line heights */ +/* QTreeView#fileTree::item - currently have problem with size column vertical + text align */ +#bsaList::item, +#dataTree::item, +#modList::item, +#categoriesTree::item, +#savegameList::item, +#tabConflicts QTreeWidget::item { + padding: 0.3em 0; +} + +QListView::item, +QTreeView#espList::item { + padding: 0.3em 0; +} + +/* to enable border color */ +QTreeView, +QListView, +QTextEdit, +QWebView, +QTableView { + border-style: solid; + border-width: 1px; +} + +QAbstractItemView { + color: #dcdcdc; + background-color: #1e1e1e; + alternate-background-color: #262626; + border-color: #3f3f46; +} + +QAbstractItemView::item:selected, +QAbstractItemView::item:selected:hover, +QAbstractItemView::item:alternate:selected, +QAbstractItemView::item:alternate:selected:hover { + color: #f1f1f1; + background-color: #cc6600; +} + +QAbstractItemView[filtered=true] { + border: 2px solid #f00 !important; +} + +QAbstractItemView, +QListView, +QTreeView { + show-decoration-selected: 1; +} + +QAbstractItemView::item:hover, +QAbstractItemView::item:alternate:hover, +QAbstractItemView::item:disabled:hover, +QAbstractItemView::item:alternate:disabled:hover QListView::item:hover, +QTreeView::branch:hover, +QTreeWidget::item:hover { + background-color: rgba(204, 102, 0, 0.3); +} + +QAbstractItemView::item:selected:disabled, +QAbstractItemView::item:alternate:selected:disabled, +QListView::item:selected:disabled, +QTreeView::branch:selected:disabled, +QTreeWidget::item:selected:disabled { + background-color: rgba(204, 102, 0, 0.3); +} + +QTreeView::branch:selected, +#bsaList::branch:selected { + background-color: #cc6600; +} + +QLabel { + background-color: transparent; +} + +LinkLabel { + qproperty-linkColor: #cc6600; +} + +/* Left Pane & File Trees #QTreeView, #QListView*/ +QTreeView::branch:closed:has-children { + image: url(./vs15/branch-closed.png); +} + +QTreeView::branch:open:has-children { + image: url(./vs15/branch-open.png); +} + +QListView::item { + color: #f1f1f1; +} + +/* Text areas and text fields #QTextEdit, #QLineEdit, #QWebView */ +QTextEdit, +QWebView, +QLineEdit, +QAbstractSpinBox, +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button, +QComboBox { + background-color: #333337; + border-color: #3f3f46; +} + +QLineEdit:hover, +QAbstractSpinBox:hover, +QTextEdit:hover, +QComboBox:hover, +QComboBox:editable:hover { + border-color: #cc6600; +} + +QLineEdit:focus, +QAbstractSpinBox::focus, +QTextEdit:focus, +QComboBox:focus, +QComboBox:editable:focus, +QComboBox:on { + background-color: #3f3f46; + border-color: #cc6600; +} + +QComboBox:on { + border-bottom-color: #3f3f46; +} + +QLineEdit, +QAbstractSpinBox { + min-height: 15px; + padding: 2px; + border-style: solid; + border-width: 1px; +} + +QLineEdit { + margin-top: 0; +} + +/* clear button */ +QLineEdit QToolButton, +QLineEdit QToolButton:hover { + background: none; + margin-top: 1px; +} + +QLineEdit#espFilterEdit QToolButton { + margin-top: -2px; + margin-bottom: 1px; +} + +/* Drop-downs #QComboBox*/ +QComboBox { + min-height: 20px; + padding-left: 5px; + margin: 3px 0 1px 0; + border-style: solid; + border-width: 1px; +} + +QComboBox:editable { + padding-left: 3px; + /* to enable hover styles */ + background-color: transparent; +} + +QComboBox::drop-down { + width: 20px; + subcontrol-origin: padding; + subcontrol-position: top right; + border: none; +} + +QComboBox::down-arrow { + image: url(./vs15/combobox-down.png); +} + +QComboBox QAbstractItemView { + background-color: #1b1b1c; + selection-background-color: #3f3f46; + border-color: #cc6600; + border-style: solid; + border-width: 0 1px 1px 1px; +} + +/* Doesn't work http://stackoverflow.com/questions/13308341/qcombobox-abstractitemviewitem */ +/* QComboBox QAbstractItemView:item { + padding: 10px; + margin: 10px; +} */ +/* Toolbar */ +QToolBar { + border: none; +} + +QToolBar::separator { + border-left-color: #222222; + border-right-color: #46464a; + border-width: 0 1px 0 1px; + border-style: solid; + width: 0; +} + +QToolButton { + padding: 4px; +} + +QToolButton:hover, QToolButton:focus { + background-color: #3e3e40; +} + +QToolButton:pressed { + background-color: #cc6600; +} + +QToolButton::menu-indicator { + image: url(./vs15/combobox-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-top: 10%; + padding-right: 5%; +} + +/* Group Boxes #QGroupBox */ +QGroupBox { + border-color: #3f3f46; + border-style: solid; + border-width: 1px; + padding: 1em 0.3em 0.3em 0.3em; + margin-top: 0.65em; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 2px; + left: 10px; +} + +/* LCD Count */ +QLCDNumber { + border-color: #3f3f46; + border-style: solid; + border-width: 1px; +} + +/* Buttons #QPushButton */ +QPushButton { + background-color: #333337; + border-color: #3f3f46; + min-height: 18px; + padding: 2px 5px; + border-style: solid; + border-width: 1px; +} + +QPushButton:hover, +QPushButton:checked, +QPushButton:focus, +QAbstractSpinBox::up-button:hover, +QAbstractSpinBox::down-button:hover { + background-color: #cc6600; +} + +QPushButton:pressed, +QPushButton:checked:hover, +QAbstractSpinBox::up-button:pressed, +QAbstractSpinBox::down-button:pressed { + background-color: #e67300; +} + +QPushButton:disabled, +QAbstractSpinBox::up-button:disabled, +QAbstractSpinBox::down-button:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QPushButton::menu-indicator { + image: url(./vs15/combobox-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 5%; +} + +/* Dialog buttons */ +QDialog QPushButton, +QSlider::handle:horizontal, +QSlider::handle:vertical { + color: #000000; + background-color: #dddddd; + border-color: #707070; + border-style: solid; + border-width: 1px; +} + +QDialog QPushButton:hover, +QSlider::handle:horizontal:hover, +QSlider::handle:vertical:hover, +QSlider::handle:horizontal:pressed, +QSlider::handle:horizontal:focus:pressed, +QSlider::handle:vertical:pressed, +QSlider::handle:vertical:focus:pressed { + background-color: #BEE6FD; + border-color: #db6e00; +} + +QSlider::handle:horizontal:focus, +QSlider::handle:vertical:focus, +QDialog QPushButton:focus, +QDialog QPushButton:checked { + background-color: #dddddd; + border-color: #cc6600; +} + +QDialog QPushButton:disabled, +QSlider::handle:horizontal:disabled, +QSlider::handle:vertical:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QDialog QPushButton { + min-width: 1.5em; + padding-left: 0.5em; + padding-right: 0.5em; +} + +/* Check boxes and Radio buttons common #QCheckBox, #QRadioButton */ +QGroupBox::indicator, +QTreeView::indicator, +QCheckBox::indicator, +QRadioButton::indicator { + background-color: #2d2d30; + border-color: #3f3f46; + width: 13px; + height: 13px; + border-style: solid; + border-width: 1px; +} + +QGroupBox::indicator:hover, +QTreeView::indicator:hover, +QCheckBox::indicator:hover, +QRadioButton::indicator:hover { + background-color: #3f3f46; + border-color: #cc6600; +} + +QGroupBox::indicator:checked, +QTreeView::indicator:checked, +QCheckBox::indicator:checked { + image: url(./vs15/checkbox-check.png); +} + +QGroupBox::indicator:disabled, +QTreeView::indicator:checked:disabled, +QCheckBox::indicator:checked:disabled { + image: url(./vs15/checkbox-check-disabled.png); +} + +/* Check boxes special */ +QTreeView#modList::indicator { + width: 15px; + height: 15px; +} + +/* Radio buttons #QRadioButton */ +QRadioButton::indicator { + border-radius: 7px; +} + +QRadioButton::indicator::checked { + background-color: #B9B9BA; + border-width: 2px; + width: 11px; + height: 11px; +} + +QRadioButton::indicator::checked:hover { + border-color: #3f3f46; +} + +/* Spinners #QSpinBox, #QDoubleSpinBox */ +QAbstractSpinBox { + margin: 1px; +} + +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button { + border-style: solid; + border-width: 1px; + subcontrol-origin: padding; +} + +QAbstractSpinBox::up-button { + subcontrol-position: top right; +} + +QAbstractSpinBox::up-arrow { + image: url(./vs15/spinner-up.png); +} + +QAbstractSpinBox::down-button { + subcontrol-position: bottom right; +} + +QAbstractSpinBox::down-arrow { + image: url(./vs15/spinner-down.png); +} + +/* Sliders #QSlider */ +QSlider::groove:horizontal { + background-color: #3f3f46; + border: none; + height: 8px; + margin: 2px 0; +} + +QSlider::handle:horizontal { + width: 0.5em; + height: 2em; + margin: -7px 0; + subcontrol-origin: margin; +} + +/* Scroll Bars #QAbstractScrollArea, #QScrollBar*/ +/* assigning background still leaves not filled area*/ +QAbstractScrollArea::corner { + background-color: transparent; +} + +/* Horizontal */ +QScrollBar:horizontal { + height: 18px; + border: none; + margin: 0 23px 0 23px; +} + +QScrollBar::handle:horizontal { + min-width: 32px; + margin: 4px 2px; +} + +QScrollBar::add-line:horizontal { + width: 23px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal { + width: 23px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +/* Vertical */ +QScrollBar:vertical { + width: 20px; + border: none; + margin: 23px 0 23px 0; +} + +QScrollBar::handle:vertical { + min-height: 32px; + margin: 2px 4px; +} + +QScrollBar::add-line:vertical { + height: 23px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:vertical { + height: 23px; + subcontrol-position: top; + subcontrol-origin: margin; +} + +/* Combined */ +QScrollBar { + background-color: #3e3e42; + border: none; +} + +QScrollBar::handle { + background-color: #686868; +} + +QScrollBar::add-line, +QScrollBar::sub-line { + background-color: #3e3e42; + border: none; +} + +/* QScrollBar::add-line:horizontal:hover, +QScrollBar::sub-line:horizontal:hover, +QScrollBar::add-line:vertical:hover, +QScrollBar::sub-line:vertical:hover, +QScrollBar::add-line:horizontal:pressed, +QScrollBar::sub-line:horizontal:pressed, +QScrollBar::add-line:vertical:pressed, +QScrollBar::sub-line:vertical:pressed { } */ +QScrollBar::handle:hover { + background: #9e9e9e; +} + +QScrollBar::handle:pressed { + background: #efebef; +} + +QScrollBar::handle:disabled { + background: #555558; +} + +QScrollBar::add-page, +QScrollBar::sub-page { + background: transparent; +} + +QScrollBar::up-arrow:vertical { + image: url(./vs15/scrollbar-up.png); +} + +QScrollBar::up-arrow:vertical:hover { + image: url(./vs15/scrollbar-up-hover.png); +} + +QScrollBar::up-arrow:vertical:disabled { + image: url(./vs15/scrollbar-up-disabled.png); +} + +QScrollBar::right-arrow:horizontal { + image: url(./vs15/scrollbar-right.png); +} + +QScrollBar::right-arrow:horizontal:hover { + image: url(./vs15/scrollbar-right-hover.png); +} + +QScrollBar::right-arrow:horizontal:disabled { + image: url(./vs15/scrollbar-right-disabled.png); +} + +QScrollBar::down-arrow:vertical { + image: url(./vs15/scrollbar-down.png); +} + +QScrollBar::down-arrow:vertical:hover { + image: url(./vs15/scrollbar-down-hover.png); +} + +QScrollBar::down-arrow:vertical:disabled { + image: url(./vs15/scrollbar-down-disabled.png); +} + +QScrollBar::left-arrow:horizontal { + image: url(./vs15/scrollbar-left.png); +} + +QScrollBar::left-arrow:horizontal:hover { + image: url(./vs15/scrollbar-left-hover.png); +} + +QScrollBar::left-arrow:horizontal:disabled { + image: url(./vs15/scrollbar-left-disabled.png); +} + +/* Header Rows and Tables (Configure Mod Categories) #QTableView, #QHeaderView */ +QTableView { + gridline-color: #3f3f46; + selection-background-color: #cc6600; + selection-color: #f1f1f1; +} + +QTableView QTableCornerButton::section { + background: #252526; + border-color: #3f3f46; + border-style: solid; + border-width: 0 1px 1px 0; +} + +QHeaderView { + border: none; +} + +QHeaderView::section { + background: #252526; + border-color: #3f3f46; + padding: 3px 5px; + border-style: solid; + border-width: 0 1px 1px 0; +} + +QHeaderView::section:hover { + background: #3e3e40; + color: #f6f6f6; +} + +QHeaderView::section:last { + border-right: 0; +} + +QHeaderView::up-arrow { + image: url(./vs15/sort-asc.png); + margin-bottom: -37px; +} + +DownloadListView QHeaderView::up-arrow { + margin-bottom: -47px; +} + +QHeaderView::down-arrow { + image: url(./vs15/sort-desc.png); + margin-bottom: 0; +} + +/* Context menus, toolbar drop-downs #QMenu */ +QMenu { + background-color: #1a1a1c; + border-color: #333337; + border-style: solid; + border-width: 1px; + padding: 2px; +} + +QMenu::item { + background: transparent; + padding: 4px 20px; +} + +QMenu::item:selected, +QMenuBar::item:selected { + background-color: #333334; +} + +QMenu::item:disabled { + background-color: transparent; +} + +QMenu::separator { + background-color: #333337; + height: 1px; + margin: 1px 0; +} + +QMenu::icon { + margin: 1px; +} + +QMenu::right-arrow { + image: url(./vs15/sub-menu-arrow.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 0.5em; +} + +QMenu QPushButton { + background-color: transparent; + border-color: #3f3f46; + margin: 1px 0 1px 0; +} + +QMenu QCheckBox, +QMenu QRadioButton { + background-color: transparent; + padding: 5px 2px; +} + +/* Tool tips #QToolTip, #SaveGameInfoWidget */ +QToolTip, +SaveGameInfoWidget { + background-color: #424245; + border-color: #4d4d50; + color: #f1f1f1; + border-style: solid; + border-width: 1px; + padding: 2px; +} + +QStatusBar::item { + border: None; +} + +/* Progress Bars (Downloads) #QProgressBar */ +QProgressBar { + background-color: #e6e6e6; + color: #000; + border-color: #bcbcbc; + text-align: center; + border-style: solid; + border-width: 1px; + margin: 0px; +} + +QProgressBar::chunk { + background: #06b025; +} + +DownloadListView[downloadView=standard]::item { + padding: 16px; +} + +DownloadListView[downloadView=compact]::item { + padding: 4px; +} + +/* Right Pane and Tab Bars #QTabWidget, #QTabBar */ +QTabWidget::pane { + border-color: #3f3f46; + border-top-color: #cc6600; + top: 0; + border-style: solid; + border-width: 1px; +} + +QTabWidget::pane:disabled { + border-top-color: #3f3f46; +} + +QTabBar::tab { + background-color: transparent; + padding: 4px 1em; + border: none; +} + +QTabBar::tab:hover { + background-color: #e67300; +} + +QTabBar::tab:selected, +QTabBar::tab:selected:hover { + background-color: #cc6600; +} + +QTabBar::tab:disabled { + background-color: transparent; + color: #656565; +} + +QTabBar::tab:selected:disabled { + background-color: #3f3f46; +} + +/* Scrollers */ +QTabBar QToolButton { + background-color: #333337; + border-color: #3f3f46; + padding: 1px; + margin: 0; + border-style: solid; + border-width: 1px; +} + +QTabBar QToolButton:hover { + border-color: #cc6600; + border-width: 1px; + border-style: solid; +} + +QTabBar QToolButton:disabled, +QTabBar QToolButton:pressed:hover { + background-color: #333337; +} + +QTabBar::scroller { + width: 23px; + background-color: red; +} + +QTabBar QToolButton::right-arrow { + image: url(./vs15/scrollbar-right.png); +} + +QTabBar QToolButton::right-arrow:hover { + image: url(./vs15/scrollbar-right-hover.png); +} + +QTabBar QToolButton::left-arrow { + image: url(./vs15/scrollbar-left.png); +} + +QTabBar QToolButton::left-arrow:hover { + image: url(./vs15/scrollbar-left-hover.png); +} + +/* Special styles */ +QWidget#tabImages QPushButton { + background-color: transparent; + margin: 0 0.3em; + padding: 0; +} + +/* like dialog QPushButton*/ +QWidget#tabESPs QToolButton { + color: #000000; + background-color: #dddddd; + border-color: #707070; + border-style: solid; + border-width: 1px; +} + +QWidget#tabESPs QToolButton:hover { + background-color: #BEE6FD; + border-color: #db6e00; +} + +QWidget#tabESPs QToolButton:focus { + background-color: #dddddd; + border-color: #cc6600; +} + +QWidget#tabESPs QToolButton:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QTreeWidget#categoriesList { + /* min-width: 225px; */ +} + +QTreeWidget#categoriesList::item { + background-position: center left; + background-repeat: no-repeat; + padding: 0.35em 10px; +} + +QTreeWidget#categoriesList::item:has-children { + background-image: url(./vs15/branch-closed.png); +} + +QTreeWidget#categoriesList::item:has-children:open { + background-image: url(./vs15/branch-open.png); +} + +QDialog#QueryOverwriteDialog QPushButton { + margin-left: 0.5em; +} + +QDialog#PyCfgDialog QPushButton:hover { + background-color: #BEE6FD; +} + +QLineEdit#modFilterEdit { + margin-top: 2px; +} + +/* highlight unchecked BSAs */ +QWidget#bsaTab QTreeWidget::indicator:unchecked { + background-color: #cc6600; +} + +/* increase version text field */ +QLineEdit#versionEdit { + max-width: 100px; +} + +/* Dialogs width changes */ +/* increase width to prevent buttons cutting */ +QDialog#QueryOverwriteDialog { + min-width: 565px; +} + +QDialog#ModInfoDialog { + min-width: 850px; +} + +QLineEdit[valid-filter=false] { + background-color: #661111 !important; +} diff --git a/src/src/stylesheets/vs15 Dark-Pink.qss b/src/src/stylesheets/vs15 Dark-Pink.qss new file mode 100644 index 0000000..5bc3bbd --- /dev/null +++ b/src/src/stylesheets/vs15 Dark-Pink.qss @@ -0,0 +1,920 @@ +/*!************************************* + VS15 Dark +**************************************** + Author: chintsu_kun, holt59, MO2 Team + Version: 2.5.0 + Licence: GNU General Public License v3.0 (https://www.gnu.org/licenses/gpl-3.0.en.html) + Url: https://github.com/nikolay-borzov/modorganizer-themes +**************************************** +*/ +/* For some reason applying background-color or border fixes paddings properties */ +QListWidget::item { + border-width: 0; +} + +/* Don't override install label on download widget. + MO2 assigns color depending on download state */ +#installLabel { + color: none; +} + +/* Make `background-color` work for :hover, :focus and :pressed states */ +QToolButton { + border: none; +} + +/* Main Window */ +QWidget { + background-color: #2d2d30; + color: #f1f1f1; +} + +QWidget::disabled { + color: #656565; +} + +/* Common */ +/* remove outline */ +* { + outline: 0; +} + +*:disabled, +QListView::item:disabled, +*::item:selected:disabled { + color: #656565; +} + +/* line heights */ +/* QTreeView#fileTree::item - currently have problem with size column vertical + text align */ +#bsaList::item, +#dataTree::item, +#modList::item, +#categoriesTree::item, +#savegameList::item, +#tabConflicts QTreeWidget::item { + padding: 0.3em 0; +} + +QListView::item, +QTreeView#espList::item { + padding: 0.3em 0; +} + +/* to enable border color */ +QTreeView, +QListView, +QTextEdit, +QWebView, +QTableView { + border-style: solid; + border-width: 1px; +} + +QAbstractItemView { + color: #dcdcdc; + background-color: #1e1e1e; + alternate-background-color: #262626; + border-color: #3f3f46; +} + +QAbstractItemView::item:selected, +QAbstractItemView::item:selected:hover, +QAbstractItemView::item:alternate:selected, +QAbstractItemView::item:alternate:selected:hover { + color: #f1f1f1; + background-color: #bb4287; +} + +QAbstractItemView[filtered=true] { + border: 2px solid #f00 !important; +} + +QAbstractItemView, +QListView, +QTreeView { + show-decoration-selected: 1; +} + +QAbstractItemView::item:hover, +QAbstractItemView::item:alternate:hover, +QAbstractItemView::item:disabled:hover, +QAbstractItemView::item:alternate:disabled:hover QListView::item:hover, +QTreeView::branch:hover, +QTreeWidget::item:hover { + background-color: rgba(187, 66, 135, 0.3); +} + +QAbstractItemView::item:selected:disabled, +QAbstractItemView::item:alternate:selected:disabled, +QListView::item:selected:disabled, +QTreeView::branch:selected:disabled, +QTreeWidget::item:selected:disabled { + background-color: rgba(187, 66, 135, 0.3); +} + +QTreeView::branch:selected, +#bsaList::branch:selected { + background-color: #bb4287; +} + +QLabel { + background-color: transparent; +} + +LinkLabel { + qproperty-linkColor: #bb4287; +} + +/* Left Pane & File Trees #QTreeView, #QListView*/ +QTreeView::branch:closed:has-children { + image: url(./vs15/branch-closed.png); +} + +QTreeView::branch:open:has-children { + image: url(./vs15/branch-open.png); +} + +QListView::item { + color: #f1f1f1; +} + +/* Text areas and text fields #QTextEdit, #QLineEdit, #QWebView */ +QTextEdit, +QWebView, +QLineEdit, +QAbstractSpinBox, +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button, +QComboBox { + background-color: #333337; + border-color: #3f3f46; +} + +QLineEdit:hover, +QAbstractSpinBox:hover, +QTextEdit:hover, +QComboBox:hover, +QComboBox:editable:hover { + border-color: #bb4287; +} + +QLineEdit:focus, +QAbstractSpinBox::focus, +QTextEdit:focus, +QComboBox:focus, +QComboBox:editable:focus, +QComboBox:on { + background-color: #3f3f46; + border-color: #bb4287; +} + +QComboBox:on { + border-bottom-color: #3f3f46; +} + +QLineEdit, +QAbstractSpinBox { + min-height: 15px; + padding: 2px; + border-style: solid; + border-width: 1px; +} + +QLineEdit { + margin-top: 0; +} + +/* clear button */ +QLineEdit QToolButton, +QLineEdit QToolButton:hover { + background: none; + margin-top: 1px; +} + +QLineEdit#espFilterEdit QToolButton { + margin-top: -2px; + margin-bottom: 1px; +} + +/* Drop-downs #QComboBox*/ +QComboBox { + min-height: 20px; + padding-left: 5px; + margin: 3px 0 1px 0; + border-style: solid; + border-width: 1px; +} + +QComboBox:editable { + padding-left: 3px; + /* to enable hover styles */ + background-color: transparent; +} + +QComboBox::drop-down { + width: 20px; + subcontrol-origin: padding; + subcontrol-position: top right; + border: none; +} + +QComboBox::down-arrow { + image: url(./vs15/combobox-down.png); +} + +QComboBox QAbstractItemView { + background-color: #1b1b1c; + selection-background-color: #3f3f46; + border-color: #bb4287; + border-style: solid; + border-width: 0 1px 1px 1px; +} + +/* Doesn't work http://stackoverflow.com/questions/13308341/qcombobox-abstractitemviewitem */ +/* QComboBox QAbstractItemView:item { + padding: 10px; + margin: 10px; +} */ +/* Toolbar */ +QToolBar { + border: none; +} + +QToolBar::separator { + border-left-color: #222222; + border-right-color: #46464a; + border-width: 0 1px 0 1px; + border-style: solid; + width: 0; +} + +QToolButton { + padding: 4px; +} + +QToolButton:hover, QToolButton:focus { + background-color: #3e3e40; +} + +QToolButton:pressed { + background-color: #bb4287; +} + +QToolButton::menu-indicator { + image: url(./vs15/combobox-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-top: 10%; + padding-right: 5%; +} + +/* Group Boxes #QGroupBox */ +QGroupBox { + border-color: #3f3f46; + border-style: solid; + border-width: 1px; + padding: 1em 0.3em 0.3em 0.3em; + margin-top: 0.65em; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 2px; + left: 10px; +} + +/* LCD Count */ +QLCDNumber { + border-color: #3f3f46; + border-style: solid; + border-width: 1px; +} + +/* Buttons #QPushButton */ +QPushButton { + background-color: #333337; + border-color: #3f3f46; + min-height: 18px; + padding: 2px 5px; + border-style: solid; + border-width: 1px; +} + +QPushButton:hover, +QPushButton:checked, +QPushButton:focus, +QAbstractSpinBox::up-button:hover, +QAbstractSpinBox::down-button:hover { + background-color: #bb4287; +} + +QPushButton:pressed, +QPushButton:checked:hover, +QAbstractSpinBox::up-button:pressed, +QAbstractSpinBox::down-button:pressed { + background-color: #a83b79; +} + +QPushButton:disabled, +QAbstractSpinBox::up-button:disabled, +QAbstractSpinBox::down-button:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QPushButton::menu-indicator { + image: url(./vs15/combobox-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 5%; +} + +/* Dialog buttons */ +QDialog QPushButton, +QSlider::handle:horizontal, +QSlider::handle:vertical { + color: #000000; + background-color: #dddddd; + border-color: #707070; + border-style: solid; + border-width: 1px; +} + +QDialog QPushButton:hover, +QSlider::handle:horizontal:hover, +QSlider::handle:vertical:hover, +QSlider::handle:horizontal:pressed, +QSlider::handle:horizontal:focus:pressed, +QSlider::handle:vertical:pressed, +QSlider::handle:vertical:focus:pressed { + background-color: #BEE6FD; + border-color: #bf498c; +} + +QSlider::handle:horizontal:focus, +QSlider::handle:vertical:focus, +QDialog QPushButton:focus, +QDialog QPushButton:checked { + background-color: #dddddd; + border-color: #bb4287; +} + +QDialog QPushButton:disabled, +QSlider::handle:horizontal:disabled, +QSlider::handle:vertical:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QDialog QPushButton { + min-width: 1.5em; + padding-left: 0.5em; + padding-right: 0.5em; +} + +/* Check boxes and Radio buttons common #QCheckBox, #QRadioButton */ +QGroupBox::indicator, +QTreeView::indicator, +QCheckBox::indicator, +QRadioButton::indicator { + background-color: #2d2d30; + border-color: #3f3f46; + width: 13px; + height: 13px; + border-style: solid; + border-width: 1px; +} + +QGroupBox::indicator:hover, +QTreeView::indicator:hover, +QCheckBox::indicator:hover, +QRadioButton::indicator:hover { + background-color: #3f3f46; + border-color: #bb4287; +} + +QGroupBox::indicator:checked, +QTreeView::indicator:checked, +QCheckBox::indicator:checked { + image: url(./vs15/checkbox-check.png); +} + +QGroupBox::indicator:disabled, +QTreeView::indicator:checked:disabled, +QCheckBox::indicator:checked:disabled { + image: url(./vs15/checkbox-check-disabled.png); +} + +/* Check boxes special */ +QTreeView#modList::indicator { + width: 15px; + height: 15px; +} + +/* Radio buttons #QRadioButton */ +QRadioButton::indicator { + border-radius: 7px; +} + +QRadioButton::indicator::checked { + background-color: #B9B9BA; + border-width: 2px; + width: 11px; + height: 11px; +} + +QRadioButton::indicator::checked:hover { + border-color: #3f3f46; +} + +/* Spinners #QSpinBox, #QDoubleSpinBox */ +QAbstractSpinBox { + margin: 1px; +} + +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button { + border-style: solid; + border-width: 1px; + subcontrol-origin: padding; +} + +QAbstractSpinBox::up-button { + subcontrol-position: top right; +} + +QAbstractSpinBox::up-arrow { + image: url(./vs15/spinner-up.png); +} + +QAbstractSpinBox::down-button { + subcontrol-position: bottom right; +} + +QAbstractSpinBox::down-arrow { + image: url(./vs15/spinner-down.png); +} + +/* Sliders #QSlider */ +QSlider::groove:horizontal { + background-color: #3f3f46; + border: none; + height: 8px; + margin: 2px 0; +} + +QSlider::handle:horizontal { + width: 0.5em; + height: 2em; + margin: -7px 0; + subcontrol-origin: margin; +} + +/* Scroll Bars #QAbstractScrollArea, #QScrollBar*/ +/* assigning background still leaves not filled area*/ +QAbstractScrollArea::corner { + background-color: transparent; +} + +/* Horizontal */ +QScrollBar:horizontal { + height: 18px; + border: none; + margin: 0 23px 0 23px; +} + +QScrollBar::handle:horizontal { + min-width: 32px; + margin: 4px 2px; +} + +QScrollBar::add-line:horizontal { + width: 23px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal { + width: 23px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +/* Vertical */ +QScrollBar:vertical { + width: 20px; + border: none; + margin: 23px 0 23px 0; +} + +QScrollBar::handle:vertical { + min-height: 32px; + margin: 2px 4px; +} + +QScrollBar::add-line:vertical { + height: 23px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:vertical { + height: 23px; + subcontrol-position: top; + subcontrol-origin: margin; +} + +/* Combined */ +QScrollBar { + background-color: #3e3e42; + border: none; +} + +QScrollBar::handle { + background-color: #686868; +} + +QScrollBar::add-line, +QScrollBar::sub-line { + background-color: #3e3e42; + border: none; +} + +/* QScrollBar::add-line:horizontal:hover, +QScrollBar::sub-line:horizontal:hover, +QScrollBar::add-line:vertical:hover, +QScrollBar::sub-line:vertical:hover, +QScrollBar::add-line:horizontal:pressed, +QScrollBar::sub-line:horizontal:pressed, +QScrollBar::add-line:vertical:pressed, +QScrollBar::sub-line:vertical:pressed { } */ +QScrollBar::handle:hover { + background: #9e9e9e; +} + +QScrollBar::handle:pressed { + background: #efebef; +} + +QScrollBar::handle:disabled { + background: #555558; +} + +QScrollBar::add-page, +QScrollBar::sub-page { + background: transparent; +} + +QScrollBar::up-arrow:vertical { + image: url(./vs15/scrollbar-up.png); +} + +QScrollBar::up-arrow:vertical:hover { + image: url(./vs15/scrollbar-up-hover.png); +} + +QScrollBar::up-arrow:vertical:disabled { + image: url(./vs15/scrollbar-up-disabled.png); +} + +QScrollBar::right-arrow:horizontal { + image: url(./vs15/scrollbar-right.png); +} + +QScrollBar::right-arrow:horizontal:hover { + image: url(./vs15/scrollbar-right-hover.png); +} + +QScrollBar::right-arrow:horizontal:disabled { + image: url(./vs15/scrollbar-right-disabled.png); +} + +QScrollBar::down-arrow:vertical { + image: url(./vs15/scrollbar-down.png); +} + +QScrollBar::down-arrow:vertical:hover { + image: url(./vs15/scrollbar-down-hover.png); +} + +QScrollBar::down-arrow:vertical:disabled { + image: url(./vs15/scrollbar-down-disabled.png); +} + +QScrollBar::left-arrow:horizontal { + image: url(./vs15/scrollbar-left.png); +} + +QScrollBar::left-arrow:horizontal:hover { + image: url(./vs15/scrollbar-left-hover.png); +} + +QScrollBar::left-arrow:horizontal:disabled { + image: url(./vs15/scrollbar-left-disabled.png); +} + +/* Header Rows and Tables (Configure Mod Categories) #QTableView, #QHeaderView */ +QTableView { + gridline-color: #3f3f46; + selection-background-color: #bb4287; + selection-color: #f1f1f1; +} + +QTableView QTableCornerButton::section { + background: #252526; + border-color: #3f3f46; + border-style: solid; + border-width: 0 1px 1px 0; +} + +QHeaderView { + border: none; +} + +QHeaderView::section { + background: #252526; + border-color: #3f3f46; + padding: 3px 5px; + border-style: solid; + border-width: 0 1px 1px 0; +} + +QHeaderView::section:hover { + background: #3e3e40; + color: #f6f6f6; +} + +QHeaderView::section:last { + border-right: 0; +} + +QHeaderView::up-arrow { + image: url(./vs15/sort-asc.png); + margin-bottom: -37px; +} + +DownloadListView QHeaderView::up-arrow { + margin-bottom: -47px; +} + +QHeaderView::down-arrow { + image: url(./vs15/sort-desc.png); + margin-bottom: 0; +} + +/* Context menus, toolbar drop-downs #QMenu */ +QMenu { + background-color: #1a1a1c; + border-color: #333337; + border-style: solid; + border-width: 1px; + padding: 2px; +} + +QMenu::item { + background: transparent; + padding: 4px 20px; +} + +QMenu::item:selected, +QMenuBar::item:selected { + background-color: #333334; +} + +QMenu::item:disabled { + background-color: transparent; +} + +QMenu::separator { + background-color: #333337; + height: 1px; + margin: 1px 0; +} + +QMenu::icon { + margin: 1px; +} + +QMenu::right-arrow { + image: url(./vs15/sub-menu-arrow.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 0.5em; +} + +QMenu QPushButton { + background-color: transparent; + border-color: #3f3f46; + margin: 1px 0 1px 0; +} + +QMenu QCheckBox, +QMenu QRadioButton { + background-color: transparent; + padding: 5px 2px; +} + +/* Tool tips #QToolTip, #SaveGameInfoWidget */ +QToolTip, +SaveGameInfoWidget { + background-color: #424245; + border-color: #4d4d50; + color: #f1f1f1; + border-style: solid; + border-width: 1px; + padding: 2px; +} + +QStatusBar::item { + border: None; +} + +/* Progress Bars (Downloads) #QProgressBar */ +QProgressBar { + background-color: #e6e6e6; + color: #000; + border-color: #bcbcbc; + text-align: center; + border-style: solid; + border-width: 1px; + margin: 0px; +} + +QProgressBar::chunk { + background: #06b025; +} + +DownloadListView[downloadView=standard]::item { + padding: 16px; +} + +DownloadListView[downloadView=compact]::item { + padding: 4px; +} + +/* Right Pane and Tab Bars #QTabWidget, #QTabBar */ +QTabWidget::pane { + border-color: #3f3f46; + border-top-color: #bb4287; + top: 0; + border-style: solid; + border-width: 1px; +} + +QTabWidget::pane:disabled { + border-top-color: #3f3f46; +} + +QTabBar::tab { + background-color: transparent; + padding: 4px 1em; + border: none; +} + +QTabBar::tab:hover { + background-color: #a83b79; +} + +QTabBar::tab:selected, +QTabBar::tab:selected:hover { + background-color: #bb4287; +} + +QTabBar::tab:disabled { + background-color: transparent; + color: #656565; +} + +QTabBar::tab:selected:disabled { + background-color: #3f3f46; +} + +/* Scrollers */ +QTabBar QToolButton { + background-color: #333337; + border-color: #3f3f46; + padding: 1px; + margin: 0; + border-style: solid; + border-width: 1px; +} + +QTabBar QToolButton:hover { + border-color: #bb4287; + border-width: 1px; + border-style: solid; +} + +QTabBar QToolButton:disabled, +QTabBar QToolButton:pressed:hover { + background-color: #333337; +} + +QTabBar::scroller { + width: 23px; + background-color: red; +} + +QTabBar QToolButton::right-arrow { + image: url(./vs15/scrollbar-right.png); +} + +QTabBar QToolButton::right-arrow:hover { + image: url(./vs15/scrollbar-right-hover.png); +} + +QTabBar QToolButton::left-arrow { + image: url(./vs15/scrollbar-left.png); +} + +QTabBar QToolButton::left-arrow:hover { + image: url(./vs15/scrollbar-left-hover.png); +} + +/* Special styles */ +QWidget#tabImages QPushButton { + background-color: transparent; + margin: 0 0.3em; + padding: 0; +} + +/* like dialog QPushButton*/ +QWidget#tabESPs QToolButton { + color: #000000; + background-color: #dddddd; + border-color: #707070; + border-style: solid; + border-width: 1px; +} + +QWidget#tabESPs QToolButton:hover { + background-color: #BEE6FD; + border-color: #bf498c; +} + +QWidget#tabESPs QToolButton:focus { + background-color: #dddddd; + border-color: #bb4287; +} + +QWidget#tabESPs QToolButton:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QTreeWidget#categoriesList { + /* min-width: 225px; */ +} + +QTreeWidget#categoriesList::item { + background-position: center left; + background-repeat: no-repeat; + padding: 0.35em 10px; +} + +QTreeWidget#categoriesList::item:has-children { + background-image: url(./vs15/branch-closed.png); +} + +QTreeWidget#categoriesList::item:has-children:open { + background-image: url(./vs15/branch-open.png); +} + +QDialog#QueryOverwriteDialog QPushButton { + margin-left: 0.5em; +} + +QDialog#PyCfgDialog QPushButton:hover { + background-color: #BEE6FD; +} + +QLineEdit#modFilterEdit { + margin-top: 2px; +} + +/* highlight unchecked BSAs */ +QWidget#bsaTab QTreeWidget::indicator:unchecked { + background-color: #bb4287; +} + +/* increase version text field */ +QLineEdit#versionEdit { + max-width: 100px; +} + +/* Dialogs width changes */ +/* increase width to prevent buttons cutting */ +QDialog#QueryOverwriteDialog { + min-width: 565px; +} + +QDialog#ModInfoDialog { + min-width: 850px; +} + +QLineEdit[valid-filter=false] { + background-color: #661111 !important; +} diff --git a/src/src/stylesheets/vs15 Dark-Purple.qss b/src/src/stylesheets/vs15 Dark-Purple.qss new file mode 100644 index 0000000..afcf897 --- /dev/null +++ b/src/src/stylesheets/vs15 Dark-Purple.qss @@ -0,0 +1,920 @@ +/*!************************************* + VS15 Dark +**************************************** + Author: chintsu_kun, holt59, MO2 Team + Version: 2.5.0 + Licence: GNU General Public License v3.0 (https://www.gnu.org/licenses/gpl-3.0.en.html) + Url: https://github.com/nikolay-borzov/modorganizer-themes +**************************************** +*/ +/* For some reason applying background-color or border fixes paddings properties */ +QListWidget::item { + border-width: 0; +} + +/* Don't override install label on download widget. + MO2 assigns color depending on download state */ +#installLabel { + color: none; +} + +/* Make `background-color` work for :hover, :focus and :pressed states */ +QToolButton { + border: none; +} + +/* Main Window */ +QWidget { + background-color: #2d2d30; + color: #f1f1f1; +} + +QWidget::disabled { + color: #656565; +} + +/* Common */ +/* remove outline */ +* { + outline: 0; +} + +*:disabled, +QListView::item:disabled, +*::item:selected:disabled { + color: #656565; +} + +/* line heights */ +/* QTreeView#fileTree::item - currently have problem with size column vertical + text align */ +#bsaList::item, +#dataTree::item, +#modList::item, +#categoriesTree::item, +#savegameList::item, +#tabConflicts QTreeWidget::item { + padding: 0.3em 0; +} + +QListView::item, +QTreeView#espList::item { + padding: 0.3em 0; +} + +/* to enable border color */ +QTreeView, +QListView, +QTextEdit, +QWebView, +QTableView { + border-style: solid; + border-width: 1px; +} + +QAbstractItemView { + color: #dcdcdc; + background-color: #1e1e1e; + alternate-background-color: #262626; + border-color: #3f3f46; +} + +QAbstractItemView::item:selected, +QAbstractItemView::item:selected:hover, +QAbstractItemView::item:alternate:selected, +QAbstractItemView::item:alternate:selected:hover { + color: #f1f1f1; + background-color: #7e2ad2; +} + +QAbstractItemView[filtered=true] { + border: 2px solid #f00 !important; +} + +QAbstractItemView, +QListView, +QTreeView { + show-decoration-selected: 1; +} + +QAbstractItemView::item:hover, +QAbstractItemView::item:alternate:hover, +QAbstractItemView::item:disabled:hover, +QAbstractItemView::item:alternate:disabled:hover QListView::item:hover, +QTreeView::branch:hover, +QTreeWidget::item:hover { + background-color: rgba(126, 42, 210, 0.3); +} + +QAbstractItemView::item:selected:disabled, +QAbstractItemView::item:alternate:selected:disabled, +QListView::item:selected:disabled, +QTreeView::branch:selected:disabled, +QTreeWidget::item:selected:disabled { + background-color: rgba(126, 42, 210, 0.3); +} + +QTreeView::branch:selected, +#bsaList::branch:selected { + background-color: #7e2ad2; +} + +QLabel { + background-color: transparent; +} + +LinkLabel { + qproperty-linkColor: #7e2ad2; +} + +/* Left Pane & File Trees #QTreeView, #QListView*/ +QTreeView::branch:closed:has-children { + image: url(./vs15/branch-closed.png); +} + +QTreeView::branch:open:has-children { + image: url(./vs15/branch-open.png); +} + +QListView::item { + color: #f1f1f1; +} + +/* Text areas and text fields #QTextEdit, #QLineEdit, #QWebView */ +QTextEdit, +QWebView, +QLineEdit, +QAbstractSpinBox, +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button, +QComboBox { + background-color: #333337; + border-color: #3f3f46; +} + +QLineEdit:hover, +QAbstractSpinBox:hover, +QTextEdit:hover, +QComboBox:hover, +QComboBox:editable:hover { + border-color: #7e2ad2; +} + +QLineEdit:focus, +QAbstractSpinBox::focus, +QTextEdit:focus, +QComboBox:focus, +QComboBox:editable:focus, +QComboBox:on { + background-color: #3f3f46; + border-color: #7e2ad2; +} + +QComboBox:on { + border-bottom-color: #3f3f46; +} + +QLineEdit, +QAbstractSpinBox { + min-height: 15px; + padding: 2px; + border-style: solid; + border-width: 1px; +} + +QLineEdit { + margin-top: 0; +} + +/* clear button */ +QLineEdit QToolButton, +QLineEdit QToolButton:hover { + background: none; + margin-top: 1px; +} + +QLineEdit#espFilterEdit QToolButton { + margin-top: -2px; + margin-bottom: 1px; +} + +/* Drop-downs #QComboBox*/ +QComboBox { + min-height: 20px; + padding-left: 5px; + margin: 3px 0 1px 0; + border-style: solid; + border-width: 1px; +} + +QComboBox:editable { + padding-left: 3px; + /* to enable hover styles */ + background-color: transparent; +} + +QComboBox::drop-down { + width: 20px; + subcontrol-origin: padding; + subcontrol-position: top right; + border: none; +} + +QComboBox::down-arrow { + image: url(./vs15/combobox-down.png); +} + +QComboBox QAbstractItemView { + background-color: #1b1b1c; + selection-background-color: #3f3f46; + border-color: #7e2ad2; + border-style: solid; + border-width: 0 1px 1px 1px; +} + +/* Doesn't work http://stackoverflow.com/questions/13308341/qcombobox-abstractitemviewitem */ +/* QComboBox QAbstractItemView:item { + padding: 10px; + margin: 10px; +} */ +/* Toolbar */ +QToolBar { + border: none; +} + +QToolBar::separator { + border-left-color: #222222; + border-right-color: #46464a; + border-width: 0 1px 0 1px; + border-style: solid; + width: 0; +} + +QToolButton { + padding: 4px; +} + +QToolButton:hover, QToolButton:focus { + background-color: #3e3e40; +} + +QToolButton:pressed { + background-color: #7e2ad2; +} + +QToolButton::menu-indicator { + image: url(./vs15/combobox-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-top: 10%; + padding-right: 5%; +} + +/* Group Boxes #QGroupBox */ +QGroupBox { + border-color: #3f3f46; + border-style: solid; + border-width: 1px; + padding: 1em 0.3em 0.3em 0.3em; + margin-top: 0.65em; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 2px; + left: 10px; +} + +/* LCD Count */ +QLCDNumber { + border-color: #3f3f46; + border-style: solid; + border-width: 1px; +} + +/* Buttons #QPushButton */ +QPushButton { + background-color: #333337; + border-color: #3f3f46; + min-height: 18px; + padding: 2px 5px; + border-style: solid; + border-width: 1px; +} + +QPushButton:hover, +QPushButton:checked, +QPushButton:focus, +QAbstractSpinBox::up-button:hover, +QAbstractSpinBox::down-button:hover { + background-color: #7e2ad2; +} + +QPushButton:pressed, +QPushButton:checked:hover, +QAbstractSpinBox::up-button:pressed, +QAbstractSpinBox::down-button:pressed { + background-color: #8b3dd8; +} + +QPushButton:disabled, +QAbstractSpinBox::up-button:disabled, +QAbstractSpinBox::down-button:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QPushButton::menu-indicator { + image: url(./vs15/combobox-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 5%; +} + +/* Dialog buttons */ +QDialog QPushButton, +QSlider::handle:horizontal, +QSlider::handle:vertical { + color: #000000; + background-color: #dddddd; + border-color: #707070; + border-style: solid; + border-width: 1px; +} + +QDialog QPushButton:hover, +QSlider::handle:horizontal:hover, +QSlider::handle:vertical:hover, +QSlider::handle:horizontal:pressed, +QSlider::handle:horizontal:focus:pressed, +QSlider::handle:vertical:pressed, +QSlider::handle:vertical:focus:pressed { + background-color: #BEE6FD; + border-color: #8635d7; +} + +QSlider::handle:horizontal:focus, +QSlider::handle:vertical:focus, +QDialog QPushButton:focus, +QDialog QPushButton:checked { + background-color: #dddddd; + border-color: #7e2ad2; +} + +QDialog QPushButton:disabled, +QSlider::handle:horizontal:disabled, +QSlider::handle:vertical:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QDialog QPushButton { + min-width: 1.5em; + padding-left: 0.5em; + padding-right: 0.5em; +} + +/* Check boxes and Radio buttons common #QCheckBox, #QRadioButton */ +QGroupBox::indicator, +QTreeView::indicator, +QCheckBox::indicator, +QRadioButton::indicator { + background-color: #2d2d30; + border-color: #3f3f46; + width: 13px; + height: 13px; + border-style: solid; + border-width: 1px; +} + +QGroupBox::indicator:hover, +QTreeView::indicator:hover, +QCheckBox::indicator:hover, +QRadioButton::indicator:hover { + background-color: #3f3f46; + border-color: #7e2ad2; +} + +QGroupBox::indicator:checked, +QTreeView::indicator:checked, +QCheckBox::indicator:checked { + image: url(./vs15/checkbox-check.png); +} + +QGroupBox::indicator:disabled, +QTreeView::indicator:checked:disabled, +QCheckBox::indicator:checked:disabled { + image: url(./vs15/checkbox-check-disabled.png); +} + +/* Check boxes special */ +QTreeView#modList::indicator { + width: 15px; + height: 15px; +} + +/* Radio buttons #QRadioButton */ +QRadioButton::indicator { + border-radius: 7px; +} + +QRadioButton::indicator::checked { + background-color: #B9B9BA; + border-width: 2px; + width: 11px; + height: 11px; +} + +QRadioButton::indicator::checked:hover { + border-color: #3f3f46; +} + +/* Spinners #QSpinBox, #QDoubleSpinBox */ +QAbstractSpinBox { + margin: 1px; +} + +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button { + border-style: solid; + border-width: 1px; + subcontrol-origin: padding; +} + +QAbstractSpinBox::up-button { + subcontrol-position: top right; +} + +QAbstractSpinBox::up-arrow { + image: url(./vs15/spinner-up.png); +} + +QAbstractSpinBox::down-button { + subcontrol-position: bottom right; +} + +QAbstractSpinBox::down-arrow { + image: url(./vs15/spinner-down.png); +} + +/* Sliders #QSlider */ +QSlider::groove:horizontal { + background-color: #3f3f46; + border: none; + height: 8px; + margin: 2px 0; +} + +QSlider::handle:horizontal { + width: 0.5em; + height: 2em; + margin: -7px 0; + subcontrol-origin: margin; +} + +/* Scroll Bars #QAbstractScrollArea, #QScrollBar*/ +/* assigning background still leaves not filled area*/ +QAbstractScrollArea::corner { + background-color: transparent; +} + +/* Horizontal */ +QScrollBar:horizontal { + height: 18px; + border: none; + margin: 0 23px 0 23px; +} + +QScrollBar::handle:horizontal { + min-width: 32px; + margin: 4px 2px; +} + +QScrollBar::add-line:horizontal { + width: 23px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal { + width: 23px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +/* Vertical */ +QScrollBar:vertical { + width: 20px; + border: none; + margin: 23px 0 23px 0; +} + +QScrollBar::handle:vertical { + min-height: 32px; + margin: 2px 4px; +} + +QScrollBar::add-line:vertical { + height: 23px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:vertical { + height: 23px; + subcontrol-position: top; + subcontrol-origin: margin; +} + +/* Combined */ +QScrollBar { + background-color: #3e3e42; + border: none; +} + +QScrollBar::handle { + background-color: #686868; +} + +QScrollBar::add-line, +QScrollBar::sub-line { + background-color: #3e3e42; + border: none; +} + +/* QScrollBar::add-line:horizontal:hover, +QScrollBar::sub-line:horizontal:hover, +QScrollBar::add-line:vertical:hover, +QScrollBar::sub-line:vertical:hover, +QScrollBar::add-line:horizontal:pressed, +QScrollBar::sub-line:horizontal:pressed, +QScrollBar::add-line:vertical:pressed, +QScrollBar::sub-line:vertical:pressed { } */ +QScrollBar::handle:hover { + background: #9e9e9e; +} + +QScrollBar::handle:pressed { + background: #efebef; +} + +QScrollBar::handle:disabled { + background: #555558; +} + +QScrollBar::add-page, +QScrollBar::sub-page { + background: transparent; +} + +QScrollBar::up-arrow:vertical { + image: url(./vs15/scrollbar-up.png); +} + +QScrollBar::up-arrow:vertical:hover { + image: url(./vs15/scrollbar-up-hover.png); +} + +QScrollBar::up-arrow:vertical:disabled { + image: url(./vs15/scrollbar-up-disabled.png); +} + +QScrollBar::right-arrow:horizontal { + image: url(./vs15/scrollbar-right.png); +} + +QScrollBar::right-arrow:horizontal:hover { + image: url(./vs15/scrollbar-right-hover.png); +} + +QScrollBar::right-arrow:horizontal:disabled { + image: url(./vs15/scrollbar-right-disabled.png); +} + +QScrollBar::down-arrow:vertical { + image: url(./vs15/scrollbar-down.png); +} + +QScrollBar::down-arrow:vertical:hover { + image: url(./vs15/scrollbar-down-hover.png); +} + +QScrollBar::down-arrow:vertical:disabled { + image: url(./vs15/scrollbar-down-disabled.png); +} + +QScrollBar::left-arrow:horizontal { + image: url(./vs15/scrollbar-left.png); +} + +QScrollBar::left-arrow:horizontal:hover { + image: url(./vs15/scrollbar-left-hover.png); +} + +QScrollBar::left-arrow:horizontal:disabled { + image: url(./vs15/scrollbar-left-disabled.png); +} + +/* Header Rows and Tables (Configure Mod Categories) #QTableView, #QHeaderView */ +QTableView { + gridline-color: #3f3f46; + selection-background-color: #7e2ad2; + selection-color: #f1f1f1; +} + +QTableView QTableCornerButton::section { + background: #252526; + border-color: #3f3f46; + border-style: solid; + border-width: 0 1px 1px 0; +} + +QHeaderView { + border: none; +} + +QHeaderView::section { + background: #252526; + border-color: #3f3f46; + padding: 3px 5px; + border-style: solid; + border-width: 0 1px 1px 0; +} + +QHeaderView::section:hover { + background: #3e3e40; + color: #f6f6f6; +} + +QHeaderView::section:last { + border-right: 0; +} + +QHeaderView::up-arrow { + image: url(./vs15/sort-asc.png); + margin-bottom: -37px; +} + +DownloadListView QHeaderView::up-arrow { + margin-bottom: -47px; +} + +QHeaderView::down-arrow { + image: url(./vs15/sort-desc.png); + margin-bottom: 0; +} + +/* Context menus, toolbar drop-downs #QMenu */ +QMenu { + background-color: #1a1a1c; + border-color: #333337; + border-style: solid; + border-width: 1px; + padding: 2px; +} + +QMenu::item { + background: transparent; + padding: 4px 20px; +} + +QMenu::item:selected, +QMenuBar::item:selected { + background-color: #333334; +} + +QMenu::item:disabled { + background-color: transparent; +} + +QMenu::separator { + background-color: #333337; + height: 1px; + margin: 1px 0; +} + +QMenu::icon { + margin: 1px; +} + +QMenu::right-arrow { + image: url(./vs15/sub-menu-arrow.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 0.5em; +} + +QMenu QPushButton { + background-color: transparent; + border-color: #3f3f46; + margin: 1px 0 1px 0; +} + +QMenu QCheckBox, +QMenu QRadioButton { + background-color: transparent; + padding: 5px 2px; +} + +/* Tool tips #QToolTip, #SaveGameInfoWidget */ +QToolTip, +SaveGameInfoWidget { + background-color: #424245; + border-color: #4d4d50; + color: #f1f1f1; + border-style: solid; + border-width: 1px; + padding: 2px; +} + +QStatusBar::item { + border: None; +} + +/* Progress Bars (Downloads) #QProgressBar */ +QProgressBar { + background-color: #e6e6e6; + color: #000; + border-color: #bcbcbc; + text-align: center; + border-style: solid; + border-width: 1px; + margin: 0px; +} + +QProgressBar::chunk { + background: #06b025; +} + +DownloadListView[downloadView=standard]::item { + padding: 16px; +} + +DownloadListView[downloadView=compact]::item { + padding: 4px; +} + +/* Right Pane and Tab Bars #QTabWidget, #QTabBar */ +QTabWidget::pane { + border-color: #3f3f46; + border-top-color: #7e2ad2; + top: 0; + border-style: solid; + border-width: 1px; +} + +QTabWidget::pane:disabled { + border-top-color: #3f3f46; +} + +QTabBar::tab { + background-color: transparent; + padding: 4px 1em; + border: none; +} + +QTabBar::tab:hover { + background-color: #8b3dd8; +} + +QTabBar::tab:selected, +QTabBar::tab:selected:hover { + background-color: #7e2ad2; +} + +QTabBar::tab:disabled { + background-color: transparent; + color: #656565; +} + +QTabBar::tab:selected:disabled { + background-color: #3f3f46; +} + +/* Scrollers */ +QTabBar QToolButton { + background-color: #333337; + border-color: #3f3f46; + padding: 1px; + margin: 0; + border-style: solid; + border-width: 1px; +} + +QTabBar QToolButton:hover { + border-color: #7e2ad2; + border-width: 1px; + border-style: solid; +} + +QTabBar QToolButton:disabled, +QTabBar QToolButton:pressed:hover { + background-color: #333337; +} + +QTabBar::scroller { + width: 23px; + background-color: red; +} + +QTabBar QToolButton::right-arrow { + image: url(./vs15/scrollbar-right.png); +} + +QTabBar QToolButton::right-arrow:hover { + image: url(./vs15/scrollbar-right-hover.png); +} + +QTabBar QToolButton::left-arrow { + image: url(./vs15/scrollbar-left.png); +} + +QTabBar QToolButton::left-arrow:hover { + image: url(./vs15/scrollbar-left-hover.png); +} + +/* Special styles */ +QWidget#tabImages QPushButton { + background-color: transparent; + margin: 0 0.3em; + padding: 0; +} + +/* like dialog QPushButton*/ +QWidget#tabESPs QToolButton { + color: #000000; + background-color: #dddddd; + border-color: #707070; + border-style: solid; + border-width: 1px; +} + +QWidget#tabESPs QToolButton:hover { + background-color: #BEE6FD; + border-color: #8635d7; +} + +QWidget#tabESPs QToolButton:focus { + background-color: #dddddd; + border-color: #7e2ad2; +} + +QWidget#tabESPs QToolButton:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QTreeWidget#categoriesList { + /* min-width: 225px; */ +} + +QTreeWidget#categoriesList::item { + background-position: center left; + background-repeat: no-repeat; + padding: 0.35em 10px; +} + +QTreeWidget#categoriesList::item:has-children { + background-image: url(./vs15/branch-closed.png); +} + +QTreeWidget#categoriesList::item:has-children:open { + background-image: url(./vs15/branch-open.png); +} + +QDialog#QueryOverwriteDialog QPushButton { + margin-left: 0.5em; +} + +QDialog#PyCfgDialog QPushButton:hover { + background-color: #BEE6FD; +} + +QLineEdit#modFilterEdit { + margin-top: 2px; +} + +/* highlight unchecked BSAs */ +QWidget#bsaTab QTreeWidget::indicator:unchecked { + background-color: #7e2ad2; +} + +/* increase version text field */ +QLineEdit#versionEdit { + max-width: 100px; +} + +/* Dialogs width changes */ +/* increase width to prevent buttons cutting */ +QDialog#QueryOverwriteDialog { + min-width: 565px; +} + +QDialog#ModInfoDialog { + min-width: 850px; +} + +QLineEdit[valid-filter=false] { + background-color: #661111 !important; +} diff --git a/src/src/stylesheets/vs15 Dark-Red.qss b/src/src/stylesheets/vs15 Dark-Red.qss new file mode 100644 index 0000000..d6f89f6 --- /dev/null +++ b/src/src/stylesheets/vs15 Dark-Red.qss @@ -0,0 +1,920 @@ +/*!************************************* + VS15 Dark +**************************************** + Author: chintsu_kun, holt59, MO2 Team + Version: 2.5.0 + Licence: GNU General Public License v3.0 (https://www.gnu.org/licenses/gpl-3.0.en.html) + Url: https://github.com/nikolay-borzov/modorganizer-themes +**************************************** +*/ +/* For some reason applying background-color or border fixes paddings properties */ +QListWidget::item { + border-width: 0; +} + +/* Don't override install label on download widget. + MO2 assigns color depending on download state */ +#installLabel { + color: none; +} + +/* Make `background-color` work for :hover, :focus and :pressed states */ +QToolButton { + border: none; +} + +/* Main Window */ +QWidget { + background-color: #2d2d30; + color: #f1f1f1; +} + +QWidget::disabled { + color: #656565; +} + +/* Common */ +/* remove outline */ +* { + outline: 0; +} + +*:disabled, +QListView::item:disabled, +*::item:selected:disabled { + color: #656565; +} + +/* line heights */ +/* QTreeView#fileTree::item - currently have problem with size column vertical + text align */ +#bsaList::item, +#dataTree::item, +#modList::item, +#categoriesTree::item, +#savegameList::item, +#tabConflicts QTreeWidget::item { + padding: 0.3em 0; +} + +QListView::item, +QTreeView#espList::item { + padding: 0.3em 0; +} + +/* to enable border color */ +QTreeView, +QListView, +QTextEdit, +QWebView, +QTableView { + border-style: solid; + border-width: 1px; +} + +QAbstractItemView { + color: #dcdcdc; + background-color: #1e1e1e; + alternate-background-color: #262626; + border-color: #3f3f46; +} + +QAbstractItemView::item:selected, +QAbstractItemView::item:selected:hover, +QAbstractItemView::item:alternate:selected, +QAbstractItemView::item:alternate:selected:hover { + color: #f1f1f1; + background-color: #990000; +} + +QAbstractItemView[filtered=true] { + border: 2px solid #f00 !important; +} + +QAbstractItemView, +QListView, +QTreeView { + show-decoration-selected: 1; +} + +QAbstractItemView::item:hover, +QAbstractItemView::item:alternate:hover, +QAbstractItemView::item:disabled:hover, +QAbstractItemView::item:alternate:disabled:hover QListView::item:hover, +QTreeView::branch:hover, +QTreeWidget::item:hover { + background-color: rgba(153, 0, 0, 0.3); +} + +QAbstractItemView::item:selected:disabled, +QAbstractItemView::item:alternate:selected:disabled, +QListView::item:selected:disabled, +QTreeView::branch:selected:disabled, +QTreeWidget::item:selected:disabled { + background-color: rgba(153, 0, 0, 0.3); +} + +QTreeView::branch:selected, +#bsaList::branch:selected { + background-color: #990000; +} + +QLabel { + background-color: transparent; +} + +LinkLabel { + qproperty-linkColor: #990000; +} + +/* Left Pane & File Trees #QTreeView, #QListView*/ +QTreeView::branch:closed:has-children { + image: url(./vs15/branch-closed.png); +} + +QTreeView::branch:open:has-children { + image: url(./vs15/branch-open.png); +} + +QListView::item { + color: #f1f1f1; +} + +/* Text areas and text fields #QTextEdit, #QLineEdit, #QWebView */ +QTextEdit, +QWebView, +QLineEdit, +QAbstractSpinBox, +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button, +QComboBox { + background-color: #333337; + border-color: #3f3f46; +} + +QLineEdit:hover, +QAbstractSpinBox:hover, +QTextEdit:hover, +QComboBox:hover, +QComboBox:editable:hover { + border-color: #990000; +} + +QLineEdit:focus, +QAbstractSpinBox::focus, +QTextEdit:focus, +QComboBox:focus, +QComboBox:editable:focus, +QComboBox:on { + background-color: #3f3f46; + border-color: #990000; +} + +QComboBox:on { + border-bottom-color: #3f3f46; +} + +QLineEdit, +QAbstractSpinBox { + min-height: 15px; + padding: 2px; + border-style: solid; + border-width: 1px; +} + +QLineEdit { + margin-top: 0; +} + +/* clear button */ +QLineEdit QToolButton, +QLineEdit QToolButton:hover { + background: none; + margin-top: 1px; +} + +QLineEdit#espFilterEdit QToolButton { + margin-top: -2px; + margin-bottom: 1px; +} + +/* Drop-downs #QComboBox*/ +QComboBox { + min-height: 20px; + padding-left: 5px; + margin: 3px 0 1px 0; + border-style: solid; + border-width: 1px; +} + +QComboBox:editable { + padding-left: 3px; + /* to enable hover styles */ + background-color: transparent; +} + +QComboBox::drop-down { + width: 20px; + subcontrol-origin: padding; + subcontrol-position: top right; + border: none; +} + +QComboBox::down-arrow { + image: url(./vs15/combobox-down.png); +} + +QComboBox QAbstractItemView { + background-color: #1b1b1c; + selection-background-color: #3f3f46; + border-color: #990000; + border-style: solid; + border-width: 0 1px 1px 1px; +} + +/* Doesn't work http://stackoverflow.com/questions/13308341/qcombobox-abstractitemviewitem */ +/* QComboBox QAbstractItemView:item { + padding: 10px; + margin: 10px; +} */ +/* Toolbar */ +QToolBar { + border: none; +} + +QToolBar::separator { + border-left-color: #222222; + border-right-color: #46464a; + border-width: 0 1px 0 1px; + border-style: solid; + width: 0; +} + +QToolButton { + padding: 4px; +} + +QToolButton:hover, QToolButton:focus { + background-color: #3e3e40; +} + +QToolButton:pressed { + background-color: #990000; +} + +QToolButton::menu-indicator { + image: url(./vs15/combobox-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-top: 10%; + padding-right: 5%; +} + +/* Group Boxes #QGroupBox */ +QGroupBox { + border-color: #3f3f46; + border-style: solid; + border-width: 1px; + padding: 1em 0.3em 0.3em 0.3em; + margin-top: 0.65em; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 2px; + left: 10px; +} + +/* LCD Count */ +QLCDNumber { + border-color: #3f3f46; + border-style: solid; + border-width: 1px; +} + +/* Buttons #QPushButton */ +QPushButton { + background-color: #333337; + border-color: #3f3f46; + min-height: 18px; + padding: 2px 5px; + border-style: solid; + border-width: 1px; +} + +QPushButton:hover, +QPushButton:checked, +QPushButton:focus, +QAbstractSpinBox::up-button:hover, +QAbstractSpinBox::down-button:hover { + background-color: #990000; +} + +QPushButton:pressed, +QPushButton:checked:hover, +QAbstractSpinBox::up-button:pressed, +QAbstractSpinBox::down-button:pressed { + background-color: #b30000; +} + +QPushButton:disabled, +QAbstractSpinBox::up-button:disabled, +QAbstractSpinBox::down-button:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QPushButton::menu-indicator { + image: url(./vs15/combobox-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 5%; +} + +/* Dialog buttons */ +QDialog QPushButton, +QSlider::handle:horizontal, +QSlider::handle:vertical { + color: #000000; + background-color: #dddddd; + border-color: #707070; + border-style: solid; + border-width: 1px; +} + +QDialog QPushButton:hover, +QSlider::handle:horizontal:hover, +QSlider::handle:vertical:hover, +QSlider::handle:horizontal:pressed, +QSlider::handle:horizontal:focus:pressed, +QSlider::handle:vertical:pressed, +QSlider::handle:vertical:focus:pressed { + background-color: #BEE6FD; + border-color: #a80000; +} + +QSlider::handle:horizontal:focus, +QSlider::handle:vertical:focus, +QDialog QPushButton:focus, +QDialog QPushButton:checked { + background-color: #dddddd; + border-color: #990000; +} + +QDialog QPushButton:disabled, +QSlider::handle:horizontal:disabled, +QSlider::handle:vertical:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QDialog QPushButton { + min-width: 1.5em; + padding-left: 0.5em; + padding-right: 0.5em; +} + +/* Check boxes and Radio buttons common #QCheckBox, #QRadioButton */ +QGroupBox::indicator, +QTreeView::indicator, +QCheckBox::indicator, +QRadioButton::indicator { + background-color: #2d2d30; + border-color: #3f3f46; + width: 13px; + height: 13px; + border-style: solid; + border-width: 1px; +} + +QGroupBox::indicator:hover, +QTreeView::indicator:hover, +QCheckBox::indicator:hover, +QRadioButton::indicator:hover { + background-color: #3f3f46; + border-color: #990000; +} + +QGroupBox::indicator:checked, +QTreeView::indicator:checked, +QCheckBox::indicator:checked { + image: url(./vs15/checkbox-check.png); +} + +QGroupBox::indicator:disabled, +QTreeView::indicator:checked:disabled, +QCheckBox::indicator:checked:disabled { + image: url(./vs15/checkbox-check-disabled.png); +} + +/* Check boxes special */ +QTreeView#modList::indicator { + width: 15px; + height: 15px; +} + +/* Radio buttons #QRadioButton */ +QRadioButton::indicator { + border-radius: 7px; +} + +QRadioButton::indicator::checked { + background-color: #B9B9BA; + border-width: 2px; + width: 11px; + height: 11px; +} + +QRadioButton::indicator::checked:hover { + border-color: #3f3f46; +} + +/* Spinners #QSpinBox, #QDoubleSpinBox */ +QAbstractSpinBox { + margin: 1px; +} + +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button { + border-style: solid; + border-width: 1px; + subcontrol-origin: padding; +} + +QAbstractSpinBox::up-button { + subcontrol-position: top right; +} + +QAbstractSpinBox::up-arrow { + image: url(./vs15/spinner-up.png); +} + +QAbstractSpinBox::down-button { + subcontrol-position: bottom right; +} + +QAbstractSpinBox::down-arrow { + image: url(./vs15/spinner-down.png); +} + +/* Sliders #QSlider */ +QSlider::groove:horizontal { + background-color: #3f3f46; + border: none; + height: 8px; + margin: 2px 0; +} + +QSlider::handle:horizontal { + width: 0.5em; + height: 2em; + margin: -7px 0; + subcontrol-origin: margin; +} + +/* Scroll Bars #QAbstractScrollArea, #QScrollBar*/ +/* assigning background still leaves not filled area*/ +QAbstractScrollArea::corner { + background-color: transparent; +} + +/* Horizontal */ +QScrollBar:horizontal { + height: 18px; + border: none; + margin: 0 23px 0 23px; +} + +QScrollBar::handle:horizontal { + min-width: 32px; + margin: 4px 2px; +} + +QScrollBar::add-line:horizontal { + width: 23px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal { + width: 23px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +/* Vertical */ +QScrollBar:vertical { + width: 20px; + border: none; + margin: 23px 0 23px 0; +} + +QScrollBar::handle:vertical { + min-height: 32px; + margin: 2px 4px; +} + +QScrollBar::add-line:vertical { + height: 23px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:vertical { + height: 23px; + subcontrol-position: top; + subcontrol-origin: margin; +} + +/* Combined */ +QScrollBar { + background-color: #3e3e42; + border: none; +} + +QScrollBar::handle { + background-color: #686868; +} + +QScrollBar::add-line, +QScrollBar::sub-line { + background-color: #3e3e42; + border: none; +} + +/* QScrollBar::add-line:horizontal:hover, +QScrollBar::sub-line:horizontal:hover, +QScrollBar::add-line:vertical:hover, +QScrollBar::sub-line:vertical:hover, +QScrollBar::add-line:horizontal:pressed, +QScrollBar::sub-line:horizontal:pressed, +QScrollBar::add-line:vertical:pressed, +QScrollBar::sub-line:vertical:pressed { } */ +QScrollBar::handle:hover { + background: #9e9e9e; +} + +QScrollBar::handle:pressed { + background: #efebef; +} + +QScrollBar::handle:disabled { + background: #555558; +} + +QScrollBar::add-page, +QScrollBar::sub-page { + background: transparent; +} + +QScrollBar::up-arrow:vertical { + image: url(./vs15/scrollbar-up.png); +} + +QScrollBar::up-arrow:vertical:hover { + image: url(./vs15/scrollbar-up-hover.png); +} + +QScrollBar::up-arrow:vertical:disabled { + image: url(./vs15/scrollbar-up-disabled.png); +} + +QScrollBar::right-arrow:horizontal { + image: url(./vs15/scrollbar-right.png); +} + +QScrollBar::right-arrow:horizontal:hover { + image: url(./vs15/scrollbar-right-hover.png); +} + +QScrollBar::right-arrow:horizontal:disabled { + image: url(./vs15/scrollbar-right-disabled.png); +} + +QScrollBar::down-arrow:vertical { + image: url(./vs15/scrollbar-down.png); +} + +QScrollBar::down-arrow:vertical:hover { + image: url(./vs15/scrollbar-down-hover.png); +} + +QScrollBar::down-arrow:vertical:disabled { + image: url(./vs15/scrollbar-down-disabled.png); +} + +QScrollBar::left-arrow:horizontal { + image: url(./vs15/scrollbar-left.png); +} + +QScrollBar::left-arrow:horizontal:hover { + image: url(./vs15/scrollbar-left-hover.png); +} + +QScrollBar::left-arrow:horizontal:disabled { + image: url(./vs15/scrollbar-left-disabled.png); +} + +/* Header Rows and Tables (Configure Mod Categories) #QTableView, #QHeaderView */ +QTableView { + gridline-color: #3f3f46; + selection-background-color: #990000; + selection-color: #f1f1f1; +} + +QTableView QTableCornerButton::section { + background: #252526; + border-color: #3f3f46; + border-style: solid; + border-width: 0 1px 1px 0; +} + +QHeaderView { + border: none; +} + +QHeaderView::section { + background: #252526; + border-color: #3f3f46; + padding: 3px 5px; + border-style: solid; + border-width: 0 1px 1px 0; +} + +QHeaderView::section:hover { + background: #3e3e40; + color: #f6f6f6; +} + +QHeaderView::section:last { + border-right: 0; +} + +QHeaderView::up-arrow { + image: url(./vs15/sort-asc.png); + margin-bottom: -37px; +} + +DownloadListView QHeaderView::up-arrow { + margin-bottom: -47px; +} + +QHeaderView::down-arrow { + image: url(./vs15/sort-desc.png); + margin-bottom: 0; +} + +/* Context menus, toolbar drop-downs #QMenu */ +QMenu { + background-color: #1a1a1c; + border-color: #333337; + border-style: solid; + border-width: 1px; + padding: 2px; +} + +QMenu::item { + background: transparent; + padding: 4px 20px; +} + +QMenu::item:selected, +QMenuBar::item:selected { + background-color: #333334; +} + +QMenu::item:disabled { + background-color: transparent; +} + +QMenu::separator { + background-color: #333337; + height: 1px; + margin: 1px 0; +} + +QMenu::icon { + margin: 1px; +} + +QMenu::right-arrow { + image: url(./vs15/sub-menu-arrow.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 0.5em; +} + +QMenu QPushButton { + background-color: transparent; + border-color: #3f3f46; + margin: 1px 0 1px 0; +} + +QMenu QCheckBox, +QMenu QRadioButton { + background-color: transparent; + padding: 5px 2px; +} + +/* Tool tips #QToolTip, #SaveGameInfoWidget */ +QToolTip, +SaveGameInfoWidget { + background-color: #424245; + border-color: #4d4d50; + color: #f1f1f1; + border-style: solid; + border-width: 1px; + padding: 2px; +} + +QStatusBar::item { + border: None; +} + +/* Progress Bars (Downloads) #QProgressBar */ +QProgressBar { + background-color: #e6e6e6; + color: #000; + border-color: #bcbcbc; + text-align: center; + border-style: solid; + border-width: 1px; + margin: 0px; +} + +QProgressBar::chunk { + background: #06b025; +} + +DownloadListView[downloadView=standard]::item { + padding: 16px; +} + +DownloadListView[downloadView=compact]::item { + padding: 4px; +} + +/* Right Pane and Tab Bars #QTabWidget, #QTabBar */ +QTabWidget::pane { + border-color: #3f3f46; + border-top-color: #990000; + top: 0; + border-style: solid; + border-width: 1px; +} + +QTabWidget::pane:disabled { + border-top-color: #3f3f46; +} + +QTabBar::tab { + background-color: transparent; + padding: 4px 1em; + border: none; +} + +QTabBar::tab:hover { + background-color: #b30000; +} + +QTabBar::tab:selected, +QTabBar::tab:selected:hover { + background-color: #990000; +} + +QTabBar::tab:disabled { + background-color: transparent; + color: #656565; +} + +QTabBar::tab:selected:disabled { + background-color: #3f3f46; +} + +/* Scrollers */ +QTabBar QToolButton { + background-color: #333337; + border-color: #3f3f46; + padding: 1px; + margin: 0; + border-style: solid; + border-width: 1px; +} + +QTabBar QToolButton:hover { + border-color: #990000; + border-width: 1px; + border-style: solid; +} + +QTabBar QToolButton:disabled, +QTabBar QToolButton:pressed:hover { + background-color: #333337; +} + +QTabBar::scroller { + width: 23px; + background-color: red; +} + +QTabBar QToolButton::right-arrow { + image: url(./vs15/scrollbar-right.png); +} + +QTabBar QToolButton::right-arrow:hover { + image: url(./vs15/scrollbar-right-hover.png); +} + +QTabBar QToolButton::left-arrow { + image: url(./vs15/scrollbar-left.png); +} + +QTabBar QToolButton::left-arrow:hover { + image: url(./vs15/scrollbar-left-hover.png); +} + +/* Special styles */ +QWidget#tabImages QPushButton { + background-color: transparent; + margin: 0 0.3em; + padding: 0; +} + +/* like dialog QPushButton*/ +QWidget#tabESPs QToolButton { + color: #000000; + background-color: #dddddd; + border-color: #707070; + border-style: solid; + border-width: 1px; +} + +QWidget#tabESPs QToolButton:hover { + background-color: #BEE6FD; + border-color: #a80000; +} + +QWidget#tabESPs QToolButton:focus { + background-color: #dddddd; + border-color: #990000; +} + +QWidget#tabESPs QToolButton:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QTreeWidget#categoriesList { + /* min-width: 225px; */ +} + +QTreeWidget#categoriesList::item { + background-position: center left; + background-repeat: no-repeat; + padding: 0.35em 10px; +} + +QTreeWidget#categoriesList::item:has-children { + background-image: url(./vs15/branch-closed.png); +} + +QTreeWidget#categoriesList::item:has-children:open { + background-image: url(./vs15/branch-open.png); +} + +QDialog#QueryOverwriteDialog QPushButton { + margin-left: 0.5em; +} + +QDialog#PyCfgDialog QPushButton:hover { + background-color: #BEE6FD; +} + +QLineEdit#modFilterEdit { + margin-top: 2px; +} + +/* highlight unchecked BSAs */ +QWidget#bsaTab QTreeWidget::indicator:unchecked { + background-color: #990000; +} + +/* increase version text field */ +QLineEdit#versionEdit { + max-width: 100px; +} + +/* Dialogs width changes */ +/* increase width to prevent buttons cutting */ +QDialog#QueryOverwriteDialog { + min-width: 565px; +} + +QDialog#ModInfoDialog { + min-width: 850px; +} + +QLineEdit[valid-filter=false] { + background-color: #661111 !important; +} diff --git a/src/src/stylesheets/vs15 Dark-Yellow.qss b/src/src/stylesheets/vs15 Dark-Yellow.qss new file mode 100644 index 0000000..b3d62d5 --- /dev/null +++ b/src/src/stylesheets/vs15 Dark-Yellow.qss @@ -0,0 +1,920 @@ +/*!************************************* + VS15 Dark +**************************************** + Author: chintsu_kun, holt59, MO2 Team + Version: 2.5.0 + Licence: GNU General Public License v3.0 (https://www.gnu.org/licenses/gpl-3.0.en.html) + Url: https://github.com/nikolay-borzov/modorganizer-themes +**************************************** +*/ +/* For some reason applying background-color or border fixes paddings properties */ +QListWidget::item { + border-width: 0; +} + +/* Don't override install label on download widget. + MO2 assigns color depending on download state */ +#installLabel { + color: none; +} + +/* Make `background-color` work for :hover, :focus and :pressed states */ +QToolButton { + border: none; +} + +/* Main Window */ +QWidget { + background-color: #2d2d30; + color: #f1f1f1; +} + +QWidget::disabled { + color: #656565; +} + +/* Common */ +/* remove outline */ +* { + outline: 0; +} + +*:disabled, +QListView::item:disabled, +*::item:selected:disabled { + color: #656565; +} + +/* line heights */ +/* QTreeView#fileTree::item - currently have problem with size column vertical + text align */ +#bsaList::item, +#dataTree::item, +#modList::item, +#categoriesTree::item, +#savegameList::item, +#tabConflicts QTreeWidget::item { + padding: 0.3em 0; +} + +QListView::item, +QTreeView#espList::item { + padding: 0.3em 0; +} + +/* to enable border color */ +QTreeView, +QListView, +QTextEdit, +QWebView, +QTableView { + border-style: solid; + border-width: 1px; +} + +QAbstractItemView { + color: #dcdcdc; + background-color: #1e1e1e; + alternate-background-color: #262626; + border-color: #3f3f46; +} + +QAbstractItemView::item:selected, +QAbstractItemView::item:selected:hover, +QAbstractItemView::item:alternate:selected, +QAbstractItemView::item:alternate:selected:hover { + color: #f1f1f1; + background-color: #9a9a00; +} + +QAbstractItemView[filtered=true] { + border: 2px solid #f00 !important; +} + +QAbstractItemView, +QListView, +QTreeView { + show-decoration-selected: 1; +} + +QAbstractItemView::item:hover, +QAbstractItemView::item:alternate:hover, +QAbstractItemView::item:disabled:hover, +QAbstractItemView::item:alternate:disabled:hover QListView::item:hover, +QTreeView::branch:hover, +QTreeWidget::item:hover { + background-color: rgba(154, 154, 0, 0.3); +} + +QAbstractItemView::item:selected:disabled, +QAbstractItemView::item:alternate:selected:disabled, +QListView::item:selected:disabled, +QTreeView::branch:selected:disabled, +QTreeWidget::item:selected:disabled { + background-color: rgba(154, 154, 0, 0.3); +} + +QTreeView::branch:selected, +#bsaList::branch:selected { + background-color: #9a9a00; +} + +QLabel { + background-color: transparent; +} + +LinkLabel { + qproperty-linkColor: #9a9a00; +} + +/* Left Pane & File Trees #QTreeView, #QListView*/ +QTreeView::branch:closed:has-children { + image: url(./vs15/branch-closed.png); +} + +QTreeView::branch:open:has-children { + image: url(./vs15/branch-open.png); +} + +QListView::item { + color: #f1f1f1; +} + +/* Text areas and text fields #QTextEdit, #QLineEdit, #QWebView */ +QTextEdit, +QWebView, +QLineEdit, +QAbstractSpinBox, +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button, +QComboBox { + background-color: #333337; + border-color: #3f3f46; +} + +QLineEdit:hover, +QAbstractSpinBox:hover, +QTextEdit:hover, +QComboBox:hover, +QComboBox:editable:hover { + border-color: #9a9a00; +} + +QLineEdit:focus, +QAbstractSpinBox::focus, +QTextEdit:focus, +QComboBox:focus, +QComboBox:editable:focus, +QComboBox:on { + background-color: #3f3f46; + border-color: #9a9a00; +} + +QComboBox:on { + border-bottom-color: #3f3f46; +} + +QLineEdit, +QAbstractSpinBox { + min-height: 15px; + padding: 2px; + border-style: solid; + border-width: 1px; +} + +QLineEdit { + margin-top: 0; +} + +/* clear button */ +QLineEdit QToolButton, +QLineEdit QToolButton:hover { + background: none; + margin-top: 1px; +} + +QLineEdit#espFilterEdit QToolButton { + margin-top: -2px; + margin-bottom: 1px; +} + +/* Drop-downs #QComboBox*/ +QComboBox { + min-height: 20px; + padding-left: 5px; + margin: 3px 0 1px 0; + border-style: solid; + border-width: 1px; +} + +QComboBox:editable { + padding-left: 3px; + /* to enable hover styles */ + background-color: transparent; +} + +QComboBox::drop-down { + width: 20px; + subcontrol-origin: padding; + subcontrol-position: top right; + border: none; +} + +QComboBox::down-arrow { + image: url(./vs15/combobox-down.png); +} + +QComboBox QAbstractItemView { + background-color: #1b1b1c; + selection-background-color: #3f3f46; + border-color: #9a9a00; + border-style: solid; + border-width: 0 1px 1px 1px; +} + +/* Doesn't work http://stackoverflow.com/questions/13308341/qcombobox-abstractitemviewitem */ +/* QComboBox QAbstractItemView:item { + padding: 10px; + margin: 10px; +} */ +/* Toolbar */ +QToolBar { + border: none; +} + +QToolBar::separator { + border-left-color: #222222; + border-right-color: #46464a; + border-width: 0 1px 0 1px; + border-style: solid; + width: 0; +} + +QToolButton { + padding: 4px; +} + +QToolButton:hover, QToolButton:focus { + background-color: #3e3e40; +} + +QToolButton:pressed { + background-color: #9a9a00; +} + +QToolButton::menu-indicator { + image: url(./vs15/combobox-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-top: 10%; + padding-right: 5%; +} + +/* Group Boxes #QGroupBox */ +QGroupBox { + border-color: #3f3f46; + border-style: solid; + border-width: 1px; + padding: 1em 0.3em 0.3em 0.3em; + margin-top: 0.65em; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 2px; + left: 10px; +} + +/* LCD Count */ +QLCDNumber { + border-color: #3f3f46; + border-style: solid; + border-width: 1px; +} + +/* Buttons #QPushButton */ +QPushButton { + background-color: #333337; + border-color: #3f3f46; + min-height: 18px; + padding: 2px 5px; + border-style: solid; + border-width: 1px; +} + +QPushButton:hover, +QPushButton:checked, +QPushButton:focus, +QAbstractSpinBox::up-button:hover, +QAbstractSpinBox::down-button:hover { + background-color: #9a9a00; +} + +QPushButton:pressed, +QPushButton:checked:hover, +QAbstractSpinBox::up-button:pressed, +QAbstractSpinBox::down-button:pressed { + background-color: #b4b400; +} + +QPushButton:disabled, +QAbstractSpinBox::up-button:disabled, +QAbstractSpinBox::down-button:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QPushButton::menu-indicator { + image: url(./vs15/combobox-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 5%; +} + +/* Dialog buttons */ +QDialog QPushButton, +QSlider::handle:horizontal, +QSlider::handle:vertical { + color: #000000; + background-color: #dddddd; + border-color: #707070; + border-style: solid; + border-width: 1px; +} + +QDialog QPushButton:hover, +QSlider::handle:horizontal:hover, +QSlider::handle:vertical:hover, +QSlider::handle:horizontal:pressed, +QSlider::handle:horizontal:focus:pressed, +QSlider::handle:vertical:pressed, +QSlider::handle:vertical:focus:pressed { + background-color: #BEE6FD; + border-color: #a9a900; +} + +QSlider::handle:horizontal:focus, +QSlider::handle:vertical:focus, +QDialog QPushButton:focus, +QDialog QPushButton:checked { + background-color: #dddddd; + border-color: #9a9a00; +} + +QDialog QPushButton:disabled, +QSlider::handle:horizontal:disabled, +QSlider::handle:vertical:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QDialog QPushButton { + min-width: 1.5em; + padding-left: 0.5em; + padding-right: 0.5em; +} + +/* Check boxes and Radio buttons common #QCheckBox, #QRadioButton */ +QGroupBox::indicator, +QTreeView::indicator, +QCheckBox::indicator, +QRadioButton::indicator { + background-color: #2d2d30; + border-color: #3f3f46; + width: 13px; + height: 13px; + border-style: solid; + border-width: 1px; +} + +QGroupBox::indicator:hover, +QTreeView::indicator:hover, +QCheckBox::indicator:hover, +QRadioButton::indicator:hover { + background-color: #3f3f46; + border-color: #9a9a00; +} + +QGroupBox::indicator:checked, +QTreeView::indicator:checked, +QCheckBox::indicator:checked { + image: url(./vs15/checkbox-check.png); +} + +QGroupBox::indicator:disabled, +QTreeView::indicator:checked:disabled, +QCheckBox::indicator:checked:disabled { + image: url(./vs15/checkbox-check-disabled.png); +} + +/* Check boxes special */ +QTreeView#modList::indicator { + width: 15px; + height: 15px; +} + +/* Radio buttons #QRadioButton */ +QRadioButton::indicator { + border-radius: 7px; +} + +QRadioButton::indicator::checked { + background-color: #B9B9BA; + border-width: 2px; + width: 11px; + height: 11px; +} + +QRadioButton::indicator::checked:hover { + border-color: #3f3f46; +} + +/* Spinners #QSpinBox, #QDoubleSpinBox */ +QAbstractSpinBox { + margin: 1px; +} + +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button { + border-style: solid; + border-width: 1px; + subcontrol-origin: padding; +} + +QAbstractSpinBox::up-button { + subcontrol-position: top right; +} + +QAbstractSpinBox::up-arrow { + image: url(./vs15/spinner-up.png); +} + +QAbstractSpinBox::down-button { + subcontrol-position: bottom right; +} + +QAbstractSpinBox::down-arrow { + image: url(./vs15/spinner-down.png); +} + +/* Sliders #QSlider */ +QSlider::groove:horizontal { + background-color: #3f3f46; + border: none; + height: 8px; + margin: 2px 0; +} + +QSlider::handle:horizontal { + width: 0.5em; + height: 2em; + margin: -7px 0; + subcontrol-origin: margin; +} + +/* Scroll Bars #QAbstractScrollArea, #QScrollBar*/ +/* assigning background still leaves not filled area*/ +QAbstractScrollArea::corner { + background-color: transparent; +} + +/* Horizontal */ +QScrollBar:horizontal { + height: 18px; + border: none; + margin: 0 23px 0 23px; +} + +QScrollBar::handle:horizontal { + min-width: 32px; + margin: 4px 2px; +} + +QScrollBar::add-line:horizontal { + width: 23px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal { + width: 23px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +/* Vertical */ +QScrollBar:vertical { + width: 20px; + border: none; + margin: 23px 0 23px 0; +} + +QScrollBar::handle:vertical { + min-height: 32px; + margin: 2px 4px; +} + +QScrollBar::add-line:vertical { + height: 23px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:vertical { + height: 23px; + subcontrol-position: top; + subcontrol-origin: margin; +} + +/* Combined */ +QScrollBar { + background-color: #3e3e42; + border: none; +} + +QScrollBar::handle { + background-color: #686868; +} + +QScrollBar::add-line, +QScrollBar::sub-line { + background-color: #3e3e42; + border: none; +} + +/* QScrollBar::add-line:horizontal:hover, +QScrollBar::sub-line:horizontal:hover, +QScrollBar::add-line:vertical:hover, +QScrollBar::sub-line:vertical:hover, +QScrollBar::add-line:horizontal:pressed, +QScrollBar::sub-line:horizontal:pressed, +QScrollBar::add-line:vertical:pressed, +QScrollBar::sub-line:vertical:pressed { } */ +QScrollBar::handle:hover { + background: #9e9e9e; +} + +QScrollBar::handle:pressed { + background: #efebef; +} + +QScrollBar::handle:disabled { + background: #555558; +} + +QScrollBar::add-page, +QScrollBar::sub-page { + background: transparent; +} + +QScrollBar::up-arrow:vertical { + image: url(./vs15/scrollbar-up.png); +} + +QScrollBar::up-arrow:vertical:hover { + image: url(./vs15/scrollbar-up-hover.png); +} + +QScrollBar::up-arrow:vertical:disabled { + image: url(./vs15/scrollbar-up-disabled.png); +} + +QScrollBar::right-arrow:horizontal { + image: url(./vs15/scrollbar-right.png); +} + +QScrollBar::right-arrow:horizontal:hover { + image: url(./vs15/scrollbar-right-hover.png); +} + +QScrollBar::right-arrow:horizontal:disabled { + image: url(./vs15/scrollbar-right-disabled.png); +} + +QScrollBar::down-arrow:vertical { + image: url(./vs15/scrollbar-down.png); +} + +QScrollBar::down-arrow:vertical:hover { + image: url(./vs15/scrollbar-down-hover.png); +} + +QScrollBar::down-arrow:vertical:disabled { + image: url(./vs15/scrollbar-down-disabled.png); +} + +QScrollBar::left-arrow:horizontal { + image: url(./vs15/scrollbar-left.png); +} + +QScrollBar::left-arrow:horizontal:hover { + image: url(./vs15/scrollbar-left-hover.png); +} + +QScrollBar::left-arrow:horizontal:disabled { + image: url(./vs15/scrollbar-left-disabled.png); +} + +/* Header Rows and Tables (Configure Mod Categories) #QTableView, #QHeaderView */ +QTableView { + gridline-color: #3f3f46; + selection-background-color: #9a9a00; + selection-color: #f1f1f1; +} + +QTableView QTableCornerButton::section { + background: #252526; + border-color: #3f3f46; + border-style: solid; + border-width: 0 1px 1px 0; +} + +QHeaderView { + border: none; +} + +QHeaderView::section { + background: #252526; + border-color: #3f3f46; + padding: 3px 5px; + border-style: solid; + border-width: 0 1px 1px 0; +} + +QHeaderView::section:hover { + background: #3e3e40; + color: #f6f6f6; +} + +QHeaderView::section:last { + border-right: 0; +} + +QHeaderView::up-arrow { + image: url(./vs15/sort-asc.png); + margin-bottom: -37px; +} + +DownloadListView QHeaderView::up-arrow { + margin-bottom: -47px; +} + +QHeaderView::down-arrow { + image: url(./vs15/sort-desc.png); + margin-bottom: 0; +} + +/* Context menus, toolbar drop-downs #QMenu */ +QMenu { + background-color: #1a1a1c; + border-color: #333337; + border-style: solid; + border-width: 1px; + padding: 2px; +} + +QMenu::item { + background: transparent; + padding: 4px 20px; +} + +QMenu::item:selected, +QMenuBar::item:selected { + background-color: #333334; +} + +QMenu::item:disabled { + background-color: transparent; +} + +QMenu::separator { + background-color: #333337; + height: 1px; + margin: 1px 0; +} + +QMenu::icon { + margin: 1px; +} + +QMenu::right-arrow { + image: url(./vs15/sub-menu-arrow.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 0.5em; +} + +QMenu QPushButton { + background-color: transparent; + border-color: #3f3f46; + margin: 1px 0 1px 0; +} + +QMenu QCheckBox, +QMenu QRadioButton { + background-color: transparent; + padding: 5px 2px; +} + +/* Tool tips #QToolTip, #SaveGameInfoWidget */ +QToolTip, +SaveGameInfoWidget { + background-color: #424245; + border-color: #4d4d50; + color: #f1f1f1; + border-style: solid; + border-width: 1px; + padding: 2px; +} + +QStatusBar::item { + border: None; +} + +/* Progress Bars (Downloads) #QProgressBar */ +QProgressBar { + background-color: #e6e6e6; + color: #000; + border-color: #bcbcbc; + text-align: center; + border-style: solid; + border-width: 1px; + margin: 0px; +} + +QProgressBar::chunk { + background: #06b025; +} + +DownloadListView[downloadView=standard]::item { + padding: 16px; +} + +DownloadListView[downloadView=compact]::item { + padding: 4px; +} + +/* Right Pane and Tab Bars #QTabWidget, #QTabBar */ +QTabWidget::pane { + border-color: #3f3f46; + border-top-color: #9a9a00; + top: 0; + border-style: solid; + border-width: 1px; +} + +QTabWidget::pane:disabled { + border-top-color: #3f3f46; +} + +QTabBar::tab { + background-color: transparent; + padding: 4px 1em; + border: none; +} + +QTabBar::tab:hover { + background-color: #b4b400; +} + +QTabBar::tab:selected, +QTabBar::tab:selected:hover { + background-color: #9a9a00; +} + +QTabBar::tab:disabled { + background-color: transparent; + color: #656565; +} + +QTabBar::tab:selected:disabled { + background-color: #3f3f46; +} + +/* Scrollers */ +QTabBar QToolButton { + background-color: #333337; + border-color: #3f3f46; + padding: 1px; + margin: 0; + border-style: solid; + border-width: 1px; +} + +QTabBar QToolButton:hover { + border-color: #9a9a00; + border-width: 1px; + border-style: solid; +} + +QTabBar QToolButton:disabled, +QTabBar QToolButton:pressed:hover { + background-color: #333337; +} + +QTabBar::scroller { + width: 23px; + background-color: red; +} + +QTabBar QToolButton::right-arrow { + image: url(./vs15/scrollbar-right.png); +} + +QTabBar QToolButton::right-arrow:hover { + image: url(./vs15/scrollbar-right-hover.png); +} + +QTabBar QToolButton::left-arrow { + image: url(./vs15/scrollbar-left.png); +} + +QTabBar QToolButton::left-arrow:hover { + image: url(./vs15/scrollbar-left-hover.png); +} + +/* Special styles */ +QWidget#tabImages QPushButton { + background-color: transparent; + margin: 0 0.3em; + padding: 0; +} + +/* like dialog QPushButton*/ +QWidget#tabESPs QToolButton { + color: #000000; + background-color: #dddddd; + border-color: #707070; + border-style: solid; + border-width: 1px; +} + +QWidget#tabESPs QToolButton:hover { + background-color: #BEE6FD; + border-color: #a9a900; +} + +QWidget#tabESPs QToolButton:focus { + background-color: #dddddd; + border-color: #9a9a00; +} + +QWidget#tabESPs QToolButton:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QTreeWidget#categoriesList { + /* min-width: 225px; */ +} + +QTreeWidget#categoriesList::item { + background-position: center left; + background-repeat: no-repeat; + padding: 0.35em 10px; +} + +QTreeWidget#categoriesList::item:has-children { + background-image: url(./vs15/branch-closed.png); +} + +QTreeWidget#categoriesList::item:has-children:open { + background-image: url(./vs15/branch-open.png); +} + +QDialog#QueryOverwriteDialog QPushButton { + margin-left: 0.5em; +} + +QDialog#PyCfgDialog QPushButton:hover { + background-color: #BEE6FD; +} + +QLineEdit#modFilterEdit { + margin-top: 2px; +} + +/* highlight unchecked BSAs */ +QWidget#bsaTab QTreeWidget::indicator:unchecked { + background-color: #9a9a00; +} + +/* increase version text field */ +QLineEdit#versionEdit { + max-width: 100px; +} + +/* Dialogs width changes */ +/* increase width to prevent buttons cutting */ +QDialog#QueryOverwriteDialog { + min-width: 565px; +} + +QDialog#ModInfoDialog { + min-width: 850px; +} + +QLineEdit[valid-filter=false] { + background-color: #661111 !important; +} diff --git a/src/src/stylesheets/vs15 Dark.qss b/src/src/stylesheets/vs15 Dark.qss new file mode 100644 index 0000000..8bb2c77 --- /dev/null +++ b/src/src/stylesheets/vs15 Dark.qss @@ -0,0 +1,920 @@ +/*!************************************* + VS15 Dark +**************************************** + Author: chintsu_kun, holt59, MO2 Team + Version: 2.5.0 + Licence: GNU General Public License v3.0 (https://www.gnu.org/licenses/gpl-3.0.en.html) + Url: https://github.com/nikolay-borzov/modorganizer-themes +**************************************** +*/ +/* For some reason applying background-color or border fixes paddings properties */ +QListWidget::item { + border-width: 0; +} + +/* Don't override install label on download widget. + MO2 assigns color depending on download state */ +#installLabel { + color: none; +} + +/* Make `background-color` work for :hover, :focus and :pressed states */ +QToolButton { + border: none; +} + +/* Main Window */ +QWidget { + background-color: #2d2d30; + color: #f1f1f1; +} + +QWidget::disabled { + color: #656565; +} + +/* Common */ +/* remove outline */ +* { + outline: 0; +} + +*:disabled, +QListView::item:disabled, +*::item:selected:disabled { + color: #656565; +} + +/* line heights */ +/* QTreeView#fileTree::item - currently have problem with size column vertical + text align */ +#bsaList::item, +#dataTree::item, +#modList::item, +#categoriesTree::item, +#savegameList::item, +#tabConflicts QTreeWidget::item { + padding: 0.3em 0; +} + +QListView::item, +QTreeView#espList::item { + padding: 0.3em 0; +} + +/* to enable border color */ +QTreeView, +QListView, +QTextEdit, +QWebView, +QTableView { + border-style: solid; + border-width: 1px; +} + +QAbstractItemView { + color: #dcdcdc; + background-color: #1e1e1e; + alternate-background-color: #262626; + border-color: #3f3f46; +} + +QAbstractItemView::item:selected, +QAbstractItemView::item:selected:hover, +QAbstractItemView::item:alternate:selected, +QAbstractItemView::item:alternate:selected:hover { + color: #f1f1f1; + background-color: #3399ff; +} + +QAbstractItemView[filtered=true] { + border: 2px solid #f00 !important; +} + +QAbstractItemView, +QListView, +QTreeView { + show-decoration-selected: 1; +} + +QAbstractItemView::item:hover, +QAbstractItemView::item:alternate:hover, +QAbstractItemView::item:disabled:hover, +QAbstractItemView::item:alternate:disabled:hover QListView::item:hover, +QTreeView::branch:hover, +QTreeWidget::item:hover { + background-color: rgba(51, 153, 255, 0.3); +} + +QAbstractItemView::item:selected:disabled, +QAbstractItemView::item:alternate:selected:disabled, +QListView::item:selected:disabled, +QTreeView::branch:selected:disabled, +QTreeWidget::item:selected:disabled { + background-color: rgba(51, 153, 255, 0.3); +} + +QTreeView::branch:selected, +#bsaList::branch:selected { + background-color: #3399ff; +} + +QLabel { + background-color: transparent; +} + +LinkLabel { + qproperty-linkColor: #3399ff; +} + +/* Left Pane & File Trees #QTreeView, #QListView*/ +QTreeView::branch:closed:has-children { + image: url(./vs15/branch-closed.png); +} + +QTreeView::branch:open:has-children { + image: url(./vs15/branch-open.png); +} + +QListView::item { + color: #f1f1f1; +} + +/* Text areas and text fields #QTextEdit, #QLineEdit, #QWebView */ +QTextEdit, +QWebView, +QLineEdit, +QAbstractSpinBox, +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button, +QComboBox { + background-color: #333337; + border-color: #3f3f46; +} + +QLineEdit:hover, +QAbstractSpinBox:hover, +QTextEdit:hover, +QComboBox:hover, +QComboBox:editable:hover { + border-color: #007acc; +} + +QLineEdit:focus, +QAbstractSpinBox::focus, +QTextEdit:focus, +QComboBox:focus, +QComboBox:editable:focus, +QComboBox:on { + background-color: #3f3f46; + border-color: #3399ff; +} + +QComboBox:on { + border-bottom-color: #3f3f46; +} + +QLineEdit, +QAbstractSpinBox { + min-height: 15px; + padding: 2px; + border-style: solid; + border-width: 1px; +} + +QLineEdit { + margin-top: 0; +} + +/* clear button */ +QLineEdit QToolButton, +QLineEdit QToolButton:hover { + background: none; + margin-top: 1px; +} + +QLineEdit#espFilterEdit QToolButton { + margin-top: -2px; + margin-bottom: 1px; +} + +/* Drop-downs #QComboBox*/ +QComboBox { + min-height: 20px; + padding-left: 5px; + margin: 3px 0 1px 0; + border-style: solid; + border-width: 1px; +} + +QComboBox:editable { + padding-left: 3px; + /* to enable hover styles */ + background-color: transparent; +} + +QComboBox::drop-down { + width: 20px; + subcontrol-origin: padding; + subcontrol-position: top right; + border: none; +} + +QComboBox::down-arrow { + image: url(./vs15/combobox-down.png); +} + +QComboBox QAbstractItemView { + background-color: #1b1b1c; + selection-background-color: #3f3f46; + border-color: #3399ff; + border-style: solid; + border-width: 0 1px 1px 1px; +} + +/* Doesn't work http://stackoverflow.com/questions/13308341/qcombobox-abstractitemviewitem */ +/* QComboBox QAbstractItemView:item { + padding: 10px; + margin: 10px; +} */ +/* Toolbar */ +QToolBar { + border: none; +} + +QToolBar::separator { + border-left-color: #222222; + border-right-color: #46464a; + border-width: 0 1px 0 1px; + border-style: solid; + width: 0; +} + +QToolButton { + padding: 4px; +} + +QToolButton:hover, QToolButton:focus { + background-color: #3e3e40; +} + +QToolButton:pressed { + background-color: #3399ff; +} + +QToolButton::menu-indicator { + image: url(./vs15/combobox-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-top: 10%; + padding-right: 5%; +} + +/* Group Boxes #QGroupBox */ +QGroupBox { + border-color: #3f3f46; + border-style: solid; + border-width: 1px; + padding: 1em 0.3em 0.3em 0.3em; + margin-top: 0.65em; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 2px; + left: 10px; +} + +/* LCD Count */ +QLCDNumber { + border-color: #3f3f46; + border-style: solid; + border-width: 1px; +} + +/* Buttons #QPushButton */ +QPushButton { + background-color: #333337; + border-color: #3f3f46; + min-height: 18px; + padding: 2px 5px; + border-style: solid; + border-width: 1px; +} + +QPushButton:hover, +QPushButton:checked, +QPushButton:focus, +QAbstractSpinBox::up-button:hover, +QAbstractSpinBox::down-button:hover { + background-color: #007acc; +} + +QPushButton:pressed, +QPushButton:checked:hover, +QAbstractSpinBox::up-button:pressed, +QAbstractSpinBox::down-button:pressed { + background-color: #1c97ea; +} + +QPushButton:disabled, +QAbstractSpinBox::up-button:disabled, +QAbstractSpinBox::down-button:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QPushButton::menu-indicator { + image: url(./vs15/combobox-down.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 5%; +} + +/* Dialog buttons */ +QDialog QPushButton, +QSlider::handle:horizontal, +QSlider::handle:vertical { + color: #000000; + background-color: #dddddd; + border-color: #707070; + border-style: solid; + border-width: 1px; +} + +QDialog QPushButton:hover, +QSlider::handle:horizontal:hover, +QSlider::handle:vertical:hover, +QSlider::handle:horizontal:pressed, +QSlider::handle:horizontal:focus:pressed, +QSlider::handle:vertical:pressed, +QSlider::handle:vertical:focus:pressed { + background-color: #BEE6FD; + border-color: #3c7fb1; +} + +QSlider::handle:horizontal:focus, +QSlider::handle:vertical:focus, +QDialog QPushButton:focus, +QDialog QPushButton:checked { + background-color: #dddddd; + border-color: #3399ff; +} + +QDialog QPushButton:disabled, +QSlider::handle:horizontal:disabled, +QSlider::handle:vertical:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QDialog QPushButton { + min-width: 1.5em; + padding-left: 0.5em; + padding-right: 0.5em; +} + +/* Check boxes and Radio buttons common #QCheckBox, #QRadioButton */ +QGroupBox::indicator, +QTreeView::indicator, +QCheckBox::indicator, +QRadioButton::indicator { + background-color: #2d2d30; + border-color: #3f3f46; + width: 13px; + height: 13px; + border-style: solid; + border-width: 1px; +} + +QGroupBox::indicator:hover, +QTreeView::indicator:hover, +QCheckBox::indicator:hover, +QRadioButton::indicator:hover { + background-color: #3f3f46; + border-color: #007acc; +} + +QGroupBox::indicator:checked, +QTreeView::indicator:checked, +QCheckBox::indicator:checked { + image: url(./vs15/checkbox-check.png); +} + +QGroupBox::indicator:disabled, +QTreeView::indicator:checked:disabled, +QCheckBox::indicator:checked:disabled { + image: url(./vs15/checkbox-check-disabled.png); +} + +/* Check boxes special */ +QTreeView#modList::indicator { + width: 15px; + height: 15px; +} + +/* Radio buttons #QRadioButton */ +QRadioButton::indicator { + border-radius: 7px; +} + +QRadioButton::indicator::checked { + background-color: #B9B9BA; + border-width: 2px; + width: 11px; + height: 11px; +} + +QRadioButton::indicator::checked:hover { + border-color: #3f3f46; +} + +/* Spinners #QSpinBox, #QDoubleSpinBox */ +QAbstractSpinBox { + margin: 1px; +} + +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button { + border-style: solid; + border-width: 1px; + subcontrol-origin: padding; +} + +QAbstractSpinBox::up-button { + subcontrol-position: top right; +} + +QAbstractSpinBox::up-arrow { + image: url(./vs15/spinner-up.png); +} + +QAbstractSpinBox::down-button { + subcontrol-position: bottom right; +} + +QAbstractSpinBox::down-arrow { + image: url(./vs15/spinner-down.png); +} + +/* Sliders #QSlider */ +QSlider::groove:horizontal { + background-color: #3f3f46; + border: none; + height: 8px; + margin: 2px 0; +} + +QSlider::handle:horizontal { + width: 0.5em; + height: 2em; + margin: -7px 0; + subcontrol-origin: margin; +} + +/* Scroll Bars #QAbstractScrollArea, #QScrollBar*/ +/* assigning background still leaves not filled area*/ +QAbstractScrollArea::corner { + background-color: transparent; +} + +/* Horizontal */ +QScrollBar:horizontal { + height: 18px; + border: none; + margin: 0 23px 0 23px; +} + +QScrollBar::handle:horizontal { + min-width: 32px; + margin: 4px 2px; +} + +QScrollBar::add-line:horizontal { + width: 23px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal { + width: 23px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +/* Vertical */ +QScrollBar:vertical { + width: 20px; + border: none; + margin: 23px 0 23px 0; +} + +QScrollBar::handle:vertical { + min-height: 32px; + margin: 2px 4px; +} + +QScrollBar::add-line:vertical { + height: 23px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:vertical { + height: 23px; + subcontrol-position: top; + subcontrol-origin: margin; +} + +/* Combined */ +QScrollBar { + background-color: #3e3e42; + border: none; +} + +QScrollBar::handle { + background-color: #686868; +} + +QScrollBar::add-line, +QScrollBar::sub-line { + background-color: #3e3e42; + border: none; +} + +/* QScrollBar::add-line:horizontal:hover, +QScrollBar::sub-line:horizontal:hover, +QScrollBar::add-line:vertical:hover, +QScrollBar::sub-line:vertical:hover, +QScrollBar::add-line:horizontal:pressed, +QScrollBar::sub-line:horizontal:pressed, +QScrollBar::add-line:vertical:pressed, +QScrollBar::sub-line:vertical:pressed { } */ +QScrollBar::handle:hover { + background: #9e9e9e; +} + +QScrollBar::handle:pressed { + background: #efebef; +} + +QScrollBar::handle:disabled { + background: #555558; +} + +QScrollBar::add-page, +QScrollBar::sub-page { + background: transparent; +} + +QScrollBar::up-arrow:vertical { + image: url(./vs15/scrollbar-up.png); +} + +QScrollBar::up-arrow:vertical:hover { + image: url(./vs15/scrollbar-up-hover.png); +} + +QScrollBar::up-arrow:vertical:disabled { + image: url(./vs15/scrollbar-up-disabled.png); +} + +QScrollBar::right-arrow:horizontal { + image: url(./vs15/scrollbar-right.png); +} + +QScrollBar::right-arrow:horizontal:hover { + image: url(./vs15/scrollbar-right-hover.png); +} + +QScrollBar::right-arrow:horizontal:disabled { + image: url(./vs15/scrollbar-right-disabled.png); +} + +QScrollBar::down-arrow:vertical { + image: url(./vs15/scrollbar-down.png); +} + +QScrollBar::down-arrow:vertical:hover { + image: url(./vs15/scrollbar-down-hover.png); +} + +QScrollBar::down-arrow:vertical:disabled { + image: url(./vs15/scrollbar-down-disabled.png); +} + +QScrollBar::left-arrow:horizontal { + image: url(./vs15/scrollbar-left.png); +} + +QScrollBar::left-arrow:horizontal:hover { + image: url(./vs15/scrollbar-left-hover.png); +} + +QScrollBar::left-arrow:horizontal:disabled { + image: url(./vs15/scrollbar-left-disabled.png); +} + +/* Header Rows and Tables (Configure Mod Categories) #QTableView, #QHeaderView */ +QTableView { + gridline-color: #3f3f46; + selection-background-color: #3399ff; + selection-color: #f1f1f1; +} + +QTableView QTableCornerButton::section { + background: #252526; + border-color: #3f3f46; + border-style: solid; + border-width: 0 1px 1px 0; +} + +QHeaderView { + border: none; +} + +QHeaderView::section { + background: #252526; + border-color: #3f3f46; + padding: 3px 5px; + border-style: solid; + border-width: 0 1px 1px 0; +} + +QHeaderView::section:hover { + background: #3e3e40; + color: #f6f6f6; +} + +QHeaderView::section:last { + border-right: 0; +} + +QHeaderView::up-arrow { + image: url(./vs15/sort-asc.png); + margin-bottom: -37px; +} + +DownloadListView QHeaderView::up-arrow { + margin-bottom: -47px; +} + +QHeaderView::down-arrow { + image: url(./vs15/sort-desc.png); + margin-bottom: 0; +} + +/* Context menus, toolbar drop-downs #QMenu */ +QMenu { + background-color: #1a1a1c; + border-color: #333337; + border-style: solid; + border-width: 1px; + padding: 2px; +} + +QMenu::item { + background: transparent; + padding: 4px 20px; +} + +QMenu::item:selected, +QMenuBar::item:selected { + background-color: #333334; +} + +QMenu::item:disabled { + background-color: transparent; +} + +QMenu::separator { + background-color: #333337; + height: 1px; + margin: 1px 0; +} + +QMenu::icon { + margin: 1px; +} + +QMenu::right-arrow { + image: url(./vs15/sub-menu-arrow.png); + subcontrol-origin: padding; + subcontrol-position: center right; + padding-right: 0.5em; +} + +QMenu QPushButton { + background-color: transparent; + border-color: #3f3f46; + margin: 1px 0 1px 0; +} + +QMenu QCheckBox, +QMenu QRadioButton { + background-color: transparent; + padding: 5px 2px; +} + +/* Tool tips #QToolTip, #SaveGameInfoWidget */ +QToolTip, +SaveGameInfoWidget { + background-color: #424245; + border-color: #4d4d50; + color: #f1f1f1; + border-style: solid; + border-width: 1px; + padding: 2px; +} + +QStatusBar::item { + border: None; +} + +/* Progress Bars (Downloads) #QProgressBar */ +QProgressBar { + background-color: #e6e6e6; + color: #000; + border-color: #bcbcbc; + text-align: center; + border-style: solid; + border-width: 1px; + margin: 0px; +} + +QProgressBar::chunk { + background: #06b025; +} + +DownloadListView[downloadView=standard]::item { + padding: 16px; +} + +DownloadListView[downloadView=compact]::item { + padding: 4px; +} + +/* Right Pane and Tab Bars #QTabWidget, #QTabBar */ +QTabWidget::pane { + border-color: #3f3f46; + border-top-color: #007acc; + top: 0; + border-style: solid; + border-width: 1px; +} + +QTabWidget::pane:disabled { + border-top-color: #3f3f46; +} + +QTabBar::tab { + background-color: transparent; + padding: 4px 1em; + border: none; +} + +QTabBar::tab:hover { + background-color: #1c97ea; +} + +QTabBar::tab:selected, +QTabBar::tab:selected:hover { + background-color: #007acc; +} + +QTabBar::tab:disabled { + background-color: transparent; + color: #656565; +} + +QTabBar::tab:selected:disabled { + background-color: #3f3f46; +} + +/* Scrollers */ +QTabBar QToolButton { + background-color: #333337; + border-color: #3f3f46; + padding: 1px; + margin: 0; + border-style: solid; + border-width: 1px; +} + +QTabBar QToolButton:hover { + border-color: #007acc; + border-width: 1px; + border-style: solid; +} + +QTabBar QToolButton:disabled, +QTabBar QToolButton:pressed:hover { + background-color: #333337; +} + +QTabBar::scroller { + width: 23px; + background-color: red; +} + +QTabBar QToolButton::right-arrow { + image: url(./vs15/scrollbar-right.png); +} + +QTabBar QToolButton::right-arrow:hover { + image: url(./vs15/scrollbar-right-hover.png); +} + +QTabBar QToolButton::left-arrow { + image: url(./vs15/scrollbar-left.png); +} + +QTabBar QToolButton::left-arrow:hover { + image: url(./vs15/scrollbar-left-hover.png); +} + +/* Special styles */ +QWidget#tabImages QPushButton { + background-color: transparent; + margin: 0 0.3em; + padding: 0; +} + +/* like dialog QPushButton*/ +QWidget#tabESPs QToolButton { + color: #000000; + background-color: #dddddd; + border-color: #707070; + border-style: solid; + border-width: 1px; +} + +QWidget#tabESPs QToolButton:hover { + background-color: #BEE6FD; + border-color: #3c7fb1; +} + +QWidget#tabESPs QToolButton:focus { + background-color: #dddddd; + border-color: #3399ff; +} + +QWidget#tabESPs QToolButton:disabled { + background-color: #333337; + border-color: #3f3f46; +} + +QTreeWidget#categoriesList { + /* min-width: 225px; */ +} + +QTreeWidget#categoriesList::item { + background-position: center left; + background-repeat: no-repeat; + padding: 0.35em 10px; +} + +QTreeWidget#categoriesList::item:has-children { + background-image: url(./vs15/branch-closed.png); +} + +QTreeWidget#categoriesList::item:has-children:open { + background-image: url(./vs15/branch-open.png); +} + +QDialog#QueryOverwriteDialog QPushButton { + margin-left: 0.5em; +} + +QDialog#PyCfgDialog QPushButton:hover { + background-color: #BEE6FD; +} + +QLineEdit#modFilterEdit { + margin-top: 2px; +} + +/* highlight unchecked BSAs */ +QWidget#bsaTab QTreeWidget::indicator:unchecked { + background-color: #3399ff; +} + +/* increase version text field */ +QLineEdit#versionEdit { + max-width: 100px; +} + +/* Dialogs width changes */ +/* increase width to prevent buttons cutting */ +QDialog#QueryOverwriteDialog { + min-width: 565px; +} + +QDialog#ModInfoDialog { + min-width: 850px; +} + +QLineEdit[valid-filter=false] { + background-color: #661111 !important; +} diff --git a/src/src/stylesheets/vs15/branch-closed.png b/src/src/stylesheets/vs15/branch-closed.png new file mode 100644 index 0000000..98d5052 Binary files /dev/null and b/src/src/stylesheets/vs15/branch-closed.png differ diff --git a/src/src/stylesheets/vs15/branch-open.png b/src/src/stylesheets/vs15/branch-open.png new file mode 100644 index 0000000..7b5d0fb Binary files /dev/null and b/src/src/stylesheets/vs15/branch-open.png differ diff --git a/src/src/stylesheets/vs15/checkbox-check-disabled.png b/src/src/stylesheets/vs15/checkbox-check-disabled.png new file mode 100644 index 0000000..f0385f1 Binary files /dev/null and b/src/src/stylesheets/vs15/checkbox-check-disabled.png differ diff --git a/src/src/stylesheets/vs15/checkbox-check.png b/src/src/stylesheets/vs15/checkbox-check.png new file mode 100644 index 0000000..ab72eeb Binary files /dev/null and b/src/src/stylesheets/vs15/checkbox-check.png differ diff --git a/src/src/stylesheets/vs15/combobox-down.png b/src/src/stylesheets/vs15/combobox-down.png new file mode 100644 index 0000000..ab4cae9 Binary files /dev/null and b/src/src/stylesheets/vs15/combobox-down.png differ diff --git a/src/src/stylesheets/vs15/scrollbar-down-disabled.png b/src/src/stylesheets/vs15/scrollbar-down-disabled.png new file mode 100644 index 0000000..fe6a465 Binary files /dev/null and b/src/src/stylesheets/vs15/scrollbar-down-disabled.png differ diff --git a/src/src/stylesheets/vs15/scrollbar-down-hover.png b/src/src/stylesheets/vs15/scrollbar-down-hover.png new file mode 100644 index 0000000..ad2e28c Binary files /dev/null and b/src/src/stylesheets/vs15/scrollbar-down-hover.png differ diff --git a/src/src/stylesheets/vs15/scrollbar-down.png b/src/src/stylesheets/vs15/scrollbar-down.png new file mode 100644 index 0000000..fcc3e6a Binary files /dev/null and b/src/src/stylesheets/vs15/scrollbar-down.png differ diff --git a/src/src/stylesheets/vs15/scrollbar-left-disabled.png b/src/src/stylesheets/vs15/scrollbar-left-disabled.png new file mode 100644 index 0000000..46dc794 Binary files /dev/null and b/src/src/stylesheets/vs15/scrollbar-left-disabled.png differ diff --git a/src/src/stylesheets/vs15/scrollbar-left-hover.png b/src/src/stylesheets/vs15/scrollbar-left-hover.png new file mode 100644 index 0000000..af1e432 Binary files /dev/null and b/src/src/stylesheets/vs15/scrollbar-left-hover.png differ diff --git a/src/src/stylesheets/vs15/scrollbar-left.png b/src/src/stylesheets/vs15/scrollbar-left.png new file mode 100644 index 0000000..b742b22 Binary files /dev/null and b/src/src/stylesheets/vs15/scrollbar-left.png differ diff --git a/src/src/stylesheets/vs15/scrollbar-right-disabled.png b/src/src/stylesheets/vs15/scrollbar-right-disabled.png new file mode 100644 index 0000000..f61615a Binary files /dev/null and b/src/src/stylesheets/vs15/scrollbar-right-disabled.png differ diff --git a/src/src/stylesheets/vs15/scrollbar-right-hover.png b/src/src/stylesheets/vs15/scrollbar-right-hover.png new file mode 100644 index 0000000..62d9993 Binary files /dev/null and b/src/src/stylesheets/vs15/scrollbar-right-hover.png differ diff --git a/src/src/stylesheets/vs15/scrollbar-right.png b/src/src/stylesheets/vs15/scrollbar-right.png new file mode 100644 index 0000000..05eb173 Binary files /dev/null and b/src/src/stylesheets/vs15/scrollbar-right.png differ diff --git a/src/src/stylesheets/vs15/scrollbar-up-disabled.png b/src/src/stylesheets/vs15/scrollbar-up-disabled.png new file mode 100644 index 0000000..2797ef8 Binary files /dev/null and b/src/src/stylesheets/vs15/scrollbar-up-disabled.png differ diff --git a/src/src/stylesheets/vs15/scrollbar-up-hover.png b/src/src/stylesheets/vs15/scrollbar-up-hover.png new file mode 100644 index 0000000..2c34ab8 Binary files /dev/null and b/src/src/stylesheets/vs15/scrollbar-up-hover.png differ diff --git a/src/src/stylesheets/vs15/scrollbar-up.png b/src/src/stylesheets/vs15/scrollbar-up.png new file mode 100644 index 0000000..23b3d6d Binary files /dev/null and b/src/src/stylesheets/vs15/scrollbar-up.png differ diff --git a/src/src/stylesheets/vs15/sort-asc.png b/src/src/stylesheets/vs15/sort-asc.png new file mode 100644 index 0000000..b1e01fd Binary files /dev/null and b/src/src/stylesheets/vs15/sort-asc.png differ diff --git a/src/src/stylesheets/vs15/sort-desc.png b/src/src/stylesheets/vs15/sort-desc.png new file mode 100644 index 0000000..6cb891b Binary files /dev/null and b/src/src/stylesheets/vs15/sort-desc.png differ diff --git a/src/src/stylesheets/vs15/spinner-down.png b/src/src/stylesheets/vs15/spinner-down.png new file mode 100644 index 0000000..7830720 Binary files /dev/null and b/src/src/stylesheets/vs15/spinner-down.png differ diff --git a/src/src/stylesheets/vs15/spinner-up.png b/src/src/stylesheets/vs15/spinner-up.png new file mode 100644 index 0000000..a635a51 Binary files /dev/null and b/src/src/stylesheets/vs15/spinner-up.png differ diff --git a/src/src/stylesheets/vs15/sub-menu-arrow-hover.png b/src/src/stylesheets/vs15/sub-menu-arrow-hover.png new file mode 100644 index 0000000..81ef4ae Binary files /dev/null and b/src/src/stylesheets/vs15/sub-menu-arrow-hover.png differ diff --git a/src/src/stylesheets/vs15/sub-menu-arrow.png b/src/src/stylesheets/vs15/sub-menu-arrow.png new file mode 100644 index 0000000..0357366 Binary files /dev/null and b/src/src/stylesheets/vs15/sub-menu-arrow.png differ diff --git a/src/src/syncoverwritedialog.cpp b/src/src/syncoverwritedialog.cpp new file mode 100644 index 0000000..f8f9294 --- /dev/null +++ b/src/src/syncoverwritedialog.cpp @@ -0,0 +1,176 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "syncoverwritedialog.h" +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +#include "ui_syncoverwritedialog.h" + +#include +#include +#include + +#include +#include +#include +#include + +using namespace MOBase; +using namespace MOShared; + +SyncOverwriteDialog::SyncOverwriteDialog(const QString& path, + DirectoryEntry* directoryStructure, + QWidget* parent) + : TutorableDialog("SyncOverwrite", parent), ui(new Ui::SyncOverwriteDialog), + m_SourcePath(path), m_DirectoryStructure(directoryStructure) +{ + ui->setupUi(this); + refresh(path); + + QHeaderView* headerView = ui->syncTree->header(); +#if QT_VERSION >= QT_VERSION_CHECK(5, 0, 0) + headerView->setSectionResizeMode(0, QHeaderView::Stretch); + headerView->setSectionResizeMode(1, QHeaderView::Interactive); +#else + headerView->setResizeMode(0, QHeaderView::Stretch); + headerView->setResizeMode(1, QHeaderView::Interactive); +#endif +} + +SyncOverwriteDialog::~SyncOverwriteDialog() +{ + delete ui; +} + +static void addToComboBox(QComboBox* box, const QString& name, const QVariant& userData) +{ + if (QString::compare(name, "overwrite", Qt::CaseInsensitive) != 0) { + box->addItem(name, userData); + } +} + +void SyncOverwriteDialog::readTree(const QString& path, + DirectoryEntry* directoryStructure, + QTreeWidgetItem* subTree) +{ + QDir overwrite(path); + overwrite.setFilter(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot); + QDirIterator dirIter(overwrite); + while (dirIter.hasNext()) { + dirIter.next(); + QFileInfo fileInfo = dirIter.fileInfo(); + + QString file = fileInfo.fileName(); + if (file == "meta.ini") { + continue; + } + + QTreeWidgetItem* newItem = new QTreeWidgetItem(subTree, QStringList(file)); + + if (fileInfo.isDir()) { + DirectoryEntry* subDir = directoryStructure->findSubDirectory(ToWString(file)); + if (subDir != nullptr) { + readTree(fileInfo.absoluteFilePath(), subDir, newItem); + } else { + log::error("no directory structure for {}?", file); + delete newItem; + newItem = nullptr; + } + } else { + const FileEntryPtr entry = directoryStructure->findFile(ToWString(file)); + QComboBox* combo = new QComboBox(ui->syncTree); + combo->addItem(tr(""), -1); + if (entry.get() != nullptr) { + bool ignore; + int origin = entry->getOrigin(ignore); + addToComboBox(combo, + ToQString(m_DirectoryStructure->getOriginByID(origin).getName()), + origin); + const auto& alternatives = entry->getAlternatives(); + for (const auto& alt : alternatives) { + addToComboBox( + combo, + ToQString(m_DirectoryStructure->getOriginByID(alt.originID()).getName()), + alt.originID()); + } + combo->setCurrentIndex(combo->count() - 1); + } else { + combo->setCurrentIndex(0); + } + ui->syncTree->setItemWidget(newItem, 1, combo); + } + if (newItem != nullptr) { + subTree->addChild(newItem); + } + } +} + +void SyncOverwriteDialog::refresh(const QString& path) +{ + QTreeWidgetItem* rootItem = new QTreeWidgetItem(ui->syncTree, QStringList("")); + readTree(path, m_DirectoryStructure, rootItem); + ui->syncTree->addTopLevelItem(rootItem); + ui->syncTree->expandAll(); +} + +void SyncOverwriteDialog::applyTo(QTreeWidgetItem* item, const QString& path, + const QString& modDirectory) +{ + for (int i = 0; i < item->childCount(); ++i) { + QTreeWidgetItem* child = item->child(i); + QString filePath; + if (path.length() != 0) { + filePath = path + "/" + child->text(0); + } else { + filePath = child->text(0); + } + if (child->childCount() != 0) { + applyTo(child, filePath, modDirectory); + } else { + QComboBox* comboBox = + qobject_cast(ui->syncTree->itemWidget(child, 1)); + if (comboBox != nullptr) { + int originID = + comboBox->itemData(comboBox->currentIndex(), Qt::UserRole).toInt(); + if (originID != -1) { + FilesOrigin& origin = m_DirectoryStructure->getOriginByID(originID); + QString source = m_SourcePath + "/" + filePath; + QString destination = + modDirectory + "/" + ToQString(origin.getName()) + "/" + filePath; + if (!QFile::remove(destination)) { + reportError(tr("failed to remove %1").arg(destination)); + } else if (!QFile::rename(source, destination)) { + reportError(tr("failed to move %1 to %2").arg(source).arg(destination)); + } + } + } + } + } + + QDir dir(m_SourcePath + "/" + path); + if ((path.length() > 0) && (dir.count() == 2)) { + dir.rmpath("."); + } +} + +void SyncOverwriteDialog::apply(const QString& modDirectory) +{ + applyTo(ui->syncTree->topLevelItem(0), "", modDirectory); +} diff --git a/src/src/syncoverwritedialog.h b/src/src/syncoverwritedialog.h new file mode 100644 index 0000000..d548988 --- /dev/null +++ b/src/src/syncoverwritedialog.h @@ -0,0 +1,57 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef SYNCOVERWRITEDIALOG_H +#define SYNCOVERWRITEDIALOG_H + +#include "shared/fileregisterfwd.h" +#include "tutorabledialog.h" +#include + +namespace Ui +{ +class SyncOverwriteDialog; +} + +class SyncOverwriteDialog : public MOBase::TutorableDialog +{ + Q_OBJECT + +public: + explicit SyncOverwriteDialog(const QString& path, + MOShared::DirectoryEntry* directoryStructure, + QWidget* parent = 0); + + ~SyncOverwriteDialog(); + + void apply(const QString& modDirectory); + +private: + void refresh(const QString& path); + void readTree(const QString& path, MOShared::DirectoryEntry* directoryStructure, + QTreeWidgetItem* subTree); + void applyTo(QTreeWidgetItem* item, const QString& path, const QString& modDirectory); + +private: + Ui::SyncOverwriteDialog* ui; + QString m_SourcePath; + MOShared::DirectoryEntry* m_DirectoryStructure; +}; + +#endif // SYNCOVERWRITEDIALOG_H diff --git a/src/src/syncoverwritedialog.ui b/src/src/syncoverwritedialog.ui new file mode 100644 index 0000000..18e5c1b --- /dev/null +++ b/src/src/syncoverwritedialog.ui @@ -0,0 +1,84 @@ + + + SyncOverwriteDialog + + + + 0 + 0 + 537 + 430 + + + + Sync Overwrite + + + + + + true + + + 300 + + + + Name + + + + + Sync To + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + SyncOverwriteDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + SyncOverwriteDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/src/systemtraymanager.cpp b/src/src/systemtraymanager.cpp new file mode 100644 index 0000000..ad96c7d --- /dev/null +++ b/src/src/systemtraymanager.cpp @@ -0,0 +1,77 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "systemtraymanager.h" + +#include +#include +#include +#include +#include +#include + +SystemTrayManager::SystemTrayManager(QMainWindow* parent, QDockWidget* logDock) + : m_Parent(parent), m_LogDock(logDock), + m_SystemTrayIcon(new QSystemTrayIcon(QIcon(":/MO/gui/app_icon"), m_Parent)) +{ + m_SystemTrayIcon->setToolTip(tr("Mod Organizer")); + + connect(m_SystemTrayIcon, SIGNAL(activated(QSystemTrayIcon::ActivationReason)), this, + SLOT(on_systemTrayIcon_activated(QSystemTrayIcon::ActivationReason))); + + auto* exitAction = new QAction(tr("Exit"), m_SystemTrayIcon); + connect(exitAction, &QAction::triggered, m_Parent, &QMainWindow::close); + + auto* trayMenu = new QMenu(m_Parent); + trayMenu->addAction(exitAction); + + m_SystemTrayIcon->setContextMenu(trayMenu); +} + +void SystemTrayManager::minimizeToSystemTray() +{ + m_SystemTrayIcon->show(); + m_Parent->hide(); + + if (m_LogDock->isFloating() && m_LogDock->isVisible()) { + m_LogDock->hide(); + } +} + +void SystemTrayManager::restoreFromSystemTray() +{ + m_SystemTrayIcon->hide(); + + m_Parent->showNormal(); + m_Parent->raise(); + m_Parent->activateWindow(); + + if (m_LogDock->isFloating() && m_LogDock->isHidden()) { + m_LogDock->show(); + } +} + +void SystemTrayManager::on_systemTrayIcon_activated( + QSystemTrayIcon::ActivationReason reason) +{ + if (m_Parent->isHidden() && reason == QSystemTrayIcon::Trigger) { + // left click + restoreFromSystemTray(); + } +} diff --git a/src/src/systemtraymanager.h b/src/src/systemtraymanager.h new file mode 100644 index 0000000..f46cd7a --- /dev/null +++ b/src/src/systemtraymanager.h @@ -0,0 +1,48 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef SYSTEMTRAYMANAGER_H +#define SYSTEMTRAYMANAGER_H + +#include +#include +#include +#include + +class SystemTrayManager : public QObject +{ + Q_OBJECT + +public: + explicit SystemTrayManager(QMainWindow* parent, QDockWidget* logDock); + + void minimizeToSystemTray(); + void restoreFromSystemTray(); + +private: + QMainWindow* m_Parent; + QDockWidget* m_LogDock; + + QSystemTrayIcon* m_SystemTrayIcon; + +private slots: + void on_systemTrayIcon_activated(QSystemTrayIcon::ActivationReason reason); +}; + +#endif // SYSTEMTRAYMANAGER_H diff --git a/src/src/texteditor.cpp b/src/src/texteditor.cpp new file mode 100644 index 0000000..2ac9a7b --- /dev/null +++ b/src/src/texteditor.cpp @@ -0,0 +1,550 @@ +#include "texteditor.h" +#include "utility.h" +#include +#include + +using namespace MOBase; + +TextEditor::TextEditor(QWidget* parent) + : QPlainTextEdit(parent), m_toolbar(nullptr), m_lineNumbers(nullptr), + m_highlighter(nullptr), m_dirty(false), m_loading(false) +{ + m_toolbar = new TextEditorToolbar(*this); + m_lineNumbers = new TextEditorLineNumbers(*this); + m_highlighter = new TextEditorHighlighter(document()); + + setDefaultStyle(); + wordWrap(true); + + emit modified(false); + + connect(document(), &QTextDocument::modificationChanged, [&](bool b) { + onModified(b); + }); + + connect(this, &QPlainTextEdit::cursorPositionChanged, [&] { + highlightCurrentLine(); + }); +} + +void TextEditor::setDefaultStyle() +{ + const auto font = QFontDatabase::systemFont(QFontDatabase::FixedFont); + + setFont(font); + m_lineNumbers->setFont(font); + + QColor textColor(Qt::black); + QColor altTextColor(Qt::darkGray); + QColor backgroundColor(Qt::white); + + { + auto w = std::make_unique(); + + if (auto* s = style()) { + s->polish(w.get()); + } + + textColor = w->palette().color(QPalette::WindowText); + altTextColor = w->palette().color(QPalette::Disabled, QPalette::WindowText); + backgroundColor = w->palette().color(QPalette::Window); + } + + setTextColor(textColor); + m_lineNumbers->setTextColor(altTextColor); + + setBackgroundColor(backgroundColor); + m_lineNumbers->setBackgroundColor(backgroundColor); + + setHighlightBackgroundColor(backgroundColor); +} + +void TextEditor::clear() +{ + QScopedValueRollback loading(m_loading, true); + + m_filename.clear(); + m_encoding.clear(); + m_needsBOM = false; + setPlainText(""); + dirty(false); + document()->setModified(false); + + emit loaded(""); +} + +bool TextEditor::load(const QString& filename) +{ + clear(); + + QScopedValueRollback loading(m_loading, true); + + m_filename = filename; + + const QString s = MOBase::readFileText(filename, &m_encoding, &m_needsBOM); + + setPlainText(s); + document()->setModified(false); + + if (s.isEmpty()) { + // the modificationChanged even is not fired by the setModified() call + // above when the text being set is empty + onModified(false); + } + + emit loaded(m_filename); + + return true; +} + +bool TextEditor::save() +{ + if (m_filename.isEmpty() || m_encoding.isEmpty()) { + return false; + } + + QFile file(m_filename); + file.open(QIODevice::WriteOnly); + file.resize(0); + + auto codec = QStringConverter::encodingForName(m_encoding.toUtf8()); + if (!codec.has_value()) + return false; + QStringConverter::Flags flags = QStringEncoder::Flag::Default; + if (m_needsBOM) + flags |= QStringConverter::Flag::WriteBom; + QStringEncoder encoder(codec.value(), flags); + + QString data = toPlainText().replace("\n", "\r\n"); + + file.write(encoder.encode(data)); + document()->setModified(false); + + return true; +} + +const QString& TextEditor::filename() const +{ + return m_filename; +} + +void TextEditor::wordWrap(bool b) +{ + if (b) { + setLineWrapMode(QPlainTextEdit::WidgetWidth); + } else { + setLineWrapMode(QPlainTextEdit::NoWrap); + } + + emit wordWrapChanged(b); +} + +void TextEditor::toggleWordWrap() +{ + wordWrap(!wordWrap()); +} + +bool TextEditor::wordWrap() const +{ + return (lineWrapMode() == QPlainTextEdit::WidgetWidth); +} + +void TextEditor::dirty(bool b) +{ + m_dirty = b; +} + +bool TextEditor::dirty() const +{ + return m_dirty; +} + +QColor TextEditor::backgroundColor() const +{ + return m_highlighter->backgroundColor(); +} + +void TextEditor::setBackgroundColor(const QColor& c) +{ + if (m_highlighter->backgroundColor() == c) { + return; + } + + m_highlighter->setBackgroundColor(c); + + setStyleSheet(QString("QPlainTextEdit{ background-color: rgba(%1, %2, %3, %4); }") + .arg(c.redF() * 255) + .arg(c.greenF() * 255) + .arg(c.blueF() * 255) + .arg(c.alphaF())); +} + +QColor TextEditor::textColor() const +{ + return m_highlighter->textColor(); +} + +void TextEditor::setTextColor(const QColor& c) +{ + m_highlighter->setTextColor(c); +} + +QColor TextEditor::highlightBackgroundColor() const +{ + return m_highlightBackground; +} + +void TextEditor::setHighlightBackgroundColor(const QColor& c) +{ + m_highlightBackground = c; + update(); +} + +void TextEditor::explore() +{ + if (m_filename.isEmpty()) { + return; + } + + shell::Explore(m_filename); +} + +void TextEditor::onModified(bool b) +{ + if (m_loading) { + return; + } + + dirty(b); + emit modified(b); +} + +void TextEditor::setupToolbar() +{ + auto* widget = wrapEditWidget(); + if (!widget) { + return; + } + + auto* layout = new QVBoxLayout(widget); + + // adding toolbar and edit + layout->addWidget(m_toolbar); + layout->addWidget(this); + + // make the edit stretch + layout->setStretch(0, 0); + layout->setStretch(1, 1); + + // visuals + layout->setContentsMargins(0, 0, 0, 0); + widget->show(); +} + +QWidget* TextEditor::wrapEditWidget() +{ + auto widget = std::make_unique(); + + // wrapping the QPlainTextEdit into a new widget so the toolbar can be + // displayed above it + + if (auto* parentLayout = parentWidget()->layout()) { + // the edit's parent has a regular layout, replace the edit by the new + // widget and delete the QLayoutItem that's returned as it's not needed + delete parentLayout->replaceWidget(this, widget.get()); + + } else if (auto* splitter = qobject_cast(parentWidget())) { + // the edit's parent is a QSplitter, which doesn't have a layout; replace + // the edit by using its index in the splitter + auto index = splitter->indexOf(this); + + if (index == -1) { + log::error("TextEditor: cannot wrap edit widget to display a toolbar, " + "parent is a splitter, but widget isn't in it"); + + return nullptr; + } + + splitter->replaceWidget(index, widget.get()); + + } else { + // unknown parent + log::error("TextEditor: cannot wrap edit widget to display a toolbar, " + "no parent or parent has no layout"); + + return nullptr; + } + + return widget.release(); +} + +void TextEditor::resizeEvent(QResizeEvent* e) +{ + QPlainTextEdit::resizeEvent(e); + + QRect cr = contentsRect(); + m_lineNumbers->setGeometry( + QRect(cr.left(), cr.top(), m_lineNumbers->areaWidth(), cr.height())); +} + +void TextEditor::paintLineNumbers(QPaintEvent* e, const QColor& textColor) +{ + QStyleOption opt; + opt.initFrom(m_lineNumbers); + + QPainter painter(m_lineNumbers); + + QTextBlock block = firstVisibleBlock(); + int blockNumber = block.blockNumber(); + int top = (int)blockBoundingGeometry(block).translated(contentOffset()).top(); + int bottom = top + (int)blockBoundingRect(block).height(); + + while (block.isValid() && top <= e->rect().bottom()) { + if (block.isVisible() && bottom >= e->rect().top()) { + QString number = QString::number(blockNumber + 1); + painter.setPen(textColor); + + painter.drawText(0, top, m_lineNumbers->width() - 3, fontMetrics().height(), + Qt::AlignRight, number); + } + + block = block.next(); + top = bottom; + bottom = top + (int)blockBoundingRect(block).height(); + ++blockNumber; + } +} + +void TextEditor::highlightCurrentLine() +{ + QList extraSelections; + + if (!isReadOnly()) { + QTextEdit::ExtraSelection selection; + + QColor lineColor = QColor(Qt::yellow).lighter(160); + + selection.format.setBackground(m_highlightBackground); + selection.format.setProperty(QTextFormat::FullWidthSelection, true); + selection.cursor = textCursor(); + selection.cursor.clearSelection(); + extraSelections.append(selection); + } + + setExtraSelections(extraSelections); +} + +TextEditorHighlighter::TextEditorHighlighter(QTextDocument* doc) + : QSyntaxHighlighter(doc), m_background(QColor("transparent")), + m_text(QColor("black")) +{} + +QColor TextEditorHighlighter::backgroundColor() const +{ + return m_background; +} + +void TextEditorHighlighter::setBackgroundColor(const QColor& c) +{ + m_background = c; + changed(); +} + +QColor TextEditorHighlighter::textColor() const +{ + return m_text; +} + +void TextEditorHighlighter::setTextColor(const QColor& c) +{ + m_text = c; + changed(); +} + +void TextEditorHighlighter::highlightBlock(const QString& s) +{ + QTextCharFormat f; + f.setBackground(m_background); + f.setForeground(m_text); + + setFormat(0, s.size(), f); +} + +void TextEditorHighlighter::changed() +{ + rehighlight(); +} + +TextEditorLineNumbers::TextEditorLineNumbers(TextEditor& editor) + : QFrame(&editor), m_editor(editor) +{ + setFont(editor.font()); + + connect(&m_editor, &QPlainTextEdit::blockCountChanged, [&] { + updateAreaWidth(); + }); + connect(&m_editor, &QPlainTextEdit::updateRequest, [&](auto&& rect, int dy) { + updateArea(rect, dy); + }); + + updateAreaWidth(); +} + +QSize TextEditorLineNumbers::sizeHint() const +{ + return QSize(areaWidth(), 0); +} + +int TextEditorLineNumbers::areaWidth() const +{ + int digits = 1; + int max = std::max(1, m_editor.blockCount()); + + while (max >= 10) { + max /= 10; + ++digits; + } + + digits = std::max(3, digits); + + int space = 3 + fontMetrics().horizontalAdvance(QLatin1Char('9')) * digits + 3; + + return space; +} + +QColor TextEditorLineNumbers::textColor() const +{ + return m_text; +} + +void TextEditorLineNumbers::setTextColor(const QColor& c) +{ + m_text = c; + m_editor.update(); +} + +QColor TextEditorLineNumbers::backgroundColor() const +{ + return m_background; +} + +void TextEditorLineNumbers::setBackgroundColor(const QColor& c) +{ + m_background = c; + m_editor.update(); +} + +void TextEditorLineNumbers::paintEvent(QPaintEvent* e) +{ + QPainter painter(this); + painter.fillRect(e->rect(), m_background); + + QFrame::paintEvent(e); + m_editor.paintLineNumbers(e, m_text); +} + +void TextEditorLineNumbers::updateAreaWidth() +{ + m_editor.setViewportMargins(areaWidth(), 0, 0, 0); +} + +void TextEditorLineNumbers::updateArea(const QRect& rect, int dy) +{ + if (dy) { + scroll(0, dy); + } else { + update(0, rect.y(), width(), rect.height()); + } + + if (rect.contains(m_editor.viewport()->rect())) { + updateAreaWidth(); + } +} + +TextEditorToolbar::TextEditorToolbar(TextEditor& editor) + : m_editor(editor), m_save(nullptr), m_wordWrap(nullptr), m_explore(nullptr), + m_path(nullptr) +{ + m_save = new QAction(QIcon(":/MO/gui/save"), QObject::tr("&Save"), &editor); + + m_save->setShortcutContext(Qt::WidgetWithChildrenShortcut); + m_save->setShortcut(Qt::CTRL + Qt::Key_S); + m_editor.addAction(m_save); + + m_wordWrap = + new QAction(QIcon(":/MO/gui/word-wrap"), QObject::tr("&Word wrap"), &editor); + + m_wordWrap->setCheckable(true); + + m_explore = new QAction(QObject::tr("&Open in Explorer"), &editor); + + m_path = new QLineEdit; + m_path->setReadOnly(true); + + QObject::connect(m_save, &QAction::triggered, [&] { + m_editor.save(); + }); + QObject::connect(m_wordWrap, &QAction::triggered, [&] { + m_editor.toggleWordWrap(); + }); + QObject::connect(m_explore, &QAction::triggered, [&] { + m_editor.explore(); + }); + + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setAlignment(Qt::AlignLeft); + + auto* b = new QToolButton; + b->setDefaultAction(m_save); + layout->addWidget(b); + + b = new QToolButton; + b->setDefaultAction(m_wordWrap); + layout->addWidget(b); + + b = new QToolButton; + b->setDefaultAction(m_explore); + layout->addWidget(b); + + layout->addWidget(m_path); + + QObject::connect(&m_editor, &TextEditor::modified, [&](bool b) { + onTextModified(b); + }); + QObject::connect(&m_editor, &TextEditor::wordWrapChanged, [&](bool b) { + onWordWrap(b); + }); + QObject::connect(&m_editor, &TextEditor::loaded, [&](QString f) { + onLoaded(f); + }); +} + +void TextEditorToolbar::onTextModified(bool b) +{ + m_save->setEnabled(b); +} + +void TextEditorToolbar::onWordWrap(bool b) +{ + m_wordWrap->setChecked(b); +} + +void TextEditorToolbar::onLoaded(const QString& path) +{ + const auto hasDoc = !path.isEmpty(); + + m_explore->setEnabled(hasDoc); + m_wordWrap->setEnabled(hasDoc); + m_path->setEnabled(hasDoc); + m_path->setText(path); +} + +void HTMLEditor::focusOutEvent(QFocusEvent* e) +{ + if (document() && document()->isModified()) { + emit editingFinished(); + } + + QTextEdit::focusInEvent(e); +} diff --git a/src/src/texteditor.h b/src/src/texteditor.h new file mode 100644 index 0000000..ed54056 --- /dev/null +++ b/src/src/texteditor.h @@ -0,0 +1,163 @@ +#ifndef MO_TEXTEDITOR_H +#define MO_TEXTEDITOR_H + +#include + +class TextEditor; + +class TextEditorToolbar : public QFrame +{ + Q_OBJECT; + +public: + TextEditorToolbar(TextEditor& editor); + +private: + TextEditor& m_editor; + QAction* m_save; + QAction* m_wordWrap; + QAction* m_explore; + QLineEdit* m_path; + + void onTextModified(bool b); + void onWordWrap(bool b); + void onLoaded(const QString& s); +}; + +// mostly from https://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html +// +class TextEditorLineNumbers : public QFrame +{ + Q_OBJECT; + Q_PROPERTY(QColor textColor READ textColor WRITE setTextColor); + Q_PROPERTY(QColor backgroundColor READ backgroundColor WRITE setBackgroundColor); + +public: + TextEditorLineNumbers(TextEditor& editor); + + QSize sizeHint() const override; + int areaWidth() const; + + QColor textColor() const; + void setTextColor(const QColor& c); + + QColor backgroundColor() const; + void setBackgroundColor(const QColor& c); + +protected: + void paintEvent(QPaintEvent* event) override; + +private: + TextEditor& m_editor; + QColor m_background, m_text; + + void updateAreaWidth(); + void updateArea(const QRect& rect, int dy); +}; + +class TextEditorHighlighter : public QSyntaxHighlighter +{ + Q_OBJECT; + +public: + TextEditorHighlighter(QTextDocument* doc); + + QColor backgroundColor() const; + void setBackgroundColor(const QColor& c); + + QColor textColor() const; + void setTextColor(const QColor& c); + +protected: + void highlightBlock(const QString& text) override; + +private: + QColor m_background, m_text; + + void changed(); +}; + +class TextEditor : public QPlainTextEdit +{ + Q_OBJECT; + Q_PROPERTY(QColor textColor READ textColor WRITE setTextColor); + Q_PROPERTY(QColor backgroundColor READ backgroundColor WRITE setBackgroundColor); + Q_PROPERTY(QColor highlightBackgroundColor READ highlightBackgroundColor WRITE + setHighlightBackgroundColor); + + friend class TextEditorLineNumbers; + +public: + TextEditor(QWidget* parent = nullptr); + + void setupToolbar(); + + void clear(); + bool load(const QString& filename); + bool save(); + + const QString& filename() const; + + void wordWrap(bool b); + void toggleWordWrap(); + bool wordWrap() const; + + bool dirty() const; + + QColor backgroundColor() const; + void setBackgroundColor(const QColor& c); + + QColor textColor() const; + void setTextColor(const QColor& c); + + QColor highlightBackgroundColor() const; + void setHighlightBackgroundColor(const QColor& c); + + void explore(); + +signals: + void loaded(QString filename); + void modified(bool b); + void wordWrapChanged(bool b); + +protected: + void resizeEvent(QResizeEvent* e) override; + +private: + TextEditorToolbar* m_toolbar; + TextEditorLineNumbers* m_lineNumbers; + TextEditorHighlighter* m_highlighter; + QColor m_highlightBackground; + QString m_filename; + QString m_encoding; + bool m_needsBOM; + bool m_dirty; + bool m_loading; + + void setDefaultStyle(); + void onModified(bool b); + void dirty(bool b); + + QWidget* wrapEditWidget(); + + void highlightCurrentLine(); + void paintLineNumbers(QPaintEvent* e, const QColor& textColor); +}; + +class HTMLEditor : public QTextEdit +{ + Q_OBJECT; + +public: + using QTextEdit::QTextEdit; + +signals: + void editingFinished(); + +protected: + void focusOutEvent(QFocusEvent* e); + +private: +}; + +#endif // MO_TEXTEDITOR_H diff --git a/src/src/thread_utils.h b/src/src/thread_utils.h new file mode 100644 index 0000000..0d29be4 --- /dev/null +++ b/src/src/thread_utils.h @@ -0,0 +1,76 @@ +#ifndef MO2_THREAD_UTILS_H +#define MO2_THREAD_UTILS_H + +#include +#include +#include +#include + +// in main.cpp +void setExceptionHandlers(); + +namespace MOShared +{ + +// starts an std::thread with an unhandled exception handler for core dumps +// and a top-level catch +// +template +std::thread startSafeThread(F&& f) +{ + return std::thread([f = std::forward(f)] { + setExceptionHandlers(); + f(); + }); +} + +/** + * @brief Apply the given callable to each element between the two given iterators + * in a parallel way. + * + * The callable should be independent, or properly synchronized, and the source of + * the range should not change during this call. + * + * @param start Beginning of the range. + * @param end End of the range. + * @param callable Callable to apply to every element of the range. See std::invoke + * requirements. Must be copiable. + * @param nThreads Number of threads to use. + * + */ +template +void parallelMap(It begin, It end, Callable callable, std::size_t nThreads) +{ + std::mutex m; + std::vector threads(nThreads); + + // Create the thread: + // - The mutex is only used to fetch/increment the iterator. + // - The callable is copied in each thread to avoid conflicts. + for (auto& thread : threads) { + thread = startSafeThread([&m, &begin, end, callable]() { + while (true) { + decltype(begin) it; + { + std::scoped_lock lock(m); + if (begin == end) { + break; + } + it = begin++; + } + if (it != end) { + std::invoke(callable, *it); + } + } + }); + } + + // Join everything: + for (auto& t : threads) { + t.join(); + } +} + +} // namespace MOShared + +#endif diff --git a/src/src/transfersavesdialog.cpp b/src/src/transfersavesdialog.cpp new file mode 100644 index 0000000..e4f8810 --- /dev/null +++ b/src/src/transfersavesdialog.cpp @@ -0,0 +1,278 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "transfersavesdialog.h" + +#include "iplugingame.h" +#include "isavegame.h" +#include "savegameinfo.h" +#include "ui_transfersavesdialog.h" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MOBase; +using namespace MOShared; + +TransferSavesDialog::TransferSavesDialog(const Profile& profile, + IPluginGame const* gamePlugin, QWidget* parent) + : TutorableDialog("TransferSaves", parent), ui(new Ui::TransferSavesDialog), + m_Profile(profile), m_GamePlugin(gamePlugin) +{ + ui->setupUi(this); + ui->label_2->setText(tr("Characters for profile %1").arg(m_Profile.name())); + refreshGlobalSaves(); + refreshLocalSaves(); + refreshGlobalCharacters(); + refreshLocalCharacters(); +} + +TransferSavesDialog::~TransferSavesDialog() +{ + delete ui; +} + +void TransferSavesDialog::refreshGlobalSaves() +{ + refreshSaves(m_GlobalSaves, m_GamePlugin->savesDirectory().absolutePath()); +} + +void TransferSavesDialog::refreshLocalSaves() +{ + refreshSaves(m_LocalSaves, m_Profile.savePath()); +} + +void TransferSavesDialog::refreshGlobalCharacters() +{ + refreshCharacters(m_GlobalSaves, ui->globalCharacterList, ui->copyToLocalBtn, + ui->moveToLocalBtn); +} + +void TransferSavesDialog::refreshLocalCharacters() +{ + refreshCharacters(m_LocalSaves, ui->localCharacterList, ui->copyToGlobalBtn, + ui->moveToGlobalBtn); +} + +bool TransferSavesDialog::testOverwrite(OverwriteMode& overwriteMode, + const QString& destinationFile) +{ + QMessageBox::StandardButton res = + overwriteMode == OVERWRITE_YES ? QMessageBox::Yes : QMessageBox::No; + if (overwriteMode == OVERWRITE_ASK) { + res = QMessageBox::question(this, tr("Overwrite"), + tr("Overwrite the file \"%1\"").arg(destinationFile), + QMessageBox::Yes | QMessageBox::No | + QMessageBox::YesToAll | QMessageBox::NoToAll); + if (res == QMessageBox::YesToAll) { + overwriteMode = OVERWRITE_YES; + res = QMessageBox::Yes; + } else if (res == QMessageBox::NoToAll) { + overwriteMode = OVERWRITE_NO; + res = QMessageBox::No; + } + } + return res == QMessageBox::Yes; +} + +#define MOVE_SAVES "Move all save games of character \"%1\"" +#define COPY_SAVES "Copy all save games of character \"%1\"" + +#define TO_PROFILE "to the profile?" +#define TO_GLOBAL \ + "to the global location? Please be aware that this will mess up the running number " \ + "of save games." + +void TransferSavesDialog::on_moveToLocalBtn_clicked() +{ + QString character = ui->globalCharacterList->currentItem()->text(); + if (transferCharacters( + character, MOVE_SAVES TO_PROFILE, m_GamePlugin->savesDirectory(), + m_GlobalSaves[character], m_Profile.savePath(), + [this](const QString& source, const QString& destination) -> bool { + return shellMove(source, destination, this); + }, + "Failed to move {} to {}")) { + refreshGlobalSaves(); + refreshGlobalCharacters(); + refreshLocalSaves(); + refreshLocalCharacters(); + } +} + +void TransferSavesDialog::on_copyToLocalBtn_clicked() +{ + QString character = ui->globalCharacterList->currentItem()->text(); + if (transferCharacters( + character, COPY_SAVES TO_PROFILE, m_GamePlugin->savesDirectory(), + m_GlobalSaves[character], m_Profile.savePath(), + [this](const QString& source, const QString& destination) -> bool { + return shellCopy(source, destination, this); + }, + "Failed to copy {} to {}")) { + refreshLocalSaves(); + refreshLocalCharacters(); + } +} + +void TransferSavesDialog::on_moveToGlobalBtn_clicked() +{ + QString character = ui->localCharacterList->currentItem()->text(); + if (transferCharacters( + character, MOVE_SAVES TO_GLOBAL, m_Profile.savePath(), + m_LocalSaves[character], m_GamePlugin->savesDirectory().absolutePath(), + [this](const QString& source, const QString& destination) -> bool { + return shellMove(source, destination, this); + }, + "Failed to move {} to {}")) { + refreshGlobalSaves(); + refreshGlobalCharacters(); + refreshLocalSaves(); + refreshLocalCharacters(); + } +} + +void TransferSavesDialog::on_copyToGlobalBtn_clicked() +{ + QString character = ui->localCharacterList->currentItem()->text(); + if (transferCharacters( + character, COPY_SAVES TO_GLOBAL, m_Profile.savePath(), + m_LocalSaves[character], m_GamePlugin->savesDirectory().absolutePath(), + [this](const QString& source, const QString& destination) -> bool { + return shellCopy(source, destination, this); + }, + "Failed to copy {} to {}")) { + refreshGlobalSaves(); + refreshGlobalCharacters(); + } +} + +void TransferSavesDialog::on_doneButton_clicked() +{ + close(); +} + +void TransferSavesDialog::on_globalCharacterList_currentTextChanged( + const QString& currentText) +{ + ui->globalSavesList->clear(); + // sadly this can get called while we're resetting the list, with an invalid + // name, so we have to check. + SaveCollection::const_iterator saveList = m_GlobalSaves.find(currentText); + if (saveList != m_GlobalSaves.end()) { + for (SaveListItem const& save : saveList->second) { + ui->globalSavesList->addItem(QFileInfo(save->getFilepath()).fileName()); + } + } +} + +void TransferSavesDialog::on_localCharacterList_currentTextChanged( + const QString& currentText) +{ + ui->localSavesList->clear(); + // sadly this can get called while we're resetting the list, with an invalid + // name, so we have to check. + SaveCollection::const_iterator saveList = m_LocalSaves.find(currentText); + if (saveList != m_LocalSaves.end()) { + for (SaveListItem const& save : saveList->second) { + ui->localSavesList->addItem(QFileInfo(save->getFilepath()).fileName()); + } + } +} + +void TransferSavesDialog::refreshSaves(SaveCollection& saveCollection, + QString const& savedir) +{ + saveCollection.clear(); + + auto saves = m_GamePlugin->listSaves(savedir); + std::sort(saves.begin(), saves.end(), [](auto const& lhs, auto const& rhs) { + return lhs->getCreationTime() > rhs->getCreationTime(); + }); + + for (auto& save : saves) { + saveCollection[save->getSaveGroupIdentifier()].push_back(save); + } +} + +void TransferSavesDialog::refreshCharacters(const SaveCollection& saveCollection, + QListWidget* charList, QPushButton* copy, + QPushButton* move) +{ + charList->clear(); + for (SaveCollection::value_type const& val : saveCollection) { + charList->addItem(val.first); + } + if (charList->count() > 0) { + charList->setCurrentRow(0); + copy->setEnabled(true); + move->setEnabled(true); + } else { + copy->setEnabled(false); + move->setEnabled(false); + } +} + +bool TransferSavesDialog::transferCharacters( + QString const& character, char const* message, QDir const& sourceDirectory, + SaveList& saves, QDir const& destination, + const std::function& method, + std::format_string errmsg) +{ + if (QMessageBox::question(this, tr("Confirm"), tr(message).arg(character), + QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { + return false; + } + + OverwriteMode overwriteMode = OVERWRITE_ASK; + + for (SaveListItem const& save : saves) { + for (QString source : save->allFiles()) { + QFileInfo sourceFile(source); + QString destinationFile( + destination.absoluteFilePath(sourceDirectory.relativeFilePath(source))); + + // If the file is already there, let them skip (or not). + if (QFile::exists(destinationFile)) { + if (!testOverwrite(overwriteMode, destinationFile)) { + continue; + } + // OK, they want to remove it. + QFile::remove(destinationFile); + } + + if (!method(sourceFile.absoluteFilePath(), destinationFile)) { + log::error(errmsg, sourceFile.absoluteFilePath(), std::move(destinationFile)); + } + } + } + return true; +} diff --git a/src/src/transfersavesdialog.h b/src/src/transfersavesdialog.h new file mode 100644 index 0000000..6430b8b --- /dev/null +++ b/src/src/transfersavesdialog.h @@ -0,0 +1,115 @@ +/* +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef TRANSFERSAVESDIALOG_H +#define TRANSFERSAVESDIALOG_H + +#include "profile.h" +#include "tutorabledialog.h" + +class QListWidget; +#include +class QPushButton; +#include +class QWidget; + +#include +#include +#include + +namespace Ui +{ +class TransferSavesDialog; +} +namespace MOBase +{ +class IPluginGame; +} +namespace MOBase +{ +class ISaveGame; +} + +class TransferSavesDialog : public MOBase::TutorableDialog +{ + Q_OBJECT + +public: + explicit TransferSavesDialog(const Profile& profile, + MOBase::IPluginGame const* gamePlugin, + QWidget* parent = 0); + ~TransferSavesDialog(); + +private slots: + + void on_moveToLocalBtn_clicked(); + + void on_doneButton_clicked(); + + void on_globalCharacterList_currentTextChanged(const QString& currentText); + + void on_localCharacterList_currentTextChanged(const QString& currentText); + + void on_copyToLocalBtn_clicked(); + + void on_moveToGlobalBtn_clicked(); + + void on_copyToGlobalBtn_clicked(); + +private: + enum OverwriteMode + { + OVERWRITE_ASK, + OVERWRITE_YES, + OVERWRITE_NO + }; + +private: + void refreshGlobalCharacters(); + void refreshLocalCharacters(); + void refreshGlobalSaves(); + void refreshLocalSaves(); + bool testOverwrite(OverwriteMode& overwriteMode, const QString& destinationFile); + +private: + Ui::TransferSavesDialog* ui; + + Profile m_Profile; + + MOBase::IPluginGame const* m_GamePlugin; + + using SaveListItem = std::shared_ptr; + using SaveList = std::vector; + using SaveCollection = std::map; + + SaveCollection m_GlobalSaves; + SaveCollection m_LocalSaves; + + void refreshSaves(SaveCollection& saveCollection, const QString& savedir); + void refreshCharacters(SaveCollection const& saveCollection, QListWidget* charList, + QPushButton* copy, QPushButton* move); + + bool + transferCharacters(QString const& character, char const* message, + QDir const& sourceDirectory, SaveList& saves, QDir const& dest, + const std::function& method, + std::format_string errmsg); +}; + +#endif // TRANSFERSAVESDIALOG_H diff --git a/src/src/transfersavesdialog.ui b/src/src/transfersavesdialog.ui new file mode 100644 index 0000000..8bc2308 --- /dev/null +++ b/src/src/transfersavesdialog.ui @@ -0,0 +1,187 @@ + + + TransferSavesDialog + + + + 0 + 0 + 882 + 528 + + + + Transfer Save Games + + + + + + + + Global Characters + + + + + + + This is a list of characters in the global location. + + + This is a list of characters in the global location. + +On Windows Vista/Windows 7: + C:\Users\[UserName]\Documents\My Games\Skyrim\Saves + +On Windows XP: + C:\Documents and Settings\[UserName]\My Documents\My Games\Skyrim\Saves + + + + + + + + This is a list of save games for the selected character in the global location. + +On Windows Vista/Windows 7: + C:\Users\[UserName]\Documents\My Games\Skyrim\Saves + +On Windows XP: + C:\Documents and Settings[UserName]\My Documents\My Games\Skyrim\Saves + + + + + QAbstractItemView::NoSelection + + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 20 + 40 + + + + + + + + false + + + Move -> + + + + + + + false + + + Copy -> + + + + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 20 + 40 + + + + + + + + false + + + <- Move + + + + + + + false + + + <- Copy + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 20 + 100 + + + + + + + + Done + + + + + + + + + + + Profile Characters + + + + + + + + + + QAbstractItemView::NoSelection + + + + + + + + + + diff --git a/src/src/tutorials/Highlight.qml b/src/src/tutorials/Highlight.qml new file mode 100644 index 0000000..b6fb767 --- /dev/null +++ b/src/src/tutorials/Highlight.qml @@ -0,0 +1,18 @@ +import QtQuick 2.7 + +Rectangle { + radius: 10 + color: "transparent" + height: 100 + border.color: "black" + border.width: 3 + opacity: 0.9 + smooth: true + + SequentialAnimation on opacity { + loops: Animation.Infinite + + PropertyAnimation { easing.type: Easing.InOutSine; duration: 300; to: 0.1 } + PropertyAnimation { easing.type: Easing.OutInSine; duration: 300; to: 0.7 } + } +} diff --git a/src/src/tutorials/Tooltip.qml b/src/src/tutorials/Tooltip.qml new file mode 100644 index 0000000..866de83 --- /dev/null +++ b/src/src/tutorials/Tooltip.qml @@ -0,0 +1,29 @@ +import QtQuick 2.7 + + +Rectangle { + color: "#EE888888" + width: childrenRect.width + 15 + height: childrenRect.height + 15 + z: 20000 + clip: false + border.color: "black" + border.width: 1 + property alias text: tooltipText.text + Text { + id: tooltipText + font.pointSize: 11 + wrapMode: Text.WordWrap + anchors.left: parent.left + anchors.top: parent.top + anchors.leftMargin: 7 + anchors.rightMargin: 7 + z: parent.z + color: "black" + onTextChanged: { + if (width > 200) + width = 200 + } + } + visible: false +} diff --git a/src/src/tutorials/TooltipArea.qml b/src/src/tutorials/TooltipArea.qml new file mode 100644 index 0000000..8d404be --- /dev/null +++ b/src/src/tutorials/TooltipArea.qml @@ -0,0 +1,55 @@ +import QtQuick 2.7 + +Rectangle { + radius: 2 + color: clickable ? "#AA6666AA" : "#AA882222" + height: 100 + border.color: "black" + border.width: 2 + z: 1 + smooth: true + property bool wasLocked: false + property bool clickable: false + property string tooltipText: "" + + MouseArea { + id: clickArea + anchors.fill: parent + hoverEnabled: true + + onPositionChanged: { + if (parent.parent.width - (parent.x + mouseX) < tooltip.width + 50) { + tooltip.x = parent.x + mouseX - 15 - tooltip.width + } else { + tooltip.x = parent.x + mouseX + 15 + } + + if (parent.parent.height - (parent.y + mouseY) < tooltip.height + 50) { + tooltip.y = parent.y + mouseY - 15 - tooltip.height + } else { + tooltip.y = parent.y + mouseY + 15 + } + } + + onEntered: { + tooltip.visible = true + tooltip.text = tooltipText + } + + onPressed: { + wasLocked = tutToplevel.backgroundEnabled() + if (wasLocked && clickable) { + tutorialControl.simulateClick(mouseX + parent.x, mouseY + parent.y) + } + mouse.accepted = false + } + + onCanceled: { + tooltip.visible = false + } + + onExited: { + tooltip.visible = false + } + } +} diff --git a/src/src/tutorials/TutorialCanceller.qml b/src/src/tutorials/TutorialCanceller.qml new file mode 100644 index 0000000..d5874ae --- /dev/null +++ b/src/src/tutorials/TutorialCanceller.qml @@ -0,0 +1,55 @@ +import QtQuick 2.7 + +// rectangle for description texts +Rectangle { + property alias text: textBox.text + property alias cancelVisible: cancelIcon.visible + property int innerWidth; + signal clicked + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 70 + width: textBox.width + 30 + height: textBox.height + 8 + border.color: "black" + border.width: 3 + smooth: true + //opacity: 0.9 + z: 10000 + color: "#FF707070" + + Image { + id: cancelIcon + anchors.bottom: parent.bottom + anchors.bottomMargin: 5 + anchors.right: parent.right + anchors.rightMargin: 5 + source: "qrc:/MO/gui/multiply_red" + + SequentialAnimation on opacity { + loops: Animation.Infinite + + PauseAnimation { duration: 500 } + PropertyAnimation { easing.type: Easing.InOutSine; duration: 400; to: 0.0 } + PropertyAnimation { easing.type: Easing.OutInSine; duration: 400; to: 1.0 } + } + } + + Text { + id: textBox + text: qsTr("Exit Tutorial") + font.pointSize: 12 + font.bold: false + width: innerWidth + font.family: "Courier" + wrapMode: Text.WordWrap + anchors.centerIn: parent + } + + MouseArea { + id: clickArea + anchors.fill: parent + onClicked: parent.clicked() + } +} diff --git a/src/src/tutorials/TutorialDescription.qml b/src/src/tutorials/TutorialDescription.qml new file mode 100644 index 0000000..e8b4cb2 --- /dev/null +++ b/src/src/tutorials/TutorialDescription.qml @@ -0,0 +1,55 @@ +import QtQuick 2.7 + +// rectangle for description texts +Rectangle { + property alias text: textBox.text + property alias continueVisible: continueIcon.visible + property int innerWidth; + signal clicked + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 100 + width: textBox.width + 30 + height: textBox.height + 8 + border.color: "black" + border.width: 3 + smooth: true + //opacity: 0.9 + z: 10000 + color: "#FF707070" + + Image { + id: continueIcon + anchors.bottom: parent.bottom + anchors.bottomMargin: 5 + anchors.right: parent.right + anchors.rightMargin: 5 + source: "qrc:/MO/gui/next" + + SequentialAnimation on opacity { + loops: Animation.Infinite + + PauseAnimation { duration: 500 } + PropertyAnimation { easing.type: Easing.InOutSine; duration: 400; to: 0.0 } + PropertyAnimation { easing.type: Easing.OutInSine; duration: 400; to: 1.0 } + } + } + + Text { + id: textBox + text: "" + font.pointSize: 12 + font.bold: false + width: innerWidth + font.family: "Courier" + wrapMode: Text.WordWrap + anchors.centerIn: parent + } + + MouseArea { + id: clickArea + anchors.fill: parent + onClicked: parent.clicked() + } +} diff --git a/src/src/tutorials/TutorialOverlay.qml b/src/src/tutorials/TutorialOverlay.qml new file mode 100644 index 0000000..e937195 --- /dev/null +++ b/src/src/tutorials/TutorialOverlay.qml @@ -0,0 +1,80 @@ +import QtQuick 2.7 +import "tutorials.js" as Logic + +Rectangle { + color: "transparent" + + id: tutToplevel + + property int step : 0 + property alias description: tutDescription + property alias highlight: tutHighlight + property alias text: tutDescription.text + property alias boxOpacity: tutDescription.opacity + property int offsetBottom: 50 + property int maxWidth: 400 + + signal tabChanged(int index) + + function init() { + Logic.init() + Logic.nextStep() + } + + function enableBackground(enabled) { + disabledBackground.visible = enabled + } + + function backgroundEnabled() { + return disabledBackground.visible + } + + function nextStep() { + if (step == 0) { + Logic.init() + } + + Logic.nextStep() + } + + TutorialDescription { + id: tutDescription + innerWidth: maxWidth + anchors.bottomMargin: offsetBottom + onClicked: { + Logic.clickNext() + } + } + + TutorialCanceller { + id: tutorialCanceller + innerWidth: 200 + anchors.bottomMargin: offsetBottom - 30 + onClicked: { + Logic.cancelTutorial() + } + } + + Rectangle { + id: disabledBackground + anchors.fill: parent + opacity: 0.2 + color: "#808080" + } + + Connections { + target: manager + function onTabChanged(index) + { + tabChanged(index) + } + } + + Tooltip { + id: tooltip + } + + Highlight { + id: tutHighlight + } +} diff --git a/src/src/tutorials/tutorial_basics.js b/src/src/tutorials/tutorial_basics.js new file mode 100644 index 0000000..c8776b2 --- /dev/null +++ b/src/src/tutorials/tutorial_basics.js @@ -0,0 +1,2 @@ +function tutorial() { +} diff --git a/src/src/tutorials/tutorial_conflictresolution_main.js b/src/src/tutorials/tutorial_conflictresolution_main.js new file mode 100644 index 0000000..09a946b --- /dev/null +++ b/src/src/tutorials/tutorial_conflictresolution_main.js @@ -0,0 +1,198 @@ +//TL Conflict Resolution#10 +function getTutorialSteps() { + return [ + function() { + tutorial.text = qsTr("Welcome to the conflict resolution tutorial. This tutorial is very dense with " + + "information, so take it slow and feel free to revisit as needed - until you have " + + "a solid grasp of the types of conflicts and tools to manage them.") + waitForClick() + }, + function() { + tutorial.text = qsTr("Some info primarily applies to Bethesda Game Studios games. If this does not apply " + + "to you, you can exit the tutorial when you reach the section about \"record conflicts\".") + waitForClick() + }, + function() { + tutorial.text = qsTr("There are multiple types of conflicts you may encounter when dealing with mods. " + + "This tutorial will try to cover and explain how to deal with all of them.") + waitForClick() + }, + function() { + tutorial.text = qsTr("First up are file conflicts. These occur when two mods contain the same file. " + + "Most commonly this happens when several mods replace the same standard asset from " + + "the game, like the texture of an armor.") + waitForClick() + }, + function() { + tutorial.text = qsTr("As an example, say you install \"Mod A\" which contains stylish new iron and leather armor. " + + "Then you install \"Mod B\" which contains sexy ebony and leather armor. Obviously there is a " + + "conflict now: which leather armor to use?") + waitForClick() + }, + function() { + tutorial.text = qsTr("If you were to install the mods manually, when installing \"Mod B\" you would be asked if you want " + +"to overwrite conflicting files. If you choose yes, you get the leather armor from \"Mod B\" otherwise " + +"you keep the one from \"Mod A\". If you later decide you made the wrong choice, " + +"you have to reinstall one of the mods.") + waitForClick() + }, + function() { + tutorial.text = qsTr("With MO, both \"Mod A\" and \"Mod B\" are installed independently, without overwriting " + + "any files. Initially, \"Mod B\" gets to provide the leather armor because it's automatically " + + "assigned the higher priority.") + waitForClick() + }, + function() { + tutorial.text = qsTr("However, you can change the mod priority at any time by dragging and dropping mods in the list. " + + "If you assign \"Mod A\" a higher priority, it provides the leather armor, no re-installation required. " + + "Since the priorities of mods in this list are treated as if the mods were installed in that order, " + + "this can generally be referred to as \"installation order\".") + highlightItem("modList", false) + waitForClick() + }, + function() { + tutorial.text = qsTr("If the \"Flags\" column is enabled in the mod list, it will show you which mods are involved in a conflict.") + waitForClick() + }, + function() { + unhighlight() + tutorial.text = qsTr(" indicates that the mod overwrites files that are also available in another mod.") + waitForClick() + }, + function() { + tutorial.text = qsTr(" indicates that the mod is partially overwritten by another.") + waitForClick() + }, + function() { + tutorial.text = qsTr(" indicates that both of the these are true.") + waitForClick() + }, + function() { + tutorial.text = qsTr(" indicates that the mod is completely overwritten. It provides no active files and is essentially disabled."); + waitForClick() + }, + function() { + tutorial.text = qsTr("There are two ways to see the individual files involved in a conflict:") + waitForClick() + }, + function() { + tutorial.text = qsTr("Option A: Switch to the \"Data\" tab.") + if (!tutorialControl.waitForTabOpen("tabWidget", "dataTab")) { + highlightItem("tabWidget", false) + waitForClick() + } else { + highlightItem("tabWidget", true) + } + }, + function() { + tutorial.text = qsTr("In the \"Data\" tab, if you check the highlighted control, the tree will only show " + + "conflicted files. In the right column, it displays which mod is currently providing " + + "the file (due to having the highest priority), and if you hover your mouse over that " + + "info, it will list which other mods contain it.") + highlightItem("dataTabShowOnlyConflicts", false) + waitForClick() + }, + function() { + tutorial.text = qsTr("Option B: Open the Information dialog of an enabled mod by either " + + "double-clicking it or selecting Information... from the right-click menu.") + highlightItem("modList", true) + manager.activateTutorial("ModInfoDialog", "tutorial_conflictresolution_modinfo.js") + applicationWindow.modInfoDisplayed.connect(nextStep) + }, + function() { + unhighlight() + tutorial.text = qsTr("That is everything you need to know about file conflicts. The second type of conflict " + + "to deal with is \"record conflicts\".") + waitForClick() + }, + function() { + tutorial.text = qsTr("In the \"First Steps\" tutorial, you learned how plugins contain changes to the game " + + "world, like modifications to the terrain or existing NPCs. Each of these changes is " + + "stored in a record, hence the name \"record conflict\". For example, when two mods " + + "try to change the same location, only one change can become active.") + waitForClick() + }, + function() { + tutorial.text = qsTr("Similar to the mod list, you will have to choose which plugins will be loaded last " + + "and take priority over other records. This time around however, choosing an incorrect " + + "order can cause your game to become unstable, as there may be strict dependencies " + + "between plugin files and records.") + waitForClick() + }, + function() { + tutorial.text = qsTr("Please open the \"Plugins\" tab...") + highlightItem("tabWidget", true) + if (!tutorialControl.waitForTabOpen("tabWidget", "espTab")) { + nextStep() + } + }, + function() { + tutorial.text = qsTr("As with mods, you can drag and drop plugins to change their priority, thus deciding " + + "which plugins take precedence in regards to conflicts. This is commonly called the " + + "\"load order\". But how do you know how to order the plugins?") + waitForClick() + }, + function() { + unhighlight() + tutorial.text = qsTr("Unlike with file conflicts, MO can provide only minimal help, indicating whether " + + "required \"master\" plugins are present in the load order. The good news is, there " + + "is a perfect tool for that called LOOT.") + waitForClick() + }, + function() { + tutorial.text = qsTr("MO has a built in integration with LOOT which can be used via the \"Sort\" button for " + + "any supported game. This will attempt to use any configuration set up within the " + + "main LOOT application.") + highlightItem("sortButton", false) + waitForClick() + }, + function() { + unhighlight() + tutorial.text = qsTr("If LOOT has been installed, Mod Organizer should detect it for any supported game and " + + "automatically add it to the available tools.") + highlightItem("startGroup", false) + waitForClick() + }, + function() { + tutorial.text = qsTr("When you run LOOT, it will automatically re-organize plugins for best compatibility " + + "(overwriting your manual changes). It will also notate the plugin list with " + + "information about patches, incompatibilities, and other useful info. This is true " + + "in both the main LOOT application and for the integration within Mod Organizer.") + waitForClick() + }, + function() { + unhighlight() + tutorial.text = qsTr("The final type of conflicts are a subset of \"record conflicts\". We will call these " + + "\"lists conflicts\". As briefly mentioned earlier, these types of record conflicts " + + "can be merged so you may be able to get all modifications in your game.") + waitForClick() + }, + function() { + tutorial.text = qsTr("One common example of such records are leveled lists that contain all the items that " + + "may spawn at a specific character level. Traditionally, if multiple mods add items to " + + "such a list, only one of these mods will actually take effect. In some cases, there " + + "are community-made patches to resolve these issues.") + waitForClick() + }, + function() { + tutorial.text = qsTr("Fortunately, there are also tools to merge many types of records so that they can all " + + "take effect. For Oblivion, Skyrim, and Fallout 4, look for Wrye Bash. For Fallout 3 " + + "and New Vegas, you can use Wrye Flash. These can create a \"bashed patch,\" which is " + + "a plugin that combines many mergeable records from all of your mods. There are other, " + + "similar tools for more specific tasks such as 'Synergy'.") + waitForClick() + }, + function() { + tutorial.text = qsTr("Finally, advanced users may consider using 'xEdit', a robust tool for comparing, " + + "modifying, and cleaning plugin records. With this you can create your own custom " + + "patch files to merge any combination of plugins and records.") + waitForClick() + }, + function() { + tutorial.text = qsTr("This completes the tutorial. Hopefully you have a better grasp on the intricacies of " + + "conflict resolution. Good luck, and happy modding!") + waitForClick() + } + + ] +} diff --git a/src/src/tutorials/tutorial_conflictresolution_modinfo.js b/src/src/tutorials/tutorial_conflictresolution_modinfo.js new file mode 100644 index 0000000..98ca660 --- /dev/null +++ b/src/src/tutorials/tutorial_conflictresolution_modinfo.js @@ -0,0 +1,23 @@ +function getTutorialSteps() { + tutorialCanceller.visible = false + return [ + function() { + tutorial.text = qsTr("Please switch to the \"Conflicts\"-Tab.") + highlightItem("tabWidget", true) + if (!tutorialControl.waitForTabOpen("tabWidget", "tabConflicts")) { + nextStep() + } + }, + function() { + tutorial.text = qsTr("Here you can see two lists: a list of files that this mod overwrites that are also " + + "provided by other mods, and a list of files in this mod which are overwritten by " + + "one or more other mods.") + waitForClick() + }, + function() { + tutorial.text = qsTr("Please close the information dialog.") + waitForClick() + } + + ] +} diff --git a/src/src/tutorials/tutorial_firststeps.js b/src/src/tutorials/tutorial_firststeps.js new file mode 100644 index 0000000..bf3cad3 --- /dev/null +++ b/src/src/tutorials/tutorial_firststeps.js @@ -0,0 +1,5 @@ +// First Steps +subTutorials = [ + ["MainWindow", "tutorial_firststeps_main.js"], + ["SettingsDialog", "tutorial_firststeps_settings.js"] + ]; diff --git a/src/src/tutorials/tutorial_firststeps_browser.js b/src/src/tutorials/tutorial_firststeps_browser.js new file mode 100644 index 0000000..062cc18 --- /dev/null +++ b/src/src/tutorials/tutorial_firststeps_browser.js @@ -0,0 +1,12 @@ +function getTutorialSteps() +{ + return [ + function() { + tutorial.text = qsTr("This is a fully featured browser that is set up to " + +"open the correct nexus page for your game. You can " + +"download any mod using the \"DOWNLOAD WITH MANAGER\"-button " + +"or the \"manual\"-link and it will be downloaded by MO.") + waitForClick() + } + ] +} diff --git a/src/src/tutorials/tutorial_firststeps_main.js b/src/src/tutorials/tutorial_firststeps_main.js new file mode 100644 index 0000000..0837de4 --- /dev/null +++ b/src/src/tutorials/tutorial_firststeps_main.js @@ -0,0 +1,184 @@ +//TL First Steps#0 +function getTutorialSteps() +{ + return [ + function() { + tutorial.text = qsTr("Welcome to the Mod Organizer Tutorial! This will guide you through the most common " + + "features of MO2.\n\n" + + "It is highly recommended for first-time users to complete the tutorial from beginning " + + "to end to properly demonstrate key components of the tool.") + waitForClick() + }, + + function() { + tutorial.text = qsTr("Before we continue with the step-by-step tutorial, here are a few ways you can receive " + + "help with Mod Organizer.") + waitForClick() + }, + + function() { + tutorial.text = qsTr("The highlighted button will display potential problems detected with your setup and may " + + "suggest solutions. (Click it and then close the window to proceed.)") + if (tutorialControl.waitForAction("actionNotifications")) { + tutorial.text += qsTr("\n\nIt appears you have one now, however you can hold off on clearing it until after " + + "completing the tutorial.") + highlightAction("actionNotifications", true) + } else { + highlightAction("actionNotifications", false) + waitForClick() + } + }, + + function() { + console.log("next") + tutorial.text = qsTr("This button contains additional information about the application, links to other sources " + + "of help, and further tutorials. (Open the menu to proceed.)") + if (tutorialControl.waitForAction("actionHelp")) { + highlightAction("actionHelp", true) + } else { + console.error("help button broken") + waitForClick() + } + }, + + function() { + unhighlight() + tutorial.text = qsTr("Finally, there are tooltips and extra information available all across Mod Organizer. If " + + "there is a control you don't understand, please try hovering over it for a short " + + "description. Alternatively, you can use \"Help on UI\" from the Help menu to click on " + + "some controls and get a comprehensive explanation.") + waitForClick() + }, + + function() { + tutorial.text = qsTr("This list displays all mods installed through MO2. It also displays installed DLCs and " + + "any 'unmanaged' mods installed outside MO2. You have limited control over those.") + highlightItem("modList", false) + waitForClick() + }, + + function() { + tutorial.text = qsTr("Before we start installing mods, let's have a quick look at the settings. (Open the " + + "settings dialog to proceed via the highlighted button.)") + manager.activateTutorial("SettingsDialog", "tutorial_firststeps_settings.js") + if (tutorialControl.waitForAction("actionSettings")) { + highlightAction("actionSettings", true) + } else { + console.error("settings action broken") + waitForClick() + } + }, + + function() { + unhighlight() + tutorial.text = qsTr("Now it's time to install a mod!\n\n" + + "(This will be a requirement in order to demonstrate other features later.)") + waitForClick() + }, + + function() { + tutorial.text = qsTr("There are a few ways to get mods into Mod Organizer. " + + "If you associated MO with NXM links in the settings you can now use your regular browser to send downloads from Nexus to MO. " + + "Click on \"Nexus\" to open nexus, find a mod and click the green download buttons on Nexus saying \"Download with Manager\".") + if (tutorialControl.waitForAction("actionNexus") && + tutorialControl.waitForAction("actionModPage")) { + highlightAction("actionNexus", true) + highlightAction("actionModPage", true) + } else { + console.error("browser action broken") + waitForClick() + } + }, + + function() { + tutorial.text = qsTr("You can also install mods from disk using the \"Install Mod\" button.") + highlightAction("actionInstallMod", false) + waitForClick() + }, + + function() { + tutorial.text = qsTr("Downloads will appear on the \"Downloads\" tab here. You have to download and install at " + + "least one mod to proceed.") + organizer.modInstalled.connect(nextStep) + highlightItem("tabWidget", true) + }, + + function() { + unhighlight() + organizer.modInstalled.disconnect(nextStep) + tutorial.text = qsTr("Great, you just installed your first mod. Please note that the installation procedure " + + "may differ based on how a mod was packaged.") + waitForClick() + }, + + function() { + unhighlight() + tutorial.text = qsTr("Now you know all about downloading and installing mods, but they are not enabled yet...") + waitForClick() + }, + + function() { + tutorial.text = qsTr("Install a few more mods if you want, then enable them by checking them in the left pane. " + + "Mods that aren't enabled have no effect on the game whatsoever. ") + highlightItem("modList", true) + modList.tutorialModlistUpdate.connect(nextStep) + }, + + function() { + modList.tutorialModlistUpdate.disconnect(nextStep) + unhighlight() + tutorial.text = qsTr("For some mods, enabling it on the left pane is all you have to do...") + waitForClick() + }, + + function() { + tutorial.text = qsTr("...but for some games they may contain plugins. These are files the game must load and " + + "are required to change or add aspects of the game (new weapons, armors, quests, areas, ...).\n\n" + + "Please open the \"Plugins\" tab to get a list of plugins.") + if (tutorialControl.waitForTabOpen("tabWidget", "espTab")) { + highlightItem("tabWidget", true) + } else { + waitForClick() + } + }, + + function() { + tutorial.text = qsTr("You may notice some plugins are grayed out. These are part of the main game and can't be " + +"disabled.") + waitForClick() + }, + + function() { + tutorial.text = qsTr("A single mod may contain zero, one, or multiple plugin files. Some or all may be optional. " + + "If in doubt, please consult the documentation of the individual mod. " + + "To do so, right-click the mod and select \"Information\".") + highlightItem("modList", true) + manager.activateTutorial("ModInfoDialog", "tutorial_firststeps_modinfo.js") + applicationWindow.modInfoDisplayed.connect(nextStep) + }, + + function() { + tutorial.text = qsTr("Now you know how to download, install, and enable mods.\n\n" + + "It's important you always start the game from inside MO, otherwise " + + "the mods you installed here won't work.") + highlightItem("startGroup", false) + waitForClick() + }, + + function() { + tutorial.text = qsTr("This combobox lets you choose what to start. This is how you will launch the game " + + "or any other tool which must access the game's mod directories. If a tool is not " + + "listed here, you can also configure these options, but that is an advanced topic.") + highlightItem("executablesListBox", false) + waitForClick() + }, + + function() { + tutorial.text = qsTr("This completes the basic tutorial. Feel free to play the game and try out your new mods! " + + "Once you have installed a larger number, you may want to continue with the tutorial " + + "on conflict resolution.") + highlightItem("startButton", false) + waitForClick() + } + ] +} diff --git a/src/src/tutorials/tutorial_firststeps_modinfo.js b/src/src/tutorials/tutorial_firststeps_modinfo.js new file mode 100644 index 0000000..5bb1163 --- /dev/null +++ b/src/src/tutorials/tutorial_firststeps_modinfo.js @@ -0,0 +1,24 @@ +function getTutorialSteps() +{ + tutorialCanceller.visible = false + return [ + function() { + tutorial.text = qsTr("This dialog tries to expose as much information about a mod as possible. " + +"Depending on the mod, this may include readmes, screenshots, optional plugins and so on. " + +"If a certain type of information was not found in a mod, the corresponding tab " + +"is grayed out.") + highlightItem("tabWidget", false) + waitForClick() + }, + function() { + tutorial.text = qsTr("If you installed the mod from Nexus, the corresponding tab should give you direct " + +"access to the mod page description, which can be refreshed directly from Nexus.") + waitForClick() + }, + function() { + unhighlight() + tutorial.text = qsTr("We may revisit this screen in later tutorials.") + waitForClick() + } + ] +} diff --git a/src/src/tutorials/tutorial_firststeps_settings.js b/src/src/tutorials/tutorial_firststeps_settings.js new file mode 100644 index 0000000..94ca26a --- /dev/null +++ b/src/src/tutorials/tutorial_firststeps_settings.js @@ -0,0 +1,29 @@ +function getTutorialSteps() +{ + tutorialCanceller.visible = false + return [ + function() { + highlightItem("tabWidget", true) + tutorial.text = qsTr("It is possible to download files directly from Nexus.\n\n" + + "Please open the \"Nexus\" tab.") + tutorialControl.waitForTabOpen("tabWidget", "nexusTab") + }, + + function() { + highlightItem("associateButton", false) + tutorial.text = qsTr("Clicking on this button should register Nexus \"Download with Manager\" buttons " + +"to download with Mod Organizer.") + waitForClick() + }, + + function() { + highlightItem("nexusBox", false) + tutorial.text = qsTr("Use this interface to obtain an API key from NexusMods. " + +"This is used for all API connections - downloads, updates " + +"etc. MO2 uses the Windows Credential Manager to store " + +"this data securely. If the SSO page on Nexus is failing, " + +"use the manual entry and copy the API key from your profile.") + waitForClick() + } + ] +} diff --git a/src/src/tutorials/tutorial_primer_main.js b/src/src/tutorials/tutorial_primer_main.js new file mode 100644 index 0000000..459cd2b --- /dev/null +++ b/src/src/tutorials/tutorial_primer_main.js @@ -0,0 +1,141 @@ +//TL Overview#5 + + +var tooltips = [] + +function tooltipWidget(widgetName, explanation, maxheight, clickable) { + var component = Qt.createComponent("TooltipArea.qml") + if (component.status === Component.Ready) + finishCreation(component, widgetName, explanation, maxheight, clickable); + else + component.statusChanged.connect(function() { + finishCreation(component, widgetName, explanation, maxheight, clickable); + }); +} + +function finishCreation(component, widgetName, explanation, maxheight, clickable) { + if (component.status === Component.Ready) { + var rect = tutorialControl.getRect(widgetName) + if (typeof clickable === 'undefined') { + clickable = false + } + if ((typeof maxheight === 'undefined') || maxheight === 0) { + maxheight = rect.height + } + + var obj = component.createObject(tutToplevel, + { + "x": rect.x, + "y": rect.y, + "width": rect.width, + "height": maxheight + }) + obj.tooltipText = explanation + obj.clickable = clickable + obj.visible = true + + tooltips.push(obj) + } else if (component.status === Component.Error) { + console.log("Error loading component: " + component.errorString()) + } +} + +function tooltipAction(actionName, explanation, maxheight, clickable) { + var rect = tutorialControl.getActionRect(actionName) + var offsetRect = tutorialControl.getMenuRect(actionName) + var component = Qt.createComponent("TooltipArea.qml") + if (typeof clickable === 'undefined') { + clickable = false + } + if ((typeof maxheight === 'undefined') || maxheight === 0) { + maxheight = rect.height + } + var obj = component.createObject(tutToplevel, + { "x" : rect.x, + "y" : rect.y + offsetRect.height, + "width" : rect.width, + "height" : maxheight + }) + obj.tooltipText = explanation + obj.clickable = clickable + obj.visible = true + + tooltips.push(obj) +} + +function setupTooptips() { + for (var tip in tooltips) { + tooltips[tip].destroy() + } + tooltips = [] + + tooltipWidget("modList", qsTr("This window shows all the mods that are installed. The column headers can be used for sorting. Only checked mods are active in the current profile.")) + tooltipWidget("profileBox", qsTr("Each profile is a separate set of enabled mods and ini settings.")) + tooltipWidget("listOptionsBtn", qsTr("Perform various actions on your mod list, such as refreshing data and checking for mod updates.")) + tooltipWidget("openFolderMenu", qsTr("Quick access to various directories, such as your MO2 mods, profiles, saves, and your active game location.")) + tooltipWidget("restoreModsButton", qsTr("Restore a mod list backup.")) + tooltipWidget("saveModsButton", qsTr("Create a backup of your current mod list.")) + tooltipWidget("activeModsCounter", qsTr("Running counter of your active mods. Hover to see a more detailed breakdown.")) + tooltipWidget("groupCombo", qsTr("The dropdown allows various ways of grouping the mods shown in the mod list.")) + tooltipWidget("displayCategoriesBtn", qsTr("Show/hide the category pane.")) + tooltipWidget("modFilterEdit", qsTr("Quickly filter the mod list as you type.")) + tooltipWidget("qt_tabwidget_tabbar", qsTr("Switch between information views."), 0, true) + tooltipWidget("categoriesGroup", qsTr("This shows mod categories and some meta categories (in angle-brackets). Select some to filter the mod list. For example select \"\" to show only active mods.")) + tooltipWidget("executablesListBox", qsTr("Customizable list for choosing the program to run.")) + tooltipWidget("startButton", qsTr("When this button is clicked, Mod Organizer creates a virtual directory structure then runs the program selected to the left.")) + tooltipWidget("linkButton", qsTr("Will create a shortcut for quick access. The shortcut can be placed in the toolbar at the top, in the Start Menu or on the Windows Desktop.")) + tooltipWidget("logList", qsTr("Log messages produced by MO. Please note that messages with a light bulb usually don't require your attention.")) + tooltipWidget("apistats", qsTr("Indicator of your current NexusMods API request limits.")) + + tooltipAction("actionChange_Game", qsTr("Change/manage MO2 instances or switch to portable mode.")) + tooltipAction("actionInstallMod", qsTr("Browse to and manually install a mod from an archive on your computer.")) + tooltipAction("actionNexus", qsTr("Automatically open NexusMods to browse and install mods via the API.")) + tooltipAction("actionModPage", qsTr("Automatically open NexusMods to browse and install mods via the API.")) + tooltipAction("actionAdd_Profile", qsTr("Manage your MO2 profiles.")) + tooltipAction("action_Refresh", qsTr("Refresh everything.")) + tooltipAction("actionModify_Executables", qsTr("Open the executable editor to add and modify applications you wish to run with MO2.")) + tooltipAction("actionTool", qsTr("Select from a collection of additional tools, such as an INI editor, integrated FNIS updater, and more.")) + tooltipAction("actionSettings", qsTr("Configure Mod Organizer.")) + tooltipAction("actionEndorseMO", qsTr("See the status of and/or endorse MO2 on NexusMods.")) + tooltipAction("actionNotifications", qsTr("Notifications about the current setup.")) + tooltipAction("actionUpdate", qsTr("Activates if there is an update for MO. Please note that if, for any reason, MO can't communicate with NMM, this will not work either.")) + tooltipAction("actionHelp", qsTr("Access more information about MO2, including these tutorials, a link to the development discord, information about the devs and dependencies.")) + + switch (tutorialControl.getTabName("tabWidget")) { + case "espTab": + tooltipWidget("espList", qsTr("Plugins (esp/esm/esl files) of the mods in the current profile. They need to be checked to be loaded.")) + tooltipWidget("sortButton", qsTr("Automatically sort plugins using the bundled LOOT application.")) + tooltipWidget("restoreButton", qsTr("Restore a backup of your plugin list order.")) + tooltipWidget("saveButton", qsTr("Save a backup of your plugin list order.")) + tooltipWidget("activePluginsCounter", qsTr("Counter of your total active plugins. Hover to see a breakdown of plugin types.")) + tooltipWidget("espFilterEdit", qsTr("Quickly filter plugin list as you type.")) + break + case "bsaTab": + tooltipWidget("bsaList", qsTr("All the asset archives (.bsa files) for all active mods.")) + break + case "dataTab": + tooltipWidget("dataTree", qsTr("The directory tree and all files that the program will see.")) + break + case "savesTab": + tooltipWidget("savegameList", qsTr("Save game browser. Shows all the saves for the current profile and whether or not the current mod-load status is correct.")) + break + case "downloadTab": + tooltipWidget("downloadView", qsTr("Shows the mods that have been downloaded and if they’ve been installed.")) + break + } +} + +function getTutorialSteps() { + tutorialCanceller.visible = false + return [ + function() { + tutorial.text = qsTr("Click to quit") + + setupTooptips() + + onTabChanged(setupTooptips) + + waitForClick() + } + ] +} diff --git a/src/src/tutorials/tutorial_window_installer.js b/src/src/tutorials/tutorial_window_installer.js new file mode 100644 index 0000000..a277923 --- /dev/null +++ b/src/src/tutorials/tutorial_window_installer.js @@ -0,0 +1,37 @@ +//WIN InstallDialog +function getTutorialSteps() +{ + return [ + function() { + tutorial.text = qsTr("This mod has been packaged in a way that Mod Organizer did not automatically " + + "recognize...") + waitForClick() + }, + function() { + tutorial.text = qsTr("You can use drag&drop on this list to fix the structure of the mod.") + highlightItem("treeContent", false) + waitForClick() + }, + function() { + tutorial.text = qsTr("The correct structure replicates the data-directory of the game. That means " + + "esps, the \"meshes\"- or \"textures\"-directory and so on should be directly " + + "below \"\".") + waitForClick() + }, + function() { + tutorial.text = qsTr("You can also disable files and directories that you don't want to unpack.") + waitForClick() + }, + function() { + tutorial.text = qsTr("From the context menu (right-click) you can open textfiles, in case " + + "you want to access a readme.") + waitForClick() + }, + function() { + tutorial.text = qsTr("This text will turn green if MO thinks the structure looks good.") + highlightItem("problemLabel", false) + manager.finishWindowTutorial("InstallDialog") + waitForClick() + } + ] +} diff --git a/src/src/tutorials/tutorials.js b/src/src/tutorials/tutorials.js new file mode 100644 index 0000000..a034842 --- /dev/null +++ b/src/src/tutorials/tutorials.js @@ -0,0 +1,83 @@ +var tutorialSteps = [] +var waitingForClick = false + + +function highlightItem(widgetName, click) { + var rect = tutorialControl.getRect(widgetName) + highlight.x = rect.x - 1 + highlight.y = rect.y - 1 + highlight.width = rect.width + 2 + highlight.height = rect.height + 2 + if (click) { + highlight.border.color = "green" + } else { + highlight.border.color = "blue" + } + highlight.visible = true +} + +function highlightAction(actionName, click) { + var rect = tutorialControl.getActionRect(actionName) + var offsetRect = tutorialControl.getMenuRect(actionName) + highlight.x = rect.x - 1 + highlight.y = rect.y + offsetRect.height + highlight.width = rect.width + 2 + highlight.height = rect.height + 2 + if (click) { + highlight.border.color = "green" + } else { + highlight.border.color = "blue" + } + highlight.visible = true +} + +function unhighlight() { + highlight.visible = false +} + +function waitForClick() { + waitingForClick = true; + description.continueVisible = true + // ui needs to be locked, otherwise the tutorial-view does not receive mouse-events! + tutorialControl.lockUI(true) +} + +function cancelTutorial() { + tutorialControl.finish() +} + +function clickNext() { + if (waitingForClick) { + nextStep() + } +} + +function nextStep() { + waitingForClick = false; + description.continueVisible = false + if (step < tutorialSteps.length) { + tutorialControl.lockUI(false) + step++ + tutorialSteps[step - 1]() + } else { + tutorialControl.finish() + } +} + +function sameStep() { + tutorialSteps[step - 1]() +} + +function onTabChanged(func) { + tutToplevel.tabChanged.connect(func) +} + +function init() { + var res = Qt.include("file:///" + scriptName) + if (res.status !== 0) { + console.log("failed to load " + scriptName + ": " + res.status) + return + } + + tutorialSteps = getTutorialSteps() +} diff --git a/src/src/tutorials/tutorials_installdialog.qml b/src/src/tutorials/tutorials_installdialog.qml new file mode 100644 index 0000000..9cc2109 --- /dev/null +++ b/src/src/tutorials/tutorials_installdialog.qml @@ -0,0 +1,7 @@ +import QtQuick 2.7 + +TutorialOverlay { + id: tutorial + offsetBottom: 65 + maxWidth: 420 +} diff --git a/src/src/tutorials/tutorials_mainwindow.qml b/src/src/tutorials/tutorials_mainwindow.qml new file mode 100644 index 0000000..0d660a2 --- /dev/null +++ b/src/src/tutorials/tutorials_mainwindow.qml @@ -0,0 +1,7 @@ +import QtQuick 2.7 + +TutorialOverlay { + id: tutorial + offsetBottom: 50 + maxWidth: 400 +} diff --git a/src/src/tutorials/tutorials_modinfodialog.qml b/src/src/tutorials/tutorials_modinfodialog.qml new file mode 100644 index 0000000..74f6adf --- /dev/null +++ b/src/src/tutorials/tutorials_modinfodialog.qml @@ -0,0 +1,7 @@ +import QtQuick 2.7 + +TutorialOverlay { + id: tutorial + offsetBottom: 60 + maxWidth: 420 +} diff --git a/src/src/tutorials/tutorials_nexusdialog.qml b/src/src/tutorials/tutorials_nexusdialog.qml new file mode 100644 index 0000000..9fb9aaf --- /dev/null +++ b/src/src/tutorials/tutorials_nexusdialog.qml @@ -0,0 +1,8 @@ +import QtQuick 2.7 + +TutorialOverlay { + id: tutorial + offsetBottom: 40 + maxWidth: 600 + boxOpacity: 0.95 +} diff --git a/src/src/tutorials/tutorials_settingsdialog.qml b/src/src/tutorials/tutorials_settingsdialog.qml new file mode 100644 index 0000000..74f6adf --- /dev/null +++ b/src/src/tutorials/tutorials_settingsdialog.qml @@ -0,0 +1,7 @@ +import QtQuick 2.7 + +TutorialOverlay { + id: tutorial + offsetBottom: 60 + maxWidth: 420 +} diff --git a/src/src/uilocker.cpp b/src/src/uilocker.cpp new file mode 100644 index 0000000..e6189f6 --- /dev/null +++ b/src/src/uilocker.cpp @@ -0,0 +1,636 @@ +#include "uilocker.h" +#include "mainwindow.h" +#include +#include +#include + +class UILockerInterface +{ +public: + UILockerInterface(QWidget* mainUI) + : m_mainUI(mainUI), m_target(nullptr), m_message(nullptr), m_info(nullptr), + m_buttons(nullptr), m_reason(UILocker::NoReason) + { + m_timer.reset(new QTimer); + QObject::connect(m_timer.get(), &QTimer::timeout, [&] { + checkTarget(); + }); + m_timer->start(200); + + set(); + } + + ~UILockerInterface() + { + if (m_topLevel) { + delete m_topLevel.data(); + } + } + + void checkTarget() + { + if (set()) { + update(m_reason); + } + } + + bool set() + { + QWidget* newTarget = findTarget(); + if (m_topLevel && newTarget == m_target) { + return false; + } + + m_target = newTarget; + + QFrame* center = nullptr; + + if (m_target) { + center = createOverlay(m_target); + } else { + center = createDialog(); + } + + createMessageLabel(); + createInfoLabel(); + createButtonsPanel(); + + center->layout()->addWidget(m_message); + center->layout()->addWidget(m_info); + center->layout()->addWidget(m_buttons); + + m_topLevel->setFocusPolicy(Qt::TabFocus); + m_topLevel->setFocus(); + m_topLevel->show(); + m_topLevel->setEnabled(true); + + m_topLevel->raise(); + m_topLevel->activateWindow(); + + return true; + } + + void update(UILocker::Reasons reason) + { + m_reason = reason; + updateMessage(reason); + updateButtons(reason); + setInfo(m_labels); + } + + void setInfo(const QStringList& labels) + { + const int MaxLabels = 2; + + m_labels = labels; + + QString s; + + if (labels.size() > MaxLabels) { + s = labels.mid(0, MaxLabels).join(", ") + "..."; + } else { + s = labels.join(", "); + } + + m_info->setText(s); + } + + QWidget* topLevel() { return m_topLevel.data(); } + +private: + class Filter : public QObject + { + public: + std::function resized; + std::function closed; + + protected: + bool eventFilter(QObject* o, QEvent* e) override + { + if (e->type() == QEvent::Resize) { + if (resized) { + resized(); + } + } else if (e->type() == QEvent::Close) { + if (closed) { + closed(); + } + } + + return QObject::eventFilter(o, e); + } + }; + + std::unique_ptr m_timer; + QWidget* m_mainUI; + QWidget* m_target; + QPointer m_topLevel; + QLabel* m_message; + QLabel* m_info; + QStringList m_labels; + QWidget* m_buttons; + std::unique_ptr m_filter; + UILocker::Reasons m_reason; + + bool hasMainUI() const { return (m_target != nullptr); } + + QWidget* findTarget() + { + auto isValidTarget = [](QWidget* w) { + // skip message boxes + if (dynamic_cast(w)) { + return false; + } + + // skip invisible widgets + if (!w->isVisible()) { + return false; + } + + // skip windows that are too small + if (w->height() < 150) { + return false; + } + + return true; + }; + + // find a modal dialog + QWidget* w = QApplication::activeModalWidget(); + + while (w && w != m_mainUI) { + if (isValidTarget(w)) { + return w; + } + + w = w->parentWidget(); + } + + // find a non-modal dialog that's a child of the main window + if (m_mainUI) { + const auto topLevels = QApplication::topLevelWidgets(); + + for (auto* w : topLevels) { + if (w && w->parentWidget() == m_mainUI) { + if (isValidTarget(w)) { + return w; + } + } + } + } + + return m_mainUI; + } + + QWidget* createTransparentWidget(QWidget* parent = nullptr) + { + auto* w = new QWidget(parent); + + w->setWindowOpacity(0); + w->setAttribute(Qt::WA_NoSystemBackground); + w->setAttribute(Qt::WA_TranslucentBackground); + + return w; + } + + QFrame* createOverlay(QWidget* mainUI) + { + if (m_topLevel) { + delete m_topLevel.data(); + m_topLevel.clear(); + } + + m_topLevel = createTransparentWidget(mainUI); + m_topLevel->setWindowFlags(m_topLevel->windowFlags() & Qt::FramelessWindowHint); + m_topLevel->setGeometry(mainUI->rect()); + + m_filter.reset(new Filter); + m_filter->resized = [=] { + m_topLevel->setGeometry(mainUI->rect()); + }; + m_filter->closed = [=] { + checkTarget(); + }; + + mainUI->installEventFilter(m_filter.get()); + + return createFrame(); + } + + QFrame* createDialog() + { + if (m_topLevel) { + delete m_topLevel.data(); + m_topLevel.clear(); + } + + m_topLevel = new QDialog; + + return createFrame(); + } + + QFrame* createFrame() + { + auto* frame = new QFrame; + auto* ly = new QVBoxLayout(frame); + + if (hasMainUI()) { + frame->setFrameStyle(QFrame::StyledPanel); + frame->setLineWidth(1); + frame->setAutoFillBackground(true); + + auto* shadow = new QGraphicsDropShadowEffect; + shadow->setBlurRadius(50); + shadow->setOffset(0); + shadow->setColor(QColor(0, 0, 0, 100)); + frame->setGraphicsEffect(shadow); + } else { + ly->setContentsMargins(0, 0, 0, 0); + } + + auto* grid = new QGridLayout(m_topLevel.data()); + grid->addWidget(createTransparentWidget(), 0, 1); + grid->addWidget(createTransparentWidget(), 2, 1); + grid->addWidget(createTransparentWidget(), 1, 0); + grid->addWidget(createTransparentWidget(), 1, 2); + grid->addWidget(frame, 1, 1); + + if (!hasMainUI()) { + grid->setContentsMargins(0, 0, 0, 0); + } + + grid->setRowStretch(0, 1); + grid->setRowStretch(2, 1); + grid->setColumnStretch(0, 1); + grid->setColumnStretch(2, 1); + + return frame; + } + + void createMessageLabel() + { + m_message = new QLabel; + m_message->setAlignment(Qt::AlignCenter | Qt::AlignHCenter); + } + + void createInfoLabel() + { + m_info = new QLabel(" "); + m_info->setAlignment(Qt::AlignCenter | Qt::AlignHCenter); + } + + void createButtonsPanel() + { + m_buttons = new QWidget; + m_buttons->setLayout(new QHBoxLayout); + } + + void updateMessage(UILocker::Reasons reason) + { + switch (reason) { + case UILocker::LockUI: { + QString s; + + if (hasMainUI()) { + s = QObject::tr("Mod Organizer is locked while the application is running."); + } else { + s = QObject::tr("Mod Organizer is currently running an application."); + } + + m_message->setText(s); + + break; + } + + case UILocker::OutputRequired: { + m_message->setText( + QObject::tr("The application must run to completion because its output is " + "required.")); + + break; + } + + case UILocker::PreventExit: { + m_message->setText(QObject::tr( + "Mod Organizer is waiting on an application to close before exiting.")); + + break; + } + } + } + + void updateButtons(UILocker::Reasons reason) + { + MOBase::deleteChildWidgets(m_buttons); + auto* ly = m_buttons->layout(); + + switch (reason) { + case UILocker::LockUI: // fall-through + case UILocker::OutputRequired: { + auto* unlock = new QPushButton(QObject::tr("Unlock")); + + QObject::connect(unlock, &QPushButton::clicked, [&] { + UILocker::instance().onForceUnlock(); + }); + + ly->addWidget(unlock); + + break; + } + + case UILocker::PreventExit: { + auto* exit = new QPushButton(QObject::tr("Exit Now")); + QObject::connect(exit, &QPushButton::clicked, [&] { + UILocker::instance().onForceUnlock(); + }); + + ly->addWidget(exit); + + auto* cancel = new QPushButton(QObject::tr("Cancel")); + QObject::connect(cancel, &QPushButton::clicked, [&] { + UILocker::instance().onCancel(); + }); + + ly->addWidget(cancel); + + break; + } + } + } +}; + +UILocker::Session::~Session() +{ + unlock(); +} + +void UILocker::Session::unlock() +{ + QMetaObject::invokeMethod(qApp, [this] { + UILocker::instance().unlock(this); + }); +} + +void UILocker::Session::setInfo(DWORD pid, const QString& name) +{ + { + std::scoped_lock lock(m_mutex); + m_pid = pid; + m_name = name; + } + + QMetaObject::invokeMethod(qApp, [this] { + UILocker::instance().updateLabel(); + }); +} + +DWORD UILocker::Session::pid() const +{ + std::scoped_lock lock(m_mutex); + return m_pid; +} + +const QString& UILocker::Session::name() const +{ + std::scoped_lock lock(m_mutex); + return m_name; +} + +UILocker::Results UILocker::Session::result() const +{ + return UILocker::instance().result(); +} + +static UILocker* g_instance = nullptr; + +UILocker::UILocker() : m_parent(nullptr), m_result(NoResult) +{ + Q_ASSERT(!g_instance); + g_instance = this; +} + +UILocker::~UILocker() +{ + const auto v = m_sessions; + + for (auto& wp : v) { + if (auto s = wp.lock()) { + unlock(s.get()); + } + } + + g_instance = nullptr; +} + +UILocker& UILocker::instance() +{ + Q_ASSERT(g_instance); + return *g_instance; +} + +void UILocker::setUserInterface(QWidget* parent) +{ + m_parent = parent; +} + +std::shared_ptr UILocker::lock(Reasons reason) +{ + m_result = StillLocked; + createUi(reason); + + auto ls = std::make_shared(); + m_sessions.push_back(ls); + + updateLabel(); + + return ls; +} + +bool UILocker::locked() const +{ + return !m_sessions.empty(); +} + +void UILocker::unlock(Session* s) +{ + auto itor = m_sessions.begin(); + for (;;) { + if (itor == m_sessions.end()) { + break; + } + + if (auto ss = itor->lock()) { + if (ss.get() == s) { + itor = m_sessions.erase(itor); + continue; + } + } else { + itor = m_sessions.erase(itor); + continue; + } + + ++itor; + } + + if (m_sessions.empty()) { + m_ui.reset(); + enableAll(); + } else { + updateLabel(); + } +} + +void UILocker::unlockCurrent() +{ + if (m_sessions.empty()) { + return; + } + + auto s = m_sessions.back().lock(); + if (!s) { + m_sessions.pop_back(); + return; + } + + unlock(s.get()); +} + +void UILocker::updateLabel() +{ + if (!m_ui) { + // this can happen if the lock overlay was destroyed while a cross-thread + // call for updateLabel() was in flight + return; + } + + QStringList labels; + + for (auto itor = m_sessions.rbegin(); itor != m_sessions.rend(); ++itor) { + if (auto ss = itor->lock()) { + labels.push_back(QString("%1 (%2)").arg(ss->name()).arg(ss->pid())); + } + } + + m_ui->setInfo(labels); +} + +UILocker::Results UILocker::result() const +{ + return m_result; +} + +void UILocker::createUi(Reasons reason) +{ + if (!m_ui) { + m_ui.reset(new UILockerInterface(m_parent)); + } + + m_ui->update(reason); + + disableAll(); +} + +void UILocker::onForceUnlock() +{ + m_result = ForceUnlocked; + unlockCurrent(); +} + +void UILocker::onCancel() +{ + m_result = Cancelled; + unlockCurrent(); +} + +template +QList findChildrenImmediate(QWidget* parent) +{ + return parent->findChildren(QString(), Qt::FindDirectChildrenOnly); +} + +void UILocker::disableAll() +{ + // the goal is to disable the main window and any dialog that's opened, + // without disabling the overlay itself + // + // the overlay might be a regular widget overlayed on top of a window, or an + // actual dialog if a shortcut is launched when MO isn't running + + // top level widgets include the main window and dialogs + for (auto* w : QApplication::topLevelWidgets()) { + if (auto* mw = dynamic_cast(w)) { + // this is the main window, disable the central widgets and the stuff + // around it + + disable(mw->centralWidget()); + disable(mw->menuBar()); + disable(mw->statusBar()); + + // every toolbar + for (auto* tb : findChildrenImmediate(w)) { + disable(tb); + } + + // every docked widget + for (auto* d : findChildrenImmediate(w)) { + disable(d); + } + } + + if (auto* d = dynamic_cast(w)) { + // this is a dialog + + if (d == m_ui->topLevel()) { + // but it's the overlay itself, skip it; this happens if a shortcut is + // launched but MO itself isn't running, in which case the lock ui is + // a dialog, not an overlay + continue; + } + + // disable all the dialog's immediate children, except for the overlay + // itself or other dialogs + for (auto* child : findChildrenImmediate(d)) { + if (child == m_ui->topLevel()) { + // this is the overlay itself, skip it + continue; + } + + if (dynamic_cast(child)) { + // this is a child dialog, skip it + // + // this typically happens when there's a second level modal dialog, + // like the create instance dialog that's opened from the instance + // manager + // + // the lock overlay is probably a child of that dialog, so disabling + // the dialog would also disable the buttons on the lock overlay + // + // since this is a dialog, it will be part of `topLevel` from the main + // loop and will be handled correctly later + continue; + } + + disable(child); + } + } + } +} + +void UILocker::enableAll() +{ + for (auto w : m_disabled) { + if (w) { + w->setEnabled(true); + } + } + + m_disabled.clear(); +} + +void UILocker::disable(QWidget* w) +{ + if (w->isEnabled()) { + w->setEnabled(false); + m_disabled.push_back(w); + } +} diff --git a/src/src/uilocker.h b/src/src/uilocker.h new file mode 100644 index 0000000..04f0e8d --- /dev/null +++ b/src/src/uilocker.h @@ -0,0 +1,97 @@ +#ifndef MODORGANIZER_UILOCKER_INCLUDED +#define MODORGANIZER_UILOCKER_INCLUDED + +#include +#include + +class UILockerInterface; + +class UILocker +{ + friend class UILockerInterface; + +public: + // reason to show the widget + // + enum Reasons + { + NoReason = 0, + + // lock the ui + LockUI, + + // because the output is required + OutputRequired, + + // to prevent exiting until all processes are completed + PreventExit + }; + + // returned by result() + // + enum Results + { + NoResult = 0, + + // the widget is still up + StillLocked, + + // force unlock was clicked + ForceUnlocked, + + // cancel was clicked + Cancelled + }; + + class Session + { + public: + ~Session(); + + void unlock(); + void setInfo(DWORD pid, const QString& name); + Results result() const; + + DWORD pid() const; + const QString& name() const; + + private: + mutable std::mutex m_mutex; + DWORD m_pid; + QString m_name; + }; + + UILocker(); + ~UILocker(); + + static UILocker& instance(); + + void setUserInterface(QWidget* parent); + + std::shared_ptr lock(Reasons reason); + bool locked() const; + + Results result() const; + +private: + QWidget* m_parent; + std::unique_ptr m_ui; + std::vector> m_sessions; + std::atomic m_result; + std::vector> m_disabled; + + void createUi(Reasons reason); + + void unlockCurrent(); + void unlock(Session* s); + void updateLabel(); + + void onForceUnlock(); + void onCancel(); + + void disableAll(); + void enableAll(); + void disable(QWidget* w); +}; + +#endif // MODORGANIZER_UILOCKER_INCLUDED diff --git a/src/src/updatedialog.cpp b/src/src/updatedialog.cpp new file mode 100644 index 0000000..ac019fb --- /dev/null +++ b/src/src/updatedialog.cpp @@ -0,0 +1,81 @@ +#include "updatedialog.h" +#include "ui_updatedialog.h" + +#ifdef MO2_WEBENGINE +#include "lootdialog.h" // for MarkdownPage +#include +#endif + +using namespace MOBase; + +UpdateDialog::UpdateDialog(QWidget* parent) + : QDialog(parent, Qt::WindowMaximizeButtonHint | Qt::WindowCloseButtonHint), + ui(new Ui::UpdateDialog) +{ + // Basic UI stuff + ui->setupUi(this); + connect(ui->installButton, &QPushButton::pressed, this, [&] { + done(QDialog::Accepted); + }); + connect(ui->cancelButton, &QPushButton::pressed, this, [&] { + done(QDialog::Rejected); + }); + + // Replace a label with an icon + QIcon icon = style()->standardIcon(QStyle::SP_MessageBoxQuestion); + QPixmap pixmap = icon.pixmap(QSize(32, 32)); + ui->iconLabel->setPixmap(pixmap); + ui->iconLabel->setScaledContents(true); + +#ifdef MO2_WEBENGINE + // Setting up the Markdown stuff (WebEngine renderer) + auto* page = new MarkdownPage(this); + ui->detailsWebView->setPage(page); + + auto* channel = new QWebChannel(this); + channel->registerObject("content", &m_changeLogs); + page->setWebChannel(channel); + + const QString path = QApplication::applicationDirPath() + "/resources/markdown.html"; + QFile f(path); + + if (f.open(QFile::ReadOnly)) { + const QString html = f.readAll(); + if (!html.isEmpty()) { + ui->detailsWebView->setHtml(html); + } else { + log::error("failed to read '{}', {}", path, f.errorString()); + } + } else { + log::error("can't open '{}', {}", path, f.errorString()); + } +#endif + + // Setting up the expander + m_expander.set(ui->detailsButton, ui->detailsWidget); + connect(&m_expander, &ExpanderWidget::toggled, this, [&] { + adjustSize(); + }); + + // Adjust sizes after the expander hides stuff + adjustSize(); +} + +UpdateDialog::~UpdateDialog() = default; + +void UpdateDialog::setChangeLogs(const QString& text) +{ + m_changeLogs.setText(text); +#ifndef MO2_WEBENGINE + // Without WebEngine, display directly in the QTextBrowser fallback. + ui->detailsWebView->setHtml(text); +#endif +} + +void UpdateDialog::setVersions(const QString& oldVersion, const QString& newVersion) +{ + ui->updateLabel->setText(tr("Mod Organizer %1 is available. The current version is " + "%2. Updating will not affect your mods or profiles.") + .arg(newVersion) + .arg(oldVersion)); +} diff --git a/src/src/updatedialog.h b/src/src/updatedialog.h new file mode 100644 index 0000000..ca49a23 --- /dev/null +++ b/src/src/updatedialog.h @@ -0,0 +1,31 @@ +#ifndef MODORGANIZER_UPDATEDIALOG_H +#define MODORGANIZER_UPDATEDIALOG_H + +#include + +#include "lootdialog.h" // for MarkdownDocument +#include + +namespace Ui +{ +class UpdateDialog; +} + +class UpdateDialog : public QDialog +{ + Q_OBJECT; + +public: + UpdateDialog(QWidget* parent); + ~UpdateDialog(); + + void setChangeLogs(const QString& text); + void setVersions(const QString& oldVersion, const QString& newVersion); + +private: + std::unique_ptr ui; + MOBase::ExpanderWidget m_expander; + MarkdownDocument m_changeLogs; +}; + +#endif // MODORGANIZER_UPDATEDIALOG_H diff --git a/src/src/updatedialog.ui b/src/src/updatedialog.ui new file mode 100644 index 0000000..b307b8f --- /dev/null +++ b/src/src/updatedialog.ui @@ -0,0 +1,200 @@ + + + UpdateDialog + + + + 0 + 0 + 578 + 539 + + + + + 460 + 0 + + + + Update available + + + true + + + true + + + + + + 0 + + + 9 + + + 9 + + + + + + 32 + 32 + + + + + 32 + 32 + + + + <?> + + + Qt::AlignCenter + + + 0 + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 10 + 20 + + + + + + + + Update text placeholder + + + true + + + + + + + + + + + Changelog + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Install + + + true + + + + + + + Cancel + + + false + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 650 + 450 + + + + + + + + + + + + + + + + diff --git a/src/src/usvfsconnector.cpp b/src/src/usvfsconnector.cpp new file mode 100644 index 0000000..83c1af1 --- /dev/null +++ b/src/src/usvfsconnector.cpp @@ -0,0 +1,342 @@ +/* +Copyright (C) 2015 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#include "usvfsconnector.h" +#include "envmodule.h" +#include "organizercore.h" +#include "settings.h" +#include "shared/util.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char SHMID[] = "mod_organizer_instance"; +using namespace MOBase; + +std::string to_hex(void* bufferIn, size_t bufferSize) +{ + unsigned char* buffer = static_cast(bufferIn); + std::ostringstream temp; + temp << std::hex; + for (size_t i = 0; i < bufferSize; ++i) { + temp << std::setfill('0') << std::setw(2) << (unsigned int)buffer[i]; + if ((i % 16) == 15) { + temp << "\n"; + } else { + temp << " "; + } + } + return temp.str(); +} + +LogWorker::LogWorker() + : m_Buffer(1024, '\0'), m_QuitRequested(false), + m_LogFile( + qApp->property("dataPath").toString() + + QString("/logs/usvfs-%1.log") + .arg(QDateTime::currentDateTimeUtc().toString("yyyy-MM-dd_hh-mm-ss"))) +{ + m_LogFile.open(QIODevice::WriteOnly); + log::debug("usvfs log messages are written to {}", m_LogFile.fileName()); +} + +LogWorker::~LogWorker() {} + +void LogWorker::process() +{ + MOShared::SetThisThreadName("LogWorker"); + + int noLogCycles = 0; + while (!m_QuitRequested) { + if (usvfsGetLogMessages(&m_Buffer[0], m_Buffer.size(), false)) { + m_LogFile.write(m_Buffer.c_str()); + m_LogFile.write("\n"); + m_LogFile.flush(); + noLogCycles = 0; + } else { + QThread::msleep(std::min(40, noLogCycles) * 5); + ++noLogCycles; + } + } + emit finished(); +} + +void LogWorker::exit() +{ + m_QuitRequested = true; +} + +LogLevel toUsvfsLogLevel(log::Levels level) +{ + switch (level) { + case log::Info: + return LogLevel::Info; + case log::Warning: + return LogLevel::Warning; + case log::Error: + return LogLevel::Error; + case log::Debug: // fall-through + default: + return LogLevel::Debug; + } +} + +CrashDumpsType toUsvfsCrashDumpsType(env::CoreDumpTypes type) +{ + switch (type) { + case env::CoreDumpTypes::None: + return CrashDumpsType::None; + + case env::CoreDumpTypes::Data: + return CrashDumpsType::Data; + + case env::CoreDumpTypes::Full: + return CrashDumpsType::Full; + + case env::CoreDumpTypes::Mini: + default: + return CrashDumpsType::Mini; + } +} + +UsvfsConnector::UsvfsConnector() +{ + using namespace std::chrono; + + const auto& s = Settings::instance(); + + const LogLevel logLevel = toUsvfsLogLevel(s.diagnostics().logLevel()); + const auto dumpType = toUsvfsCrashDumpsType(s.diagnostics().coreDumpType()); + const auto delay = duration_cast(s.diagnostics().spawnDelay()); + std::string dumpPath = + MOShared::ToString(OrganizerCore::getGlobalCoreDumpPath(), true); + + usvfsParameters* params = usvfsCreateParameters(); + + usvfsSetInstanceName(params, SHMID); + usvfsSetDebugMode(params, false); + usvfsSetLogLevel(params, logLevel); + usvfsSetCrashDumpType(params, dumpType); + usvfsSetCrashDumpPath(params, dumpPath.c_str()); + usvfsSetProcessDelay(params, delay.count()); + + usvfsInitLogging(false); + + log::debug("initializing usvfs:\n" + " . instance: {}\n" + " . log: {}\n" + " . dump: {} ({})", + SHMID, usvfsLogLevelToString(logLevel), dumpPath.c_str(), + usvfsCrashDumpTypeToString(dumpType)); + + usvfsCreateVFS(params); + usvfsFreeParameters(params); + + usvfsClearExecutableBlacklist(); + for (auto exec : s.executablesBlacklist().split(";")) { + std::wstring buf = exec.toStdWString(); + usvfsBlacklistExecutable(buf.data()); + } + + usvfsClearSkipFileSuffixes(); + for (auto& suffix : s.skipFileSuffixes()) { + if (suffix.isEmpty()) { + continue; + } + std::wstring buf = suffix.toStdWString(); + usvfsAddSkipFileSuffix(buf.data()); + } + + usvfsClearSkipDirectories(); + for (auto& dir : s.skipDirectories()) { + std::wstring buf = dir.toStdWString(); + usvfsAddSkipDirectory(buf.data()); + } + + usvfsClearLibraryForceLoads(); + + m_LogWorker.moveToThread(&m_WorkerThread); + + connect(&m_WorkerThread, SIGNAL(started()), &m_LogWorker, SLOT(process())); + connect(&m_LogWorker, SIGNAL(finished()), &m_WorkerThread, SLOT(quit())); + + m_WorkerThread.start(QThread::LowestPriority); +} + +UsvfsConnector::~UsvfsConnector() +{ + usvfsDisconnectVFS(); + m_LogWorker.exit(); + m_WorkerThread.quit(); + m_WorkerThread.wait(); +} + +void UsvfsConnector::updateMapping(const MappingType& mapping) +{ + const auto start = std::chrono::high_resolution_clock::now(); + + QProgressDialog progress(qApp->activeWindow()); + progress.setLabelText(tr("Preparing vfs")); + progress.setMaximum(static_cast(mapping.size())); + progress.show(); + + int value = 0; + int files = 0; + int dirs = 0; + + log::debug("Updating VFS mappings..."); + + usvfsClearVirtualMappings(); + + for (auto map : mapping) { + if (progress.wasCanceled()) { + usvfsClearVirtualMappings(); + throw UsvfsConnectorException("VFS mapping canceled by user"); + } + progress.setValue(value++); + if (value % 10 == 0) { + QCoreApplication::processEvents(); + } + + if (map.isDirectory) { + usvfsVirtualLinkDirectoryStatic( + map.source.toStdWString().c_str(), map.destination.toStdWString().c_str(), + (map.createTarget ? LINKFLAG_CREATETARGET : 0) | LINKFLAG_RECURSIVE); + ++dirs; + } else { + usvfsVirtualLinkFile(map.source.toStdWString().c_str(), + map.destination.toStdWString().c_str(), 0); + ++files; + } + } + + const auto end = std::chrono::high_resolution_clock::now(); + const auto time = std::chrono::duration_cast(end - start); + + log::debug("VFS mappings updated, linked {} dirs and {} files in {}ms", dirs, files, + time.count()); +} + +void UsvfsConnector::updateParams(MOBase::log::Levels logLevel, + env::CoreDumpTypes coreDumpType, + const QString& crashDumpsPath, + std::chrono::seconds spawnDelay, + QString executableBlacklist, + const QStringList& skipFileSuffixes, + const QStringList& skipDirectories) +{ + using namespace std::chrono; + + usvfsParameters* p = usvfsCreateParameters(); + + usvfsSetDebugMode(p, FALSE); + usvfsSetLogLevel(p, toUsvfsLogLevel(logLevel)); + usvfsSetCrashDumpType(p, toUsvfsCrashDumpsType(coreDumpType)); + usvfsSetCrashDumpPath(p, crashDumpsPath.toStdString().c_str()); + usvfsSetProcessDelay(p, duration_cast(spawnDelay).count()); + + usvfsUpdateParameters(p); + usvfsFreeParameters(p); + + usvfsClearExecutableBlacklist(); + for (auto exec : executableBlacklist.split(";")) { + std::wstring buf = exec.toStdWString(); + usvfsBlacklistExecutable(buf.data()); + } + + usvfsClearSkipFileSuffixes(); + for (auto& suffix : skipFileSuffixes) { + if (suffix.isEmpty()) { + continue; + } + std::wstring buf = suffix.toStdWString(); + usvfsAddSkipFileSuffix(buf.data()); + } + + usvfsClearSkipDirectories(); + for (auto& dir : skipDirectories) { + std::wstring buf = dir.toStdWString(); + usvfsAddSkipDirectory(buf.data()); + } +} + +void UsvfsConnector::updateForcedLibraries( + const QList& forcedLibraries) +{ + usvfsClearLibraryForceLoads(); + for (auto setting : forcedLibraries) { + if (setting.enabled()) { + usvfsForceLoadLibrary(setting.process().toStdWString().data(), + setting.library().toStdWString().data()); + } + } +} + +std::vector getRunningUSVFSProcesses() +{ + std::vector pids; + + { + size_t count = 0; + DWORD* buffer = nullptr; + if (!::usvfsGetVFSProcessList2(&count, &buffer)) { + log::error("failed to get usvfs process list"); + return {}; + } + + if (buffer) { + pids.assign(buffer, buffer + count); + std::free(buffer); + } + } + + const auto thisPid = GetCurrentProcessId(); + std::vector v; + + const auto rights = + PROCESS_QUERY_LIMITED_INFORMATION | // exit code, image name, etc. + SYNCHRONIZE | // wait functions + PROCESS_SET_QUOTA | PROCESS_TERMINATE; // add to job + + for (auto&& pid : pids) { + if (pid == thisPid) { + continue; // obviously don't wait for MO process + } + + HANDLE handle = ::OpenProcess(rights, FALSE, pid); + + if (handle == INVALID_HANDLE_VALUE) { + const auto e = GetLastError(); + + log::warn("failed to open usvfs process {}: {}", pid, formatSystemMessage(e)); + + continue; + } + + v.push_back(handle); + } + + return v; +} diff --git a/src/src/usvfsconnector.h b/src/src/usvfsconnector.h new file mode 100644 index 0000000..ce410e4 --- /dev/null +++ b/src/src/usvfsconnector.h @@ -0,0 +1,102 @@ +/* +Copyright (C) 2015 Sebastian Herbord. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef USVFSCONNECTOR_H +#define USVFSCONNECTOR_H + +#include "envdump.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class LogWorker : public QThread +{ + + Q_OBJECT + +public: + LogWorker(); + ~LogWorker(); + +public slots: + + void process(); + void exit(); + +signals: + + void outputLog(const QString& message); + void finished(); + +private: + std::string m_Buffer; + bool m_QuitRequested; + QFile m_LogFile; +}; + +class UsvfsConnectorException : public std::exception +{ + +public: + UsvfsConnectorException(const QString& text) + : std::exception(), m_Message(text.toLocal8Bit()) + {} + + virtual const char* what() const throw() { return m_Message.constData(); } + +private: + QByteArray m_Message; +}; + +class UsvfsConnector : public QObject +{ + + Q_OBJECT + +public: + UsvfsConnector(); + ~UsvfsConnector(); + + void updateMapping(const MappingType& mapping); + + void updateParams(MOBase::log::Levels logLevel, env::CoreDumpTypes coreDumpType, + const QString& crashDumpsPath, std::chrono::seconds spawnDelay, + QString executableBlacklist, const QStringList& skipFileSuffixes, + const QStringList& skipDirectories); + + void updateForcedLibraries( + const QList& forcedLibraries); + +private: + LogWorker m_LogWorker; + QThread m_WorkerThread; +}; + +CrashDumpsType crashDumpsType(int type); + +std::vector getRunningUSVFSProcesses(); + +#endif // USVFSCONNECTOR_H diff --git a/src/src/validationprogressdialog.ui b/src/src/validationprogressdialog.ui new file mode 100644 index 0000000..1c0cbcb --- /dev/null +++ b/src/src/validationprogressdialog.ui @@ -0,0 +1,117 @@ + + + ValidationProgressDialog + + + + 0 + 0 + 305 + 93 + + + + Validating Nexus Connection + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Connecting to Nexus... + + + + + + + 24 + + + false + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Cancel + + + + + + + Qt::Horizontal + + + + 122 + 20 + + + + + + + + Hide + + + true + + + + + + + + + + + diff --git a/src/src/version.rc b/src/src/version.rc new file mode 100644 index 0000000..477a7dc --- /dev/null +++ b/src/src/version.rc @@ -0,0 +1,37 @@ +#include "Winver.h" + +// If VS_FF_PRERELEASE is not set, MO labels the build as a release and uses VER_FILEVERSION to determine version number. +// Otherwise, if letters are used in VER_FILEVERSION_STR, uses the full MOBase::VersionInfo parser +// Otherwise, uses the numbers from VER_FILEVERSION and sets the release type as pre-alpha +#define VER_FILEVERSION 2,5,2 +#define VER_FILEVERSION_STR "2.5.2\0" + +VS_VERSION_INFO VERSIONINFO +FILEVERSION VER_FILEVERSION +PRODUCTVERSION VER_FILEVERSION +FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +FILEFLAGS (0) +FILEOS VOS__WINDOWS32 +FILETYPE VFT_APP +FILESUBTYPE (0) +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904B0" + BEGIN + VALUE "FileVersion", VER_FILEVERSION_STR + VALUE "CompanyName", "Mod Organizer 2 Team\0" + VALUE "FileDescription", "Mod Organizer 2 GUI\0" + VALUE "OriginalFilename", "ModOrganizer.exe\0" + VALUE "InternalName", "ModOrganizer2\0" + VALUE "LegalCopyright", "Copyright 2011-2016 Sebastian Herbord\r\nCopyright 2016-2026 Mod Organizer 2 contributors\0" + VALUE "ProductName", "Mod Organizer 2\0" + VALUE "ProductVersion", VER_FILEVERSION_STR + END + END + + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x0409, 1200 + END +END diff --git a/src/src/vfs/inodetable.cpp b/src/src/vfs/inodetable.cpp new file mode 100644 index 0000000..f4dcbcb --- /dev/null +++ b/src/src/vfs/inodetable.cpp @@ -0,0 +1,99 @@ +#include "inodetable.h" + +#include "vfstree.h" + +#include + +namespace +{ +std::string canonicalizePath(const std::string& path) +{ + std::string canonical = path; + for (char& c : canonical) { + if (c == '\\') { + c = '/'; + } + } + + while (!canonical.empty() && canonical.front() == '/') { + canonical.erase(canonical.begin()); + } + while (!canonical.empty() && canonical.back() == '/') { + canonical.pop_back(); + } + + return canonical; +} +} // namespace + +InodeTable::InodeTable() +{ + m_pathToInode.emplace("", 1); + m_inodeToPath.emplace(1, ""); +} + +uint64_t InodeTable::getOrCreate(const std::string& path) +{ + const std::string key = normalizeForLookup(path); + auto existing = m_pathToInode.find(key); + if (existing != m_pathToInode.end()) { + return existing->second; + } + + const uint64_t ino = m_nextInode++; + m_pathToInode.emplace(key, ino); + m_inodeToPath.emplace(ino, canonicalizePath(path)); + return ino; +} + +std::string InodeTable::getPath(uint64_t ino) const +{ + auto it = m_inodeToPath.find(ino); + if (it == m_inodeToPath.end()) { + return ""; + } + return it->second; +} + +void InodeTable::rename(const std::string& old_path, const std::string& new_path) +{ + const std::string oldCanonical = canonicalizePath(old_path); + const std::string newCanonical = canonicalizePath(new_path); + const std::string oldKey = normalizeForLookup(oldCanonical); + const std::string newKey = normalizeForLookup(newCanonical); + + auto it = m_pathToInode.find(oldKey); + if (it != m_pathToInode.end()) { + const uint64_t ino = it->second; + m_pathToInode.erase(it); + m_pathToInode.emplace(newKey, ino); + m_inodeToPath[ino] = newCanonical; + } + + std::vector> descendants; + descendants.reserve(m_pathToInode.size()); + + const std::string oldPrefix = oldKey.empty() ? oldKey : oldKey + "/"; + for (const auto& [key, ino] : m_pathToInode) { + if (oldPrefix.empty() || key.rfind(oldPrefix, 0) == 0) { + descendants.emplace_back(key, ino); + } + } + + for (const auto& [descKey, ino] : descendants) { + if (descKey == oldKey) { + continue; + } + + const std::string suffix = descKey.substr(oldPrefix.size()); + const std::string nextKey = newKey.empty() ? suffix : newKey + "/" + suffix; + m_pathToInode.erase(descKey); + m_pathToInode.emplace(nextKey, ino); + + auto inodeIt = m_inodeToPath.find(ino); + if (inodeIt != m_inodeToPath.end()) { + const std::string suffixCanonical = inodeIt->second.substr(oldCanonical.size()); + inodeIt->second = newCanonical + suffixCanonical; + } + } +} diff --git a/src/src/vfs/inodetable.h b/src/src/vfs/inodetable.h new file mode 100644 index 0000000..3594db1 --- /dev/null +++ b/src/src/vfs/inodetable.h @@ -0,0 +1,23 @@ +#ifndef VFS_INODETABLE_H +#define VFS_INODETABLE_H + +#include +#include +#include + +class InodeTable +{ +public: + InodeTable(); + + uint64_t getOrCreate(const std::string& path); + std::string getPath(uint64_t ino) const; + void rename(const std::string& old_path, const std::string& new_path); + +private: + std::unordered_map m_pathToInode; + std::unordered_map m_inodeToPath; + uint64_t m_nextInode = 2; +}; + +#endif diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp new file mode 100644 index 0000000..7842c1a --- /dev/null +++ b/src/src/vfs/mo2filesystem.cpp @@ -0,0 +1,794 @@ +#include "mo2filesystem.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace +{ +namespace fs = std::filesystem; + +constexpr double TTL_SECONDS = 60.0 * 60.0 * 24.0 * 365.0; + +struct NodeSnapshot +{ + bool found = false; + bool is_directory = false; + bool is_backing = false; + uint64_t size = 0; + std::chrono::system_clock::time_point mtime{}; + std::string real_path; +}; + +Mo2FsContext* getContext(fuse_req_t req) +{ + return static_cast(fuse_req_userdata(req)); +} + +std::vector splitPath(const std::string& path) +{ + std::vector out; + std::string clean = path; + std::replace(clean.begin(), clean.end(), '\\', '/'); + + size_t start = 0; + while (start < clean.size()) { + while (start < clean.size() && clean[start] == '/') { + ++start; + } + if (start >= clean.size()) { + break; + } + const size_t end = clean.find('/', start); + if (end == std::string::npos) { + out.push_back(clean.substr(start)); + break; + } + out.push_back(clean.substr(start, end - start)); + start = end + 1; + } + + return out; +} + +std::string joinPath(const std::string& base, const std::string& name) +{ + if (base.empty()) { + return name; + } + return base + "/" + name; +} + +std::string inodeToPath(const Mo2FsContext* ctx, fuse_ino_t ino, bool* ok) +{ + std::scoped_lock lock(ctx->inode_mutex); + const std::string path = ctx->inodes->getPath(ino); + + if (ino == 1) { + *ok = true; + return ""; + } + + *ok = !path.empty(); + return path; +} + +NodeSnapshot snapshotForPath(const Mo2FsContext* ctx, const std::string& path) +{ + NodeSnapshot snap; + std::shared_lock lock(ctx->tree_mutex); + + const VfsNode* node = path.empty() ? &ctx->tree->root : ctx->tree->root.resolve(splitPath(path)); + if (node == nullptr) { + return snap; + } + + snap.found = true; + snap.is_directory = node->is_directory; + if (!node->is_directory) { + snap.real_path = node->file_info.real_path; + snap.size = node->file_info.size; + snap.mtime = node->file_info.mtime; + snap.is_backing = node->file_info.is_backing; + } + + return snap; +} + +std::vector> listChildrenSnapshot( + const Mo2FsContext* ctx, const std::string& path, bool* ok) +{ + std::vector> out; + std::shared_lock lock(ctx->tree_mutex); + + const VfsNode* node = path.empty() ? &ctx->tree->root : ctx->tree->root.resolve(splitPath(path)); + if (node == nullptr || !node->is_directory) { + *ok = false; + return out; + } + + *ok = true; + for (const auto& [name, child] : node->listChildren()) { + out.emplace_back(name, child->is_directory); + } + + return out; +} + +void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid) +{ + std::memset(st, 0, sizeof(struct stat)); + st->st_ino = ino; + st->st_mode = S_IFDIR | 0755; + st->st_nlink = 2; + st->st_uid = uid; + st->st_gid = gid; +} + +void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid, + uint64_t size, + const std::chrono::system_clock::time_point& mtime) +{ + std::memset(st, 0, sizeof(struct stat)); + st->st_ino = ino; + st->st_mode = S_IFREG | 0644; + st->st_nlink = 1; + st->st_uid = uid; + st->st_gid = gid; + st->st_size = static_cast(size); + + const auto secs = std::chrono::duration_cast( + mtime.time_since_epoch()); + st->st_mtim.tv_sec = secs.count(); + st->st_ctim.tv_sec = secs.count(); + st->st_atim.tv_sec = secs.count(); +} + +void replyEntryFromSnapshot(fuse_req_t req, const Mo2FsContext* ctx, fuse_ino_t ino, + const NodeSnapshot& snap) +{ + struct fuse_entry_param e; + std::memset(&e, 0, sizeof(e)); + e.ino = ino; + e.attr_timeout = TTL_SECONDS; + e.entry_timeout = TTL_SECONDS; + + if (snap.is_directory) { + fillStatForDir(&e.attr, ino, ctx->uid, ctx->gid); + } else { + fillStatForFile(&e.attr, ino, ctx->uid, ctx->gid, snap.size, snap.mtime); + } + + fuse_reply_entry(req, &e); +} + +bool isWritableOpen(int flags) +{ + return (flags & O_WRONLY) != 0 || (flags & O_RDWR) != 0; +} + +std::chrono::system_clock::time_point fileMtimeOrNow(const std::string& path) +{ + std::error_code ec; + const auto mtime = fs::last_write_time(path, ec); + if (ec) { + return std::chrono::system_clock::now(); + } + + const auto nowFs = fs::file_time_type::clock::now(); + const auto nowSys = std::chrono::system_clock::now(); + return nowSys + std::chrono::duration_cast( + mtime - nowFs); +} + +void updateFileNode(Mo2FsContext* ctx, const std::string& relative, + const std::string& realPath, const std::string& origin) +{ + std::error_code ec; + const uint64_t size = static_cast(fs::file_size(realPath, ec)); + const auto mtime = fileMtimeOrNow(realPath); + + std::unique_lock lock(ctx->tree_mutex); + ctx->tree->root.insertFile(splitPath(relative), realPath, ec ? 0 : size, mtime, + origin); +} + +} // namespace + +void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name) +{ + Mo2FsContext* ctx = getContext(req); + if (ctx == nullptr || name == nullptr) { + fuse_reply_err(req, EINVAL); + return; + } + + bool ok = false; + const std::string parentPath = inodeToPath(ctx, parent, &ok); + if (!ok) { + fuse_reply_err(req, ENOENT); + return; + } + + const std::string childPath = joinPath(parentPath, name); + const auto snap = snapshotForPath(ctx, childPath); + if (!snap.found) { + fuse_reply_err(req, ENOENT); + return; + } + + fuse_ino_t childIno; + { + std::scoped_lock lock(ctx->inode_mutex); + childIno = ctx->inodes->getOrCreate(childPath); + } + + replyEntryFromSnapshot(req, ctx, childIno, snap); +} + +void mo2_getattr(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* /*fi*/) +{ + Mo2FsContext* ctx = getContext(req); + if (ctx == nullptr) { + fuse_reply_err(req, EINVAL); + return; + } + + if (ino == 1) { + struct stat st; + fillStatForDir(&st, 1, ctx->uid, ctx->gid); + fuse_reply_attr(req, &st, TTL_SECONDS); + return; + } + + bool ok = false; + const std::string path = inodeToPath(ctx, ino, &ok); + if (!ok) { + fuse_reply_err(req, ENOENT); + return; + } + + const auto snap = snapshotForPath(ctx, path); + if (!snap.found) { + fuse_reply_err(req, ENOENT); + return; + } + + struct stat st; + if (snap.is_directory) { + fillStatForDir(&st, ino, ctx->uid, ctx->gid); + } else { + fillStatForFile(&st, ino, ctx->uid, ctx->gid, snap.size, snap.mtime); + } + + fuse_reply_attr(req, &st, TTL_SECONDS); +} + +void mo2_readdir(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, + struct fuse_file_info* /*fi*/) +{ + Mo2FsContext* ctx = getContext(req); + if (ctx == nullptr || off < 0) { + fuse_reply_err(req, EINVAL); + return; + } + + bool ok = false; + const std::string path = inodeToPath(ctx, ino, &ok); + if (!ok) { + fuse_reply_err(req, ENOENT); + return; + } + + bool listOk = false; + auto children = listChildrenSnapshot(ctx, path, &listOk); + if (!listOk) { + fuse_reply_err(req, ENOTDIR); + return; + } + + struct Entry + { + fuse_ino_t ino; + std::string name; + bool isDir; + }; + + std::vector entries; + entries.reserve(children.size() + 2); + entries.push_back({ino, ".", true}); + entries.push_back({1, "..", true}); + + { + std::scoped_lock lock(ctx->inode_mutex); + for (const auto& [name, isDir] : children) { + const std::string childPath = joinPath(path, name); + entries.push_back({ctx->inodes->getOrCreate(childPath), name, isDir}); + } + } + + std::vector buf(size); + size_t used = 0; + + for (size_t i = static_cast(off); i < entries.size(); ++i) { + struct stat st; + std::memset(&st, 0, sizeof(st)); + st.st_ino = entries[i].ino; + st.st_mode = entries[i].isDir ? (S_IFDIR | 0755) : (S_IFREG | 0644); + + const size_t ent = fuse_add_direntry(req, buf.data() + used, size - used, + entries[i].name.c_str(), &st, + static_cast(i + 1)); + if (ent > size - used) { + break; + } + used += ent; + } + + fuse_reply_buf(req, buf.data(), used); +} + +void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) +{ + Mo2FsContext* ctx = getContext(req); + if (ctx == nullptr || fi == nullptr) { + fuse_reply_err(req, EINVAL); + return; + } + + bool ok = false; + const std::string path = inodeToPath(ctx, ino, &ok); + if (!ok) { + fuse_reply_err(req, ENOENT); + return; + } + + const auto snap = snapshotForPath(ctx, path); + if (!snap.found || snap.is_directory) { + fuse_reply_err(req, ENOENT); + return; + } + + std::string realPath = snap.real_path; + const bool writable = isWritableOpen(fi->flags); + bool isBacking = snap.is_backing; + + if (writable) { + try { + if (isBacking && ctx->backing_dir_fd >= 0) { + realPath = ctx->overwrite->copyOnWriteFromFd(ctx->backing_dir_fd, path); + } else { + realPath = ctx->overwrite->copyOnWrite(realPath, path); + } + isBacking = false; + updateFileNode(ctx, path, realPath, "Staging"); + } catch (...) { + fuse_reply_err(req, EIO); + return; + } + } + + const uint64_t fh = ctx->next_fh.fetch_add(1, std::memory_order_relaxed); + { + std::scoped_lock lock(ctx->open_files_mutex); + Mo2FsContext::OpenFile of; + of.real_path = realPath; + of.writable = writable; + of.is_backing = isBacking; + of.relative_path = path; + ctx->open_files[fh] = std::move(of); + } + + fi->fh = fh; + fi->keep_cache = 1; + fuse_reply_open(req, fi); +} + +void mo2_read(fuse_req_t req, fuse_ino_t /*ino*/, size_t size, off_t off, + struct fuse_file_info* fi) +{ + Mo2FsContext* ctx = getContext(req); + if (ctx == nullptr || fi == nullptr || off < 0) { + fuse_reply_err(req, EINVAL); + return; + } + + std::string realPath; + bool isBacking = false; + { + std::scoped_lock lock(ctx->open_files_mutex); + auto it = ctx->open_files.find(fi->fh); + if (it == ctx->open_files.end()) { + fuse_reply_err(req, EBADF); + return; + } + realPath = it->second.real_path; + isBacking = it->second.is_backing; + } + + int fd = -1; + if (isBacking && ctx->backing_dir_fd >= 0) { + fd = openat(ctx->backing_dir_fd, realPath.c_str(), O_RDONLY); + } else { + fd = open(realPath.c_str(), O_RDONLY); + } + + if (fd < 0) { + fuse_reply_err(req, EIO); + return; + } + + std::vector out(size); + const ssize_t n = pread(fd, out.data(), size, off); + close(fd); + + if (n < 0) { + fuse_reply_err(req, EIO); + return; + } + + fuse_reply_buf(req, out.data(), static_cast(n)); +} + +void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size, + off_t off, struct fuse_file_info* fi) +{ + Mo2FsContext* ctx = getContext(req); + if (ctx == nullptr || fi == nullptr || off < 0 || (buf == nullptr && size > 0)) { + fuse_reply_err(req, EINVAL); + return; + } + + Mo2FsContext::OpenFile open; + { + std::scoped_lock lock(ctx->open_files_mutex); + auto it = ctx->open_files.find(fi->fh); + if (it == ctx->open_files.end()) { + fuse_reply_err(req, EBADF); + return; + } + open = it->second; + } + + if (!open.writable) { + fuse_reply_err(req, EACCES); + return; + } + + std::fstream io(open.real_path, std::ios::binary | std::ios::in | std::ios::out); + if (!io) { + io.open(open.real_path, std::ios::binary | std::ios::out); + io.close(); + io.open(open.real_path, std::ios::binary | std::ios::in | std::ios::out); + } + + if (!io) { + fuse_reply_err(req, EIO); + return; + } + + io.seekp(off, std::ios::beg); + io.write(buf, static_cast(size)); + io.flush(); + if (!io) { + fuse_reply_err(req, EIO); + return; + } + + updateFileNode(ctx, open.relative_path, open.real_path, "Staging"); + fuse_reply_write(req, size); +} + +void mo2_create(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t /*mode*/, + struct fuse_file_info* fi) +{ + Mo2FsContext* ctx = getContext(req); + if (ctx == nullptr || fi == nullptr || name == nullptr) { + fuse_reply_err(req, EINVAL); + return; + } + + bool ok = false; + const std::string parentPath = inodeToPath(ctx, parent, &ok); + if (!ok) { + fuse_reply_err(req, ENOENT); + return; + } + + const std::string relative = joinPath(parentPath, name); + + std::string realPath; + try { + realPath = ctx->overwrite->writeFile(relative, {}); + } catch (...) { + fuse_reply_err(req, EIO); + return; + } + + updateFileNode(ctx, relative, realPath, "Staging"); + { + std::unique_lock lock(ctx->tree_mutex); + ++ctx->tree->file_count; + } + + fuse_ino_t newIno; + { + std::scoped_lock lock(ctx->inode_mutex); + newIno = ctx->inodes->getOrCreate(relative); + } + + const auto snap = snapshotForPath(ctx, relative); + if (!snap.found || snap.is_directory) { + fuse_reply_err(req, EIO); + return; + } + + const uint64_t fh = ctx->next_fh.fetch_add(1, std::memory_order_relaxed); + { + std::scoped_lock lock(ctx->open_files_mutex); + Mo2FsContext::OpenFile of; + of.real_path = realPath; + of.writable = true; + of.is_backing = false; + of.relative_path = relative; + ctx->open_files[fh] = std::move(of); + } + + fi->fh = fh; + fi->keep_cache = 1; + + struct fuse_entry_param e; + std::memset(&e, 0, sizeof(e)); + e.ino = newIno; + e.attr_timeout = TTL_SECONDS; + e.entry_timeout = TTL_SECONDS; + fillStatForFile(&e.attr, newIno, ctx->uid, ctx->gid, snap.size, snap.mtime); + + fuse_reply_create(req, &e, fi); +} + +void mo2_rename(fuse_req_t req, fuse_ino_t parent, const char* name, + fuse_ino_t newparent, const char* newname, unsigned int flags) +{ + Mo2FsContext* ctx = getContext(req); + if (ctx == nullptr || name == nullptr || newname == nullptr || flags != 0) { + fuse_reply_err(req, EINVAL); + return; + } + + bool okParent = false; + bool okNewParent = false; + const std::string parentPath = inodeToPath(ctx, parent, &okParent); + const std::string newParentPath = inodeToPath(ctx, newparent, &okNewParent); + if (!okParent || !okNewParent) { + fuse_reply_err(req, ENOENT); + return; + } + + const std::string oldRelative = joinPath(parentPath, name); + const std::string newRelative = joinPath(newParentPath, newname); + + const auto oldSnap = snapshotForPath(ctx, oldRelative); + if (!oldSnap.found) { + fuse_reply_err(req, ENOENT); + return; + } + + if (!ctx->overwrite->rename(oldRelative, newRelative)) { + fuse_reply_err(req, EACCES); + return; + } + + { + std::unique_lock lock(ctx->tree_mutex); + ctx->tree->root.removeFromTree(splitPath(oldRelative)); + + if (oldSnap.is_directory) { + ctx->tree->root.insertDirectory(splitPath(newRelative)); + } else { + const std::string staged = ctx->overwrite->stagingPath(newRelative); + const std::string over = ctx->overwrite->overwritePath(newRelative); + const std::string real = fs::exists(staged) ? staged : over; + ctx->tree->root.insertFile(splitPath(newRelative), real, oldSnap.size, + std::chrono::system_clock::now(), "Staging"); + } + } + + { + std::scoped_lock lock(ctx->inode_mutex); + ctx->inodes->rename(oldRelative, newRelative); + } + + fuse_reply_err(req, 0); +} + +void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set, + struct fuse_file_info* fi) +{ + Mo2FsContext* ctx = getContext(req); + if (ctx == nullptr) { + fuse_reply_err(req, EINVAL); + return; + } + + if (ino == 1) { + struct stat st; + fillStatForDir(&st, 1, ctx->uid, ctx->gid); + fuse_reply_attr(req, &st, TTL_SECONDS); + return; + } + + bool ok = false; + const std::string path = inodeToPath(ctx, ino, &ok); + if (!ok) { + fuse_reply_err(req, ENOENT); + return; + } + + if ((to_set & FUSE_SET_ATTR_SIZE) != 0 && attr != nullptr) { + std::string target; + bool targetIsBacking = false; + uint64_t fh = 0; + + if (fi != nullptr) { + fh = fi->fh; + std::scoped_lock lock(ctx->open_files_mutex); + auto it = ctx->open_files.find(fh); + if (it != ctx->open_files.end()) { + target = it->second.real_path; + targetIsBacking = it->second.is_backing; + } + } + + if (target.empty()) { + const auto snap = snapshotForPath(ctx, path); + if (!snap.found || snap.is_directory) { + fuse_reply_err(req, ENOENT); + return; + } + target = snap.real_path; + targetIsBacking = snap.is_backing; + } + + const std::string stagedPath = ctx->overwrite->stagingPath(path); + if (fs::path(target).lexically_normal().string() != + fs::path(stagedPath).lexically_normal().string()) { + try { + if (targetIsBacking && ctx->backing_dir_fd >= 0) { + target = ctx->overwrite->copyOnWriteFromFd(ctx->backing_dir_fd, path); + } else { + target = ctx->overwrite->copyOnWrite(target, path); + } + } catch (...) { + fuse_reply_err(req, EIO); + return; + } + + if (fi != nullptr) { + std::scoped_lock lock(ctx->open_files_mutex); + auto it = ctx->open_files.find(fh); + if (it != ctx->open_files.end()) { + it->second.real_path = target; + it->second.writable = true; + } + } + } + + std::error_code ec; + fs::resize_file(target, static_cast(attr->st_size), ec); + if (ec) { + fuse_reply_err(req, EIO); + return; + } + + updateFileNode(ctx, path, target, "Staging"); + } + + const auto snap = snapshotForPath(ctx, path); + if (!snap.found) { + fuse_reply_err(req, ENOENT); + return; + } + + struct stat st; + if (snap.is_directory) { + fillStatForDir(&st, ino, ctx->uid, ctx->gid); + } else { + fillStatForFile(&st, ino, ctx->uid, ctx->gid, snap.size, snap.mtime); + } + fuse_reply_attr(req, &st, TTL_SECONDS); +} + +void mo2_unlink(fuse_req_t req, fuse_ino_t parent, const char* name) +{ + Mo2FsContext* ctx = getContext(req); + if (ctx == nullptr || name == nullptr) { + fuse_reply_err(req, EINVAL); + return; + } + + bool ok = false; + const std::string parentPath = inodeToPath(ctx, parent, &ok); + if (!ok) { + fuse_reply_err(req, ENOENT); + return; + } + + const std::string relative = joinPath(parentPath, name); + if (!ctx->overwrite->removeFile(relative)) { + fuse_reply_err(req, EACCES); + return; + } + + { + std::unique_lock lock(ctx->tree_mutex); + if (ctx->tree->root.removeFromTree(splitPath(relative))) { + ctx->tree->file_count = ctx->tree->file_count > 0 ? ctx->tree->file_count - 1 : 0; + } + } + + fuse_reply_err(req, 0); +} + +void mo2_mkdir(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t /*mode*/) +{ + Mo2FsContext* ctx = getContext(req); + if (ctx == nullptr || name == nullptr) { + fuse_reply_err(req, EINVAL); + return; + } + + bool ok = false; + const std::string parentPath = inodeToPath(ctx, parent, &ok); + if (!ok) { + fuse_reply_err(req, ENOENT); + return; + } + + const std::string relative = joinPath(parentPath, name); + if (!ctx->overwrite->createDirectory(relative)) { + fuse_reply_err(req, EIO); + return; + } + + { + std::unique_lock lock(ctx->tree_mutex); + ctx->tree->root.insertDirectory(splitPath(relative)); + ++ctx->tree->dir_count; + } + + fuse_ino_t dirIno; + { + std::scoped_lock lock(ctx->inode_mutex); + dirIno = ctx->inodes->getOrCreate(relative); + } + + const auto snap = snapshotForPath(ctx, relative); + if (!snap.found) { + fuse_reply_err(req, EIO); + return; + } + + replyEntryFromSnapshot(req, ctx, dirIno, snap); +} + +void mo2_release(fuse_req_t req, fuse_ino_t /*ino*/, struct fuse_file_info* fi) +{ + Mo2FsContext* ctx = getContext(req); + if (ctx == nullptr || fi == nullptr) { + fuse_reply_err(req, EINVAL); + return; + } + + { + std::scoped_lock lock(ctx->open_files_mutex); + ctx->open_files.erase(fi->fh); + } + + fuse_reply_err(req, 0); +} diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h new file mode 100644 index 0000000..a66d08d --- /dev/null +++ b/src/src/vfs/mo2filesystem.h @@ -0,0 +1,64 @@ +#ifndef VFS_MO2FILESYSTEM_H +#define VFS_MO2FILESYSTEM_H + +#include + +#include "inodetable.h" +#include "overwritemanager.h" +#include "vfstree.h" + +#include +#include +#include +#include +#include +#include + +struct Mo2FsContext +{ + std::shared_ptr tree; + mutable std::shared_mutex tree_mutex; + + std::unique_ptr inodes; + mutable std::mutex inode_mutex; + + std::unique_ptr overwrite; + + int backing_dir_fd = -1; + + struct OpenFile + { + std::string real_path; + bool writable = false; + bool is_backing = false; + std::string relative_path; + }; + + std::unordered_map open_files; + mutable std::mutex open_files_mutex; + std::atomic next_fh{1}; + + uid_t uid = 0; + gid_t gid = 0; +}; + +void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name); +void mo2_getattr(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi); +void mo2_readdir(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, + struct fuse_file_info* fi); +void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi); +void mo2_read(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, + struct fuse_file_info* fi); +void mo2_write(fuse_req_t req, fuse_ino_t ino, const char* buf, size_t size, + off_t off, struct fuse_file_info* fi); +void mo2_create(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t mode, + struct fuse_file_info* fi); +void mo2_rename(fuse_req_t req, fuse_ino_t parent, const char* name, + fuse_ino_t newparent, const char* newname, unsigned int flags); +void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set, + struct fuse_file_info* fi); +void mo2_unlink(fuse_req_t req, fuse_ino_t parent, const char* name); +void mo2_mkdir(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t mode); +void mo2_release(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi); + +#endif diff --git a/src/src/vfs/overwritemanager.cpp b/src/src/vfs/overwritemanager.cpp new file mode 100644 index 0000000..7edfe7d --- /dev/null +++ b/src/src/vfs/overwritemanager.cpp @@ -0,0 +1,178 @@ +#include "overwritemanager.h" + +#include +#include + +#include +#include + +namespace +{ +namespace fs = std::filesystem; + +std::string sanitizeRelative(const std::string& relative) +{ + std::string out = relative; + for (char& c : out) { + if (c == '\\') { + c = '/'; + } + } + while (!out.empty() && out.front() == '/') { + out.erase(out.begin()); + } + return out; +} +} // namespace + +OverwriteManager::OverwriteManager(const std::string& staging_dir, + const std::string& overwrite_dir) + : m_stagingDir(staging_dir), m_overwriteDir(overwrite_dir) +{ + std::error_code ec; + fs::create_directories(m_stagingDir, ec); + fs::create_directories(m_overwriteDir, ec); +} + +std::string OverwriteManager::stagingPath(const std::string& relative_path) const +{ + return (fs::path(m_stagingDir) / sanitizeRelative(relative_path)).string(); +} + +std::string OverwriteManager::overwritePath(const std::string& relative_path) const +{ + return (fs::path(m_overwriteDir) / sanitizeRelative(relative_path)).string(); +} + +std::string OverwriteManager::copyOnWrite(const std::string& source_path, + const std::string& relative_path) +{ + const fs::path dest = stagingPath(relative_path); + + std::error_code ec; + fs::create_directories(dest.parent_path(), ec); + + if (fs::exists(dest, ec)) { + return dest.string(); + } + + if (!source_path.empty() && fs::exists(source_path, ec)) { + fs::copy_file(source_path, dest, fs::copy_options::overwrite_existing, ec); + if (ec) { + throw fs::filesystem_error("copyOnWrite", fs::path(source_path), dest, ec); + } + } else { + std::ofstream out(dest, std::ios::binary); + out.close(); + } + + return dest.string(); +} + +std::string OverwriteManager::copyOnWriteFromFd(int dir_fd, + const std::string& relative_path) +{ + const fs::path dest = stagingPath(relative_path); + + std::error_code ec; + fs::create_directories(dest.parent_path(), ec); + + if (fs::exists(dest, ec)) { + return dest.string(); + } + + const std::string rel = sanitizeRelative(relative_path); + const int src_fd = openat(dir_fd, rel.c_str(), O_RDONLY); + if (src_fd < 0) { + // Source doesn't exist in backing dir, create empty file + std::ofstream out(dest, std::ios::binary); + out.close(); + return dest.string(); + } + + std::ofstream out(dest, std::ios::binary); + if (!out) { + close(src_fd); + throw fs::filesystem_error("copyOnWriteFromFd", dest, + std::error_code(errno, std::generic_category())); + } + + char buf[65536]; + ssize_t n; + while ((n = read(src_fd, buf, sizeof(buf))) > 0) { + out.write(buf, n); + } + + close(src_fd); + return dest.string(); +} + +std::string OverwriteManager::writeFile(const std::string& relative_path, + const std::vector& data) +{ + const fs::path path = stagingPath(relative_path); + std::error_code ec; + fs::create_directories(path.parent_path(), ec); + + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out) { + throw fs::filesystem_error("writeFile", path, std::error_code(errno, std::generic_category())); + } + + if (!data.empty()) { + out.write(reinterpret_cast(data.data()), static_cast(data.size())); + } + + return path.string(); +} + +bool OverwriteManager::rename(const std::string& old_relative, + const std::string& new_relative) +{ + std::error_code ec; + + fs::path from = stagingPath(old_relative); + fs::path to = stagingPath(new_relative); + + if (!fs::exists(from, ec)) { + from = overwritePath(old_relative); + to = overwritePath(new_relative); + if (!fs::exists(from, ec)) { + return false; + } + } + + fs::create_directories(to.parent_path(), ec); + fs::rename(from, to, ec); + return !ec; +} + +bool OverwriteManager::removeFile(const std::string& relative_path) +{ + std::error_code ec; + fs::path staged = stagingPath(relative_path); + if (fs::exists(staged, ec)) { + return fs::remove(staged, ec); + } + + fs::path overwrite = overwritePath(relative_path); + if (fs::exists(overwrite, ec)) { + return fs::remove(overwrite, ec); + } + + return false; +} + +bool OverwriteManager::createDirectory(const std::string& relative_path) +{ + std::error_code ec; + fs::create_directories(stagingPath(relative_path), ec); + return !ec; +} + +bool OverwriteManager::exists(const std::string& relative_path) const +{ + std::error_code ec; + return fs::exists(stagingPath(relative_path), ec) || + fs::exists(overwritePath(relative_path), ec); +} diff --git a/src/src/vfs/overwritemanager.h b/src/src/vfs/overwritemanager.h new file mode 100644 index 0000000..abc09d9 --- /dev/null +++ b/src/src/vfs/overwritemanager.h @@ -0,0 +1,34 @@ +#ifndef VFS_OVERWRITEMANAGER_H +#define VFS_OVERWRITEMANAGER_H + +#include +#include +#include + +class OverwriteManager +{ +public: + OverwriteManager(const std::string& staging_dir, const std::string& overwrite_dir); + + std::string copyOnWrite(const std::string& source_path, + const std::string& relative_path); + + std::string copyOnWriteFromFd(int dir_fd, const std::string& relative_path); + + std::string writeFile(const std::string& relative_path, + const std::vector& data); + + bool rename(const std::string& old_relative, const std::string& new_relative); + bool removeFile(const std::string& relative_path); + bool createDirectory(const std::string& relative_path); + + bool exists(const std::string& relative_path) const; + std::string overwritePath(const std::string& relative_path) const; + std::string stagingPath(const std::string& relative_path) const; + +private: + std::string m_stagingDir; + std::string m_overwriteDir; +}; + +#endif diff --git a/src/src/vfs/vfs_helper_main.cpp b/src/src/vfs/vfs_helper_main.cpp new file mode 100644 index 0000000..f0b4889 --- /dev/null +++ b/src/src/vfs/vfs_helper_main.cpp @@ -0,0 +1,319 @@ +// Standalone VFS helper for Flatpak FUSE support. +// Runs on the host via flatpak-spawn --host, where FUSE works normally. +// Communicates with MO2 GUI via stdin/stdout pipes. + +#include "inodetable.h" +#include "mo2filesystem.h" +#include "overwritemanager.h" +#include "vfstree.h" + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +struct HelperConfig +{ + std::string mount_point; + std::string game_dir; + std::string data_dir_name; + std::string overwrite_dir; + std::vector> mods; +}; + +static HelperConfig readConfig(const std::string& path) +{ + HelperConfig cfg; + std::ifstream in(path); + std::string line; + + while (std::getline(in, line)) { + if (line.empty() || line[0] == '#') { + continue; + } + + const auto eq = line.find('='); + if (eq == std::string::npos) { + continue; + } + + const std::string key = line.substr(0, eq); + const std::string val = line.substr(eq + 1); + + if (key == "mount_point") { + cfg.mount_point = val; + } else if (key == "game_dir") { + cfg.game_dir = val; + } else if (key == "data_dir_name") { + cfg.data_dir_name = val; + } else if (key == "overwrite_dir") { + cfg.overwrite_dir = val; + } else if (key == "mod") { + const auto pipe = val.find('|'); + if (pipe != std::string::npos) { + cfg.mods.emplace_back(val.substr(0, pipe), val.substr(pipe + 1)); + } + } + } + + return cfg; +} + +static void tryUnmountStale(const std::string& path) +{ + pid_t pid = fork(); + if (pid == 0) { + int devnull = open("/dev/null", O_WRONLY); + if (devnull >= 0) { + dup2(devnull, STDERR_FILENO); + close(devnull); + } + execlp("fusermount3", "fusermount3", "-u", path.c_str(), nullptr); + _exit(1); + } + if (pid > 0) { + int status; + waitpid(pid, &status, 0); + } +} + +static void flushStaging(const std::string& stagingDir, + const std::string& overwriteDir) +{ + const fs::path staging(stagingDir); + const fs::path overwrite(overwriteDir); + if (!fs::exists(staging)) { + return; + } + + std::error_code ec; + for (auto it = fs::recursive_directory_iterator( + staging, fs::directory_options::skip_permission_denied); + it != fs::recursive_directory_iterator(); ++it) { + const auto& entry = *it; + const fs::path rel = fs::relative(entry.path(), staging, ec); + if (ec || rel.empty()) { + continue; + } + + const fs::path dest = overwrite / rel; + if (entry.is_directory(ec)) { + fs::create_directories(dest, ec); + continue; + } + + if (!entry.is_regular_file(ec)) { + continue; + } + + fs::create_directories(dest.parent_path(), ec); + fs::rename(entry.path(), dest, ec); + if (ec) { + ec.clear(); + fs::copy_file(entry.path(), dest, fs::copy_options::overwrite_existing, ec); + if (!ec) { + fs::remove(entry.path(), ec); + } + } + } + + fs::remove_all(staging, ec); +} + +static void setupFuseOps(struct fuse_lowlevel_ops* ops) +{ + std::memset(ops, 0, sizeof(struct fuse_lowlevel_ops)); + ops->lookup = mo2_lookup; + ops->getattr = mo2_getattr; + ops->readdir = mo2_readdir; + ops->open = mo2_open; + ops->read = mo2_read; + ops->write = mo2_write; + ops->create = mo2_create; + ops->rename = mo2_rename; + ops->setattr = mo2_setattr; + ops->unlink = mo2_unlink; + ops->mkdir = mo2_mkdir; + ops->release = mo2_release; +} + +static struct fuse_session* g_session = nullptr; + +static void signalHandler(int /*sig*/) +{ + if (g_session) { + fuse_session_exit(g_session); + } +} + +int main(int argc, char* argv[]) +{ + if (argc < 2) { + std::cerr << "Usage: mo2-vfs-helper \n"; + return 1; + } + + const std::string configPath = argv[1]; + auto config = readConfig(configPath); + + if (config.mount_point.empty()) { + std::cout << "error: mount_point not set in config" << std::endl; + return 1; + } + + const std::string dataDirPath = config.mount_point; + const std::string stagingDir = + (fs::path(config.overwrite_dir).parent_path() / "VFS_staging").string(); + + if (!fs::exists(dataDirPath)) { + std::cout << "error: data directory does not exist: " << dataDirPath + << std::endl; + return 1; + } + + std::error_code ec; + fs::create_directories(stagingDir, ec); + fs::create_directories(config.overwrite_dir, ec); + + // Scan base game files BEFORE mounting (after mount they're hidden) + auto baseFileCache = scanDataDir(dataDirPath); + + // Open fd to data dir BEFORE mounting so we can access original files + int backingFd = open(dataDirPath.c_str(), O_RDONLY | O_DIRECTORY); + if (backingFd < 0) { + std::cout << "error: failed to open backing fd for " << dataDirPath + << std::endl; + return 1; + } + + // Clean up any stale FUSE mount + tryUnmountStale(dataDirPath); + + // Build VFS tree + auto tree = std::make_shared( + buildDataDirVfs(baseFileCache, dataDirPath, config.mods, + config.overwrite_dir)); + + auto context = std::make_shared(); + context->tree = tree; + context->inodes = std::make_unique(); + context->overwrite = + std::make_unique(stagingDir, config.overwrite_dir); + context->backing_dir_fd = backingFd; + context->uid = ::getuid(); + context->gid = ::getgid(); + + // Setup FUSE + std::vector argvStorage = { + "mo2-vfs-helper", "-o", "fsname=mo2linux", "-o", "default_permissions", + "-o", "noatime"}; + + std::vector fuseArgv; + fuseArgv.reserve(argvStorage.size()); + for (auto& s : argvStorage) { + fuseArgv.push_back(s.data()); + } + + struct fuse_args args = + FUSE_ARGS_INIT(static_cast(fuseArgv.size()), fuseArgv.data()); + + struct fuse_lowlevel_ops ops; + setupFuseOps(&ops); + + struct fuse_session* session = + fuse_session_new(&args, &ops, sizeof(ops), context.get()); + if (session == nullptr) { + close(backingFd); + std::cout << "error: failed to create FUSE session" << std::endl; + return 1; + } + + if (fuse_session_mount(session, dataDirPath.c_str()) != 0) { + fuse_session_destroy(session); + close(backingFd); + std::cout << "error: failed to mount FUSE at " << dataDirPath << std::endl; + return 1; + } + + g_session = session; + + // Handle signals for clean shutdown + struct sigaction sa; + sa.sa_handler = signalHandler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + sigaction(SIGINT, &sa, nullptr); + sigaction(SIGTERM, &sa, nullptr); + + // Start FUSE event loop in background thread + std::thread fuseThread([session]() { + fuse_session_loop_mt(session, nullptr); + }); + + std::cout << "mounted" << std::endl; + + // Command loop: read commands from stdin + std::string line; + while (std::getline(std::cin, line)) { + if (line == "rebuild") { + auto newConfig = readConfig(configPath); + auto newTree = std::make_shared(buildDataDirVfs( + baseFileCache, dataDirPath, newConfig.mods, newConfig.overwrite_dir)); + + { + std::unique_lock lock(context->tree_mutex); + context->tree.swap(newTree); + } + + config = newConfig; + std::cout << "ok" << std::endl; + } else if (line == "flush") { + flushStaging(stagingDir, config.overwrite_dir); + fs::create_directories(stagingDir, ec); + + auto newTree = std::make_shared(buildDataDirVfs( + baseFileCache, dataDirPath, config.mods, config.overwrite_dir)); + + { + std::unique_lock lock(context->tree_mutex); + context->tree.swap(newTree); + } + + context->overwrite = + std::make_unique(stagingDir, config.overwrite_dir); + std::cout << "ok" << std::endl; + } else if (line == "quit") { + break; + } + } + + // Clean shutdown + fuse_session_exit(session); + fuse_session_unmount(session); + + if (fuseThread.joinable()) { + fuseThread.join(); + } + + fuse_session_destroy(session); + g_session = nullptr; + + flushStaging(stagingDir, config.overwrite_dir); + close(backingFd); + + std::cout << "ok" << std::endl; + return 0; +} diff --git a/src/src/vfs/vfstree.cpp b/src/src/vfs/vfstree.cpp new file mode 100644 index 0000000..5070635 --- /dev/null +++ b/src/src/vfs/vfstree.cpp @@ -0,0 +1,380 @@ +#include "vfstree.h" + +#include +#include +#include + +namespace +{ +namespace fs = std::filesystem; + +std::chrono::system_clock::time_point +fsTimeToSystemClock(const fs::file_time_type& t) +{ + const auto nowFs = fs::file_time_type::clock::now(); + const auto nowSys = std::chrono::system_clock::now(); + return nowSys + std::chrono::duration_cast(t - nowFs); +} + +std::vector splitPath(const std::string& path) +{ + std::vector out; + std::string clean = path; + std::replace(clean.begin(), clean.end(), '\\', '/'); + + size_t start = 0; + while (start < clean.size()) { + while (start < clean.size() && clean[start] == '/') { + ++start; + } + if (start >= clean.size()) { + break; + } + const size_t end = clean.find('/', start); + if (end == std::string::npos) { + out.push_back(clean.substr(start)); + break; + } + out.push_back(clean.substr(start, end - start)); + start = end + 1; + } + + return out; +} + +void addDirectoryToTree(VfsTree& tree, const fs::path& walkDir, + const fs::path& stripPrefix, const std::string& origin, + const std::vector& prefix, + bool is_backing = false) +{ + if (!fs::exists(walkDir)) { + return; + } + + for (auto it = fs::recursive_directory_iterator( + walkDir, fs::directory_options::skip_permission_denied); + it != fs::recursive_directory_iterator(); ++it) { + const auto& entry = *it; + std::error_code ec; + + const fs::path rel = fs::relative(entry.path(), stripPrefix, ec); + if (ec || rel.empty()) { + continue; + } + + const std::string relStr = rel.generic_string(); + if (relStr == "meta.ini") { + continue; + } + + auto relParts = splitPath(relStr); + std::vector components; + components.reserve(prefix.size() + relParts.size()); + components.insert(components.end(), prefix.begin(), prefix.end()); + components.insert(components.end(), relParts.begin(), relParts.end()); + + if (entry.is_directory(ec)) { + tree.root.insertDirectory(components); + ++tree.dir_count; + continue; + } + + if (!entry.is_regular_file(ec)) { + continue; + } + + const auto size = entry.file_size(ec); + const auto mtime = entry.last_write_time(ec); + tree.root.insertFile(components, entry.path().string(), size, + ec ? std::chrono::system_clock::time_point{} + : fsTimeToSystemClock(mtime), + origin, is_backing); + ++tree.file_count; + } +} + +bool removeNodeRecursive(VfsNode* node, const std::vector& components, + size_t index) +{ + if (node == nullptr || !node->is_directory || index >= components.size()) { + return false; + } + + const std::string key = normalizeForLookup(components[index]); + auto it = node->dir_info.children.find(key); + if (it == node->dir_info.children.end()) { + return false; + } + + if (index + 1 == components.size()) { + node->dir_info.children.erase(it); + node->dir_info.display_names.erase(key); + return true; + } + + VfsNode* child = it->second.get(); + if (!removeNodeRecursive(child, components, index + 1)) { + return false; + } + + if (child->is_directory && child->dir_info.children.empty()) { + node->dir_info.children.erase(key); + node->dir_info.display_names.erase(key); + } + + return true; +} + +} // namespace + +std::string normalizeForLookup(const std::string& path) +{ + std::string result; + result.reserve(path.size()); + for (unsigned char c : path) { + result.push_back(c == '\\' ? '/' : static_cast(std::tolower(c))); + } + return result; +} + +void VfsNode::insertFile(const std::vector& components, + const std::string& real_path, uint64_t size, + std::chrono::system_clock::time_point mtime, + const std::string& origin, bool is_backing) +{ + if (components.empty()) { + return; + } + + VfsNode* current = this; + for (size_t i = 0; i < components.size(); ++i) { + const std::string& part = components[i]; + if (part.empty()) { + continue; + } + + if (!current->is_directory) { + current->is_directory = true; + current->dir_info = VfsDirInfo{}; + } + + const std::string key = normalizeForLookup(part); + current->dir_info.display_names[key] = part; + + if (i + 1 == components.size()) { + auto fileNode = std::make_unique(); + fileNode->is_directory = false; + fileNode->file_info = {real_path, size, mtime, origin, is_backing}; + current->dir_info.children[key] = std::move(fileNode); + return; + } + + auto it = current->dir_info.children.find(key); + if (it == current->dir_info.children.end() || !it->second->is_directory) { + auto newDir = std::make_unique(); + newDir->is_directory = true; + current->dir_info.children[key] = std::move(newDir); + } + + current = current->dir_info.children[key].get(); + } +} + +void VfsNode::insertDirectory(const std::vector& components) +{ + if (components.empty()) { + return; + } + + VfsNode* current = this; + for (const auto& part : components) { + if (part.empty()) { + continue; + } + + if (!current->is_directory) { + current->is_directory = true; + current->dir_info = VfsDirInfo{}; + } + + const std::string key = normalizeForLookup(part); + current->dir_info.display_names[key] = part; + + auto it = current->dir_info.children.find(key); + if (it == current->dir_info.children.end() || !it->second->is_directory) { + auto newDir = std::make_unique(); + newDir->is_directory = true; + current->dir_info.children[key] = std::move(newDir); + } + + current = current->dir_info.children[key].get(); + } +} + +const VfsNode* VfsNode::resolve(const std::vector& components) const +{ + const VfsNode* current = this; + + for (const auto& part : components) { + if (part.empty()) { + continue; + } + + if (!current->is_directory) { + return nullptr; + } + + const std::string key = normalizeForLookup(part); + auto it = current->dir_info.children.find(key); + if (it == current->dir_info.children.end()) { + return nullptr; + } + + current = it->second.get(); + } + + return current; +} + +std::vector> VfsNode::listChildren() const +{ + std::vector> out; + if (!is_directory) { + return out; + } + + out.reserve(dir_info.children.size()); + for (const auto& [key, node] : dir_info.children) { + auto display = dir_info.display_names.find(key); + if (display == dir_info.display_names.end()) { + continue; + } + out.emplace_back(display->second, node.get()); + } + + return out; +} + +bool VfsNode::removeFromTree(const std::vector& components) +{ + if (components.empty()) { + return false; + } + return removeNodeRecursive(this, components, 0); +} + +VfsTree buildVfsTree(const std::vector>& mods, + const std::string& overwrite_dir) +{ + VfsTree tree; + tree.root.is_directory = true; + tree.file_count = 0; + tree.dir_count = 1; + + addDirectoryToTree(tree, fs::path(overwrite_dir), fs::path(overwrite_dir), + "Overwrite", {}); + + for (const auto& [modName, modPath] : mods) { + addDirectoryToTree(tree, fs::path(modPath), fs::path(modPath), modName, {}); + } + + return tree; +} + +VfsTree buildFullGameVfs(const std::string& game_dir, const std::string& data_dir, + const std::vector>& mods, + const std::string& overwrite_dir) +{ + VfsTree tree; + tree.root.is_directory = true; + tree.file_count = 0; + tree.dir_count = 1; + + addDirectoryToTree(tree, fs::path(game_dir), fs::path(game_dir), "_base_game", {}); + addDirectoryToTree(tree, fs::path(overwrite_dir), fs::path(overwrite_dir), + "Overwrite", {}); + + const auto dataPrefix = splitPath(data_dir); + for (const auto& [modName, modPath] : mods) { + // Step D requirement: no Root/ handling. Every mod file is projected under data_dir. + addDirectoryToTree(tree, fs::path(modPath), fs::path(modPath), modName, dataPrefix); + } + + return tree; +} + +std::vector scanDataDir(const std::string& data_dir_path) +{ + std::vector cache; + const fs::path dataDir(data_dir_path); + + if (!fs::exists(dataDir)) { + return cache; + } + + for (auto it = fs::recursive_directory_iterator( + dataDir, fs::directory_options::skip_permission_denied); + it != fs::recursive_directory_iterator(); ++it) { + const auto& entry = *it; + std::error_code ec; + + const fs::path rel = fs::relative(entry.path(), dataDir, ec); + if (ec || rel.empty()) { + continue; + } + + CachedBaseFile cf; + cf.relative_path = rel.generic_string(); + cf.is_dir = entry.is_directory(ec); + + if (!cf.is_dir) { + if (!entry.is_regular_file(ec)) { + continue; + } + cf.size = entry.file_size(ec); + const auto mtime = entry.last_write_time(ec); + cf.mtime = ec ? std::chrono::system_clock::time_point{} + : fsTimeToSystemClock(mtime); + } + + cache.push_back(std::move(cf)); + } + + return cache; +} + +VfsTree buildDataDirVfs(const std::vector& cached_files, + const std::string& data_dir, + const std::vector>& mods, + const std::string& overwrite_dir) +{ + VfsTree tree; + tree.root.is_directory = true; + tree.file_count = 0; + tree.dir_count = 1; + + // Layer 1: Base game files from cache (is_backing=true) + // real_path stores the relative path; FUSE handler uses openat(backing_fd, rel) + for (const auto& cf : cached_files) { + const auto components = splitPath(cf.relative_path); + if (cf.is_dir) { + tree.root.insertDirectory(components); + ++tree.dir_count; + } else { + tree.root.insertFile(components, cf.relative_path, cf.size, cf.mtime, + "_base_game", /*is_backing=*/true); + ++tree.file_count; + } + } + + // Layer 2: Overwrite (higher priority, overwrites base game) + addDirectoryToTree(tree, fs::path(overwrite_dir), fs::path(overwrite_dir), + "Overwrite", {}); + + // Layer 3: Mods in priority order (highest priority) + for (const auto& [modName, modPath] : mods) { + addDirectoryToTree(tree, fs::path(modPath), fs::path(modPath), modName, {}); + } + + return tree; +} diff --git a/src/src/vfs/vfstree.h b/src/src/vfs/vfstree.h new file mode 100644 index 0000000..5d6de43 --- /dev/null +++ b/src/src/vfs/vfstree.h @@ -0,0 +1,80 @@ +#ifndef VFS_VFSTREE_H +#define VFS_VFSTREE_H + +#include +#include +#include +#include +#include +#include +#include + +struct VfsNode; + +struct VfsFileInfo +{ + std::string real_path; + uint64_t size = 0; + std::chrono::system_clock::time_point mtime{}; + std::string origin; + bool is_backing = false; +}; + +struct CachedBaseFile +{ + std::string relative_path; + uint64_t size = 0; + std::chrono::system_clock::time_point mtime{}; + bool is_dir = false; +}; + +struct VfsDirInfo +{ + std::unordered_map> children; + std::unordered_map display_names; +}; + +struct VfsNode +{ + bool is_directory = true; + VfsFileInfo file_info; + VfsDirInfo dir_info; + + void insertFile(const std::vector& components, + const std::string& real_path, uint64_t size, + std::chrono::system_clock::time_point mtime, + const std::string& origin, bool is_backing = false); + + void insertDirectory(const std::vector& components); + + const VfsNode* resolve(const std::vector& components) const; + + std::vector> listChildren() const; + + bool removeFromTree(const std::vector& components); +}; + +struct VfsTree +{ + VfsNode root; + size_t file_count = 0; + size_t dir_count = 0; +}; + +std::string normalizeForLookup(const std::string& path); + +VfsTree buildVfsTree(const std::vector>& mods, + const std::string& overwrite_dir); + +VfsTree buildFullGameVfs(const std::string& game_dir, const std::string& data_dir, + const std::vector>& mods, + const std::string& overwrite_dir); + +std::vector scanDataDir(const std::string& data_dir_path); + +VfsTree buildDataDirVfs(const std::vector& cached_files, + const std::string& data_dir, + const std::vector>& mods, + const std::string& overwrite_dir); + +#endif diff --git a/src/src/viewmarkingscrollbar.cpp b/src/src/viewmarkingscrollbar.cpp new file mode 100644 index 0000000..331cf61 --- /dev/null +++ b/src/src/viewmarkingscrollbar.cpp @@ -0,0 +1,56 @@ +#include "viewmarkingscrollbar.h" +#include "modelutils.h" +#include +#include +#include + +using namespace MOShared; + +ViewMarkingScrollBar::ViewMarkingScrollBar(QTreeView* view, int role) + : QScrollBar(view), m_view(view), m_role(role) +{ + // not implemented for horizontal sliders + Q_ASSERT(this->orientation() == Qt::Vertical); +} + +QColor ViewMarkingScrollBar::color(const QModelIndex& index) const +{ + auto data = index.data(m_role); + if (data.canConvert()) { + return data.value(); + } + return QColor(); +} + +void ViewMarkingScrollBar::paintEvent(QPaintEvent* event) +{ + if (m_view->model() == nullptr) { + return; + } + QScrollBar::paintEvent(event); + + QStyleOptionSlider styleOption; + initStyleOption(&styleOption); + + QPainter painter(this); + + QRect handleRect = style()->subControlRect(QStyle::CC_ScrollBar, &styleOption, + QStyle::SC_ScrollBarSlider, this); + QRect innerRect = style()->subControlRect(QStyle::CC_ScrollBar, &styleOption, + QStyle::SC_ScrollBarGroove, this); + + auto indices = visibleIndex(m_view, 0); + + painter.translate(innerRect.topLeft() + QPoint(0, 3)); + qreal scale = + static_cast(innerRect.height() - 3) / static_cast(indices.size()); + + for (int i = 0; i < indices.size(); ++i) { + QColor color = this->color(indices[i]); + if (color.isValid()) { + painter.setPen(color); + painter.setBrush(color); + painter.drawRect(QRect(2, i * scale - 2, handleRect.width() - 5, 3)); + } + } +} diff --git a/src/src/viewmarkingscrollbar.h b/src/src/viewmarkingscrollbar.h new file mode 100644 index 0000000..ec91ac6 --- /dev/null +++ b/src/src/viewmarkingscrollbar.h @@ -0,0 +1,24 @@ +#ifndef VIEWMARKINGSCROLLBAR_H +#define VIEWMARKINGSCROLLBAR_H + +#include +#include + +class ViewMarkingScrollBar : public QScrollBar +{ +public: + ViewMarkingScrollBar(QTreeView* view, int role); + +protected: + void paintEvent(QPaintEvent* event) override; + + // retrieve the color of the marker for the given index + // + virtual QColor color(const QModelIndex& index) const; + +private: + QTreeView* m_view; + int m_role; +}; + +#endif // VIEWMARKINGSCROLLBAR_H diff --git a/src/src/virtualfiletree.cpp b/src/src/virtualfiletree.cpp new file mode 100644 index 0000000..bfd4a43 --- /dev/null +++ b/src/src/virtualfiletree.cpp @@ -0,0 +1,82 @@ +#include "virtualfiletree.h" + +#include "shared/directoryentry.h" +#include "shared/fileentry.h" +#include "shared/filesorigin.h" +// #include "shared/util.h" + +using namespace MOBase; +using namespace MOShared; + +class VirtualFileTreeImpl : public VirtualFileTree +{ +public: + /** + * + */ + VirtualFileTreeImpl(std::shared_ptr parent, + const DirectoryEntry* dir) + : FileTreeEntry(parent, parent ? QString::fromStdWString(dir->getName()) : ""), + VirtualFileTree(), m_dirEntry(dir) + {} + +protected: + /** + * No mutable operations allowed. + */ + bool beforeReplace(IFileTree const* dstTree, FileTreeEntry const* destination, + FileTreeEntry const* source) override + { + return false; + } + bool beforeInsert(IFileTree const* entry, FileTreeEntry const* name) override + { + return false; + } + bool beforeRemove(IFileTree const* entry, FileTreeEntry const* name) override + { + return false; + } + std::shared_ptr makeFile(std::shared_ptr parent, + QString name) const override + { + return nullptr; + } + std::shared_ptr makeDirectory(std::shared_ptr parent, + QString name) const override + { + return nullptr; + } + + bool doPopulate(std::shared_ptr parent, + std::vector>& entries) const override + { + for (auto* subdirEntry : m_dirEntry->getSubDirectories()) { + entries.push_back(std::make_shared(parent, subdirEntry)); + } + for (auto& file : m_dirEntry->getFiles()) { + entries.push_back( + createFileEntry(parent, QString::fromStdWString(file->getName()))); + } + + // Vector is already sorted: + return true; + } + + std::shared_ptr doClone() const + { + return std::make_shared(nullptr, m_dirEntry); + } + +private: + const DirectoryEntry* m_dirEntry; +}; + +/** + * + */ +std::shared_ptr +VirtualFileTree::makeTree(const DirectoryEntry* rootEntry) +{ + return std::make_shared(nullptr, rootEntry); +} diff --git a/src/src/virtualfiletree.h b/src/src/virtualfiletree.h new file mode 100644 index 0000000..2451473 --- /dev/null +++ b/src/src/virtualfiletree.h @@ -0,0 +1,62 @@ +/* +Copyright (C) MO2 Team. All rights reserved. + +This file is part of Mod Organizer. + +Mod Organizer is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Mod Organizer is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Mod Organizer. If not, see . +*/ + +#ifndef ARCHIVEFILENETRY_H +#define ARCHIVEFILENTRY_H + +#include + +#include "ifiletree.h" + +namespace MOShared +{ +class DirectoryEntry; +} + +/** + * @brief Class that expose the VFS main directory structure as a `MOBase::IFileTree`. + * + * The tree is lazily populated: each subtree is only populated when needed, + * as specified by IFileTree. + * + * This class does not expose mutable operations, so any mutable operations will + * fail. + */ +class VirtualFileTree : public MOBase::IFileTree +{ +public: + /** + * @brief Create a new file tree representing the given VFS directory. + * + * @param root Root directory. + * + * @return a file tree representing the VFS directory. + */ + static std::shared_ptr + makeTree(const MOShared::DirectoryEntry* root); + +protected: + using IFileTree::IFileTree; + + virtual bool + doPopulate(std::shared_ptr parent, + std::vector>& entries) const = 0; +}; + +#endif diff --git a/src/src/wineprefix.cpp b/src/src/wineprefix.cpp new file mode 100644 index 0000000..85b04bb --- /dev/null +++ b/src/src/wineprefix.cpp @@ -0,0 +1,288 @@ +#include "wineprefix.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr const char* BackupSavesUpper = ".mo2linux_backup_Saves"; +constexpr const char* BackupSavesLower = ".mo2linux_backup_saves"; +constexpr const char* BackupIniSuffix = ".mo2linux_backup"; + +bool copyFileWithParents(const QString& source, const QString& destination) +{ + const QFileInfo destinationInfo(destination); + if (!QDir().mkpath(destinationInfo.dir().absolutePath())) { + return false; + } + + if (QFile::exists(destination) && !QFile::remove(destination)) { + return false; + } + + return QFile::copy(source, destination); +} + +bool copyTreeContents(const QString& sourceRoot, const QString& destinationRoot) +{ + QDirIterator it(sourceRoot, QDir::Files, QDirIterator::Subdirectories); + + while (it.hasNext()) { + const QString source = it.next(); + const QString relativePath = QDir(sourceRoot).relativeFilePath(source); + const QString destination = QDir(destinationRoot).filePath(relativePath); + + if (!copyFileWithParents(source, destination)) { + return false; + } + } + + return true; +} + +bool restoreBackedUpSaves(const QString& liveUpper, const QString& liveLower, + const QString& backupUpper, const QString& backupLower) +{ + if (QDir(liveUpper).exists() && !QDir(liveUpper).removeRecursively()) { + return false; + } + if (QDir(liveLower).exists() && !QDir(liveLower).removeRecursively()) { + return false; + } + + if (QDir(backupUpper).exists() && !QDir().rename(backupUpper, liveUpper)) { + return false; + } + if (QDir(backupLower).exists() && !QDir().rename(backupLower, liveLower)) { + return false; + } + + return true; +} + +bool restoreBackedUpIni(const QString& liveIni, const QString& backupIni) +{ + if (!QFile::exists(backupIni)) { + return true; + } + + if (QFile::exists(liveIni) && !QFile::remove(liveIni)) { + return false; + } + + return QFile::rename(backupIni, liveIni); +} +} // namespace + +WinePrefix::WinePrefix(const QString& prefixPath) + : m_prefixPath(QDir::cleanPath(prefixPath)) +{} + +bool WinePrefix::isValid() const +{ + return QDir(driveC()).exists(); +} + +QString WinePrefix::driveC() const +{ + return QDir(m_prefixPath).filePath("drive_c"); +} + +QString WinePrefix::documentsPath() const +{ + return QDir(driveC()).filePath("users/steamuser/Documents"); +} + +QString WinePrefix::myGamesPath() const +{ + return QDir(documentsPath()).filePath("My Games"); +} + +QString WinePrefix::appdataLocal() const +{ + return QDir(driveC()).filePath("users/steamuser/AppData/Local"); +} + +bool WinePrefix::deployPlugins(const QStringList& plugins, const QString& dataDir) const +{ + if (!isValid()) { + return false; + } + + const QString pluginsDir = QDir(appdataLocal()).filePath(dataDir); + if (!QDir().mkpath(pluginsDir)) { + return false; + } + + QFile pluginsFile(QDir(pluginsDir).filePath("Plugins.txt")); + if (!pluginsFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + return false; + } + + QTextStream pluginsStream(&pluginsFile); + for (const QString& plugin : plugins) { + pluginsStream << plugin << "\r\n"; + } + pluginsFile.close(); + + QFile loadOrderFile(QDir(pluginsDir).filePath("loadorder.txt")); + if (!loadOrderFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + return false; + } + + QTextStream loadOrderStream(&loadOrderFile); + for (const QString& plugin : plugins) { + QString line = plugin; + if (line.startsWith('*')) { + line.remove(0, 1); + } + + loadOrderStream << line << "\r\n"; + } + + return true; +} + +bool WinePrefix::deployProfileIni(const QString& sourceIniPath, + const QString& targetIniPath) const +{ + const QFileInfo iniInfo(sourceIniPath); + if (!iniInfo.exists() || !iniInfo.isFile()) { + return false; + } + + const QString destination = QDir::cleanPath(targetIniPath); + const QString backup = destination + BackupIniSuffix; + + if (!restoreBackedUpIni(destination, backup)) { + return false; + } + + if (QFile::exists(destination) && !QFile::rename(destination, backup)) { + return false; + } + + return copyFileWithParents(iniInfo.absoluteFilePath(), destination); +} + +bool WinePrefix::deployProfileSaves(const QString& profileSaveDir, const QString& gameName, + const QString& saveRelativePath, + bool clearDestination) const +{ + if (!isValid()) { + return false; + } + + const QString gameRoot = QDir(myGamesPath()).filePath(gameName); + const QString normalizedSavePath = + QString(saveRelativePath).replace('\\', '/').trimmed(); + const QString effectiveSavePath = normalizedSavePath.isEmpty() ? "Saves" : normalizedSavePath; + const QString destinationSavesDirUpper = QDir(gameRoot).filePath(effectiveSavePath); + const QString destinationSavesDirLower = + QDir(gameRoot).filePath(effectiveSavePath.toLower()); + const QString backupUpper = QDir(gameRoot).filePath(BackupSavesUpper); + const QString backupLower = QDir(gameRoot).filePath(BackupSavesLower); + + if (clearDestination) { + // Recover from any stale backup left by an interrupted run. + if ((QDir(backupUpper).exists() || QDir(backupLower).exists()) && + !restoreBackedUpSaves(destinationSavesDirUpper, destinationSavesDirLower, + backupUpper, backupLower)) { + return false; + } + + if (QDir(destinationSavesDirUpper).exists() && + !QDir().rename(destinationSavesDirUpper, backupUpper)) { + return false; + } + if (QDir(destinationSavesDirLower).exists() && + !QDir().rename(destinationSavesDirLower, backupLower)) { + return false; + } + } + + if (!QDir().mkpath(destinationSavesDirUpper)) { + return false; + } + + if (!QDir(profileSaveDir).exists()) { + return true; + } + + return copyTreeContents(profileSaveDir, destinationSavesDirUpper); +} + +bool WinePrefix::syncSavesBack(const QString& profileSaveDir, const QString& gameName, + const QString& saveRelativePath) const +{ + if (!isValid()) { + return false; + } + + const QString gameRoot = QDir(myGamesPath()).filePath(gameName); + const QString normalizedSavePath = + QString(saveRelativePath).replace('\\', '/').trimmed(); + const QString effectiveSavePath = normalizedSavePath.isEmpty() ? "Saves" : normalizedSavePath; + const QString upperSaves = QDir(gameRoot).filePath(effectiveSavePath); + const QString lowerSaves = QDir(gameRoot).filePath(effectiveSavePath.toLower()); + + QString sourceSavesDir; + if (QDir(upperSaves).exists()) { + sourceSavesDir = upperSaves; + } else if (QDir(lowerSaves).exists()) { + sourceSavesDir = lowerSaves; + } else { + return true; + } + + if (!QDir().mkpath(profileSaveDir)) { + return false; + } + + const bool copied = copyTreeContents(sourceSavesDir, profileSaveDir); + if (!copied) { + MOBase::log::warn("Failed syncing saves from '{}' to '{}'", sourceSavesDir, + profileSaveDir); + } + + const QString backupUpper = QDir(gameRoot).filePath(BackupSavesUpper); + const QString backupLower = QDir(gameRoot).filePath(BackupSavesLower); + if (!restoreBackedUpSaves(upperSaves, lowerSaves, backupUpper, backupLower)) { + MOBase::log::warn("Failed restoring backed up global saves in '{}'", gameRoot); + return false; + } + + return copied; +} + +bool WinePrefix::syncProfileInisBack( + const QList>& iniMappings) const +{ + bool allCopied = true; + for (const auto& mapping : iniMappings) { + const QString profileIniPath = QDir::cleanPath(mapping.first); + const QString prefixIniPath = QDir::cleanPath(mapping.second); + const QString backupIniPath = prefixIniPath + BackupIniSuffix; + + if (!QFileInfo::exists(prefixIniPath)) { + if (!restoreBackedUpIni(prefixIniPath, backupIniPath)) { + allCopied = false; + } + continue; + } + + if (!copyFileWithParents(prefixIniPath, profileIniPath)) { + allCopied = false; + } + + if (!restoreBackedUpIni(prefixIniPath, backupIniPath)) { + allCopied = false; + } + } + + return allCopied; +} diff --git a/src/src/wineprefix.h b/src/src/wineprefix.h new file mode 100644 index 0000000..7b54d4b --- /dev/null +++ b/src/src/wineprefix.h @@ -0,0 +1,38 @@ +#ifndef WINEPREFIX_H +#define WINEPREFIX_H + +#include +#include +#include +#include + +class WinePrefix +{ +public: + explicit WinePrefix(const QString& prefixPath); + + bool isValid() const; // drive_c/ exists + QString driveC() const; + QString documentsPath() const; // drive_c/users/steamuser/Documents + QString myGamesPath() const; // .../Documents/My Games + QString appdataLocal() const; // .../AppData/Local + + // Deploy profile files into prefix + bool deployPlugins(const QStringList& plugins, const QString& dataDir) const; + bool deployProfileIni(const QString& sourceIniPath, + const QString& targetIniPath) const; + bool deployProfileSaves(const QString& profileSaveDir, const QString& gameName, + const QString& saveRelativePath, + bool clearDestination) const; + + // Sync saves back from prefix to profile + bool syncSavesBack(const QString& profileSaveDir, const QString& gameName, + const QString& saveRelativePath) const; + bool syncProfileInisBack( + const QList>& iniMappings) const; + +private: + QString m_prefixPath; +}; + +#endif // WINEPREFIX_H -- cgit v1.3.1