├── .gitattributes ├── .github ├── actions │ ├── goreleaser │ │ └── action.yml │ └── setup │ │ └── action.yml └── workflows │ ├── build-base.yml │ ├── push.yml │ ├── release.yml │ └── translations.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .vscode ├── SatisfactoryModManager.code-workspace └── tasks.json ├── LICENSE ├── README.md ├── Taskfile.yml ├── backend ├── app │ ├── app.go │ ├── debug_info.go │ ├── info.go │ ├── interactions.go │ └── window.go ├── args.go ├── autoupdate │ ├── apply │ │ ├── appimage.go │ │ ├── darwin_app.go │ │ ├── nsis.go │ │ └── singlefile.go │ ├── autoupdate.go │ ├── checksum │ │ └── goreleaser │ │ │ └── goreleaser.go │ ├── source │ │ └── github │ │ │ ├── github.go │ │ │ └── types.go │ ├── update_mode.go │ ├── update_mode_darwin.go │ ├── update_mode_linux.go │ ├── update_mode_windows.go │ └── updater │ │ ├── check.go │ │ ├── progress.go │ │ ├── types.go │ │ └── updater.go ├── common │ └── app.go ├── ficsitcli │ ├── action.go │ ├── installs.go │ ├── launch_other.go │ ├── launch_windows.go │ ├── metadata.go │ ├── mods.go │ ├── offline.go │ ├── profiles.go │ ├── remote_servers.go │ ├── serverpicker.go │ ├── types.go │ ├── update.go │ └── wrapper.go ├── installfinders │ ├── common │ │ ├── gameinfo.go │ │ ├── helpers.go │ │ ├── launcherplatform.go │ │ ├── launcherplatform_native.go │ │ ├── launcherplatform_wine.go │ │ └── types.go │ ├── findinstalls.go │ └── launchers │ │ ├── all │ │ └── all.go │ │ ├── crossover │ │ ├── crossover_darwin.go │ │ ├── crossover_noop.go │ │ └── types_darwin.go │ │ ├── epic │ │ ├── epic.go │ │ ├── epic_windows.go │ │ └── epic_wine_unix.go │ │ ├── heroic │ │ ├── heroic.go │ │ ├── heroic_flatpak_linux.go │ │ ├── heroic_native_all.go │ │ └── heroic_snap_linux.go │ │ ├── legendary │ │ ├── legendary.go │ │ └── legendary_all.go │ │ ├── lutris │ │ ├── lutris_linux.go │ │ └── lutris_noop.go │ │ ├── registry.go │ │ ├── steam │ │ ├── steam.go │ │ ├── steam_flatpak_linux.go │ │ ├── steam_native_unix.go │ │ ├── steam_snap_linux.go │ │ ├── steam_windows.go │ │ └── steam_wine_darwin.go │ │ └── whisky │ │ ├── types_darwin.go │ │ ├── whisky_darwin.go │ │ └── whisky_noop.go ├── logging │ ├── logging.go │ └── redaction.go ├── migration │ └── migration.go ├── settings │ ├── settings.go │ └── smm2settings.go ├── utils │ ├── display.go │ ├── events.go │ ├── json.go │ ├── os.go │ ├── paths.go │ ├── progress.go │ ├── restart.go │ ├── sizes.go │ └── zip.go ├── wails_logger.go ├── wailsextras │ ├── WailsContext.h │ ├── patch_user_agent.go │ ├── patch_user_agent_darwin.go │ ├── patch_user_agent_linux.go │ ├── patch_user_agent_noop.go │ ├── patch_user_agent_windows.go │ └── window.go └── websocket │ └── websocket.go ├── build ├── README.md ├── appicon.png ├── darwin │ ├── Info.dev.plist │ └── Info.plist ├── icons │ ├── 128x128.png │ ├── 16x16.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 512x512.png │ └── 64x64.png ├── linux │ ├── SatisfactoryModManager.desktop │ └── appimage.sh ├── smm2 │ ├── latest-linux.yml.tmpl │ └── latest.yml.tmpl ├── smmprofile.png └── windows │ ├── .gitignore │ ├── icon.ico │ ├── icons │ └── smmprofile.ico │ ├── installer │ ├── .gitignore │ ├── NsisMultiUser │ │ ├── .gitignore │ │ ├── Include │ │ │ ├── NsisMultiUser.nsh │ │ │ ├── NsisMultiUserLang.nsh │ │ │ ├── StdUtils.nsh │ │ │ └── UAC.nsh │ │ ├── License.txt │ │ ├── Plugins │ │ │ ├── x86-ansi │ │ │ │ ├── StdUtils.dll │ │ │ │ └── UAC.dll │ │ │ └── x86-unicode │ │ │ │ ├── StdUtils.dll │ │ │ │ └── UAC.dll │ │ └── README.md │ ├── checkRunning.nsh │ ├── project.nsi │ ├── smm2.nsh │ └── utils.nsh │ ├── installer_version.go │ ├── smmprofile.ico │ └── wails.exe.manifest ├── cspell.json ├── docs └── images │ └── preview.gif ├── frontend ├── .env ├── .eslintrc.cjs ├── .gitignore ├── .graphqlrc.yml ├── .npmrc ├── .tolgeerc.json ├── .vscode │ └── settings.json ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── smmTheme.ts ├── src │ ├── App.svelte │ ├── _global.postcss │ ├── gql │ │ ├── announcements │ │ │ └── getAnnouncements.graphql │ │ ├── healthcheck │ │ │ └── healthcheck.graphql │ │ ├── mods │ │ │ ├── modCount.graphql │ │ │ ├── modDetails.graphql │ │ │ ├── modKeyFragment.graphql │ │ │ ├── modName.graphql │ │ │ ├── modNames.graphql │ │ │ ├── modReference.graphql │ │ │ ├── modSummary.graphql │ │ │ └── mods.graphql │ │ ├── update │ │ │ └── getChangelog.graphql │ │ └── versionCompatibility │ │ │ ├── getModReportedCompatibility.graphql │ │ │ └── getModVersionsCompatibility.graphql │ ├── lib │ │ ├── components │ │ │ ├── Markdown.svelte │ │ │ ├── Marquee.svelte │ │ │ ├── RemoteServerPicker.svelte │ │ │ ├── ResponsiveButton.svelte │ │ │ ├── SVGIcon.svelte │ │ │ ├── Select.svelte │ │ │ ├── T.svelte │ │ │ ├── Thumbhash.svelte │ │ │ ├── TitleBar.svelte │ │ │ ├── Tooltip.svelte │ │ │ ├── VirtualList.svelte │ │ │ ├── announcements │ │ │ │ ├── Announcement.svelte │ │ │ │ └── AnnouncementsBar.svelte │ │ │ ├── left-bar │ │ │ │ ├── LaunchButton.svelte │ │ │ │ ├── LeftBar.svelte │ │ │ │ ├── Settings.svelte │ │ │ │ └── Updates.svelte │ │ │ ├── mod-details │ │ │ │ ├── ModDetails.svelte │ │ │ │ └── ModDetailsEntry.svelte │ │ │ ├── modals │ │ │ │ ├── ErrorDetails.svelte │ │ │ │ ├── ErrorModal.svelte │ │ │ │ ├── ExternalInstallMod.svelte │ │ │ │ ├── MigrationModal.svelte │ │ │ │ ├── ModChangelog.svelte │ │ │ │ ├── ModImage.svelte │ │ │ │ ├── ProgressModal.svelte │ │ │ │ ├── ServerManager.svelte │ │ │ │ ├── first-time-setup │ │ │ │ │ ├── FirstTimeSetupModal.svelte │ │ │ │ │ └── LanguageSelector.svelte │ │ │ │ ├── modalsRegistry.ts │ │ │ │ ├── profiles │ │ │ │ │ ├── AddProfile.svelte │ │ │ │ │ ├── DeleteProfile.svelte │ │ │ │ │ ├── ImportProfile.svelte │ │ │ │ │ ├── RenameProfile.svelte │ │ │ │ │ ├── addProfile.ts │ │ │ │ │ ├── importProfile.ts │ │ │ │ │ └── renameProfile.ts │ │ │ │ ├── settings │ │ │ │ │ ├── CacheLocationPicker.svelte │ │ │ │ │ ├── Proxy.svelte │ │ │ │ │ └── cacheLocationPicker.ts │ │ │ │ ├── smmUpdate │ │ │ │ │ ├── SMMUpdateDownload.svelte │ │ │ │ │ ├── SMMUpdateReady.svelte │ │ │ │ │ └── smmUpdate.ts │ │ │ │ └── updates │ │ │ │ │ ├── UpdatesModal.svelte │ │ │ │ │ └── updatesStore.ts │ │ │ └── mods-list │ │ │ │ ├── ModsList.svelte │ │ │ │ ├── ModsListFilters.svelte │ │ │ │ └── ModsListItem.svelte │ │ ├── core │ │ │ ├── graphql.ts │ │ │ └── index.ts │ │ ├── generated │ │ │ ├── .gitignore │ │ │ ├── graphql │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── localization │ │ │ └── index.ts │ │ ├── skeletonExtensions │ │ │ ├── index.ts │ │ │ ├── modal.ts │ │ │ └── popup.ts │ │ ├── store │ │ │ ├── actionQueue.ts │ │ │ ├── ficsitCLIStore.ts │ │ │ ├── generalStore.ts │ │ │ ├── modFiltersStore.ts │ │ │ ├── settingsStore.ts │ │ │ ├── smmUpdateStore.ts │ │ │ └── wailsStoreBindings.ts │ │ ├── utils │ │ │ ├── dataFormats.ts │ │ │ ├── getModAuthor.ts │ │ │ ├── interval.ts │ │ │ ├── markdown.ts │ │ │ ├── modCompatibility.ts │ │ │ ├── progress.ts │ │ │ └── timeSeries.ts │ │ └── wailsTypesExtensions.ts │ ├── main.ts │ └── types │ │ └── svelte-carousel.d.ts ├── static │ └── images │ │ ├── launch │ │ ├── cat │ │ │ ├── bg.png │ │ │ ├── cat.png │ │ │ ├── cat_full.png │ │ │ └── sec.png │ │ └── fun │ │ │ ├── launch_fun.png │ │ │ ├── launch_fun_button_normal.png │ │ │ ├── launch_fun_button_over.png │ │ │ └── launch_fun_button_press.png │ │ ├── no_image.webp │ │ ├── smm_icon.png │ │ └── smm_icon_small.png ├── svelte.config.js ├── tailwind.config.ts ├── tools │ └── translations.cjs ├── tsconfig.json └── vite.config.ts ├── go.mod ├── go.sum ├── icons ├── 128x128.png ├── 16x16.png ├── 256x256.png ├── 32x32.png ├── 512x512.png ├── 64x64.png └── icon.ico ├── main.go ├── tools.go └── wails.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Linters require Unix-style line endings 2 | * text=auto eol=lf 3 | # Exclude vendored nsis library from stats 4 | build/windows/installer/NsisMultiUser/** linguist-vendored -------------------------------------------------------------------------------- /.github/actions/goreleaser/action.yml: -------------------------------------------------------------------------------- 1 | name: 'goreleaser' 2 | description: 'Download and run goreleaser' 3 | inputs: 4 | args: 5 | description: 'goreleaser arguments' 6 | required: true 7 | runs: 8 | using: 'composite' 9 | steps: 10 | - name: Download goreleaser 11 | if: runner.os == 'Windows' 12 | run: | 13 | gh release download --repo mircearoata/goreleaser --pattern "goreleaser-windows.exe" --output "$(go env GOPATH)/bin/goreleaser.exe" 14 | shell: powershell 15 | env: 16 | GH_TOKEN: ${{ github.token }} 17 | 18 | - name: Download goreleaser 19 | if: runner.os == 'Linux' 20 | run: | 21 | gh release download --repo mircearoata/goreleaser --pattern "goreleaser-linux" --output "$(go env GOPATH)/bin/goreleaser" 22 | chmod +x $(go env GOPATH)/bin/goreleaser 23 | shell: bash 24 | env: 25 | GH_TOKEN: ${{ github.token }} 26 | 27 | - name: Download goreleaser 28 | if: runner.os == 'macOS' 29 | shell: bash 30 | run: | 31 | gh release download --repo mircearoata/goreleaser --pattern "goreleaser-darwin" --output "$(go env GOPATH)/bin/goreleaser" 32 | chmod +x $(go env GOPATH)/bin/goreleaser 33 | env: 34 | GH_TOKEN: ${{ github.token }} 35 | 36 | - name: Run GoReleaser ${{ inputs.goreleaser_args }} 37 | shell: bash 38 | run: goreleaser ${{ inputs.args }} 39 | 40 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: 'setup' 2 | description: 'Setup project dependencies' 3 | inputs: 4 | backend: 5 | description: 'Whether to setup backend dependencies' 6 | required: false 7 | default: 'true' 8 | frontend: 9 | description: 'Whether to setup frontend dependencies' 10 | required: false 11 | default: 'true' 12 | skip-wails: 13 | description: 'Whether to skip Wails installation' 14 | required: false 15 | default: 'false' 16 | runs: 17 | using: 'composite' 18 | steps: 19 | - uses: actions/setup-go@v5 20 | with: 21 | go-version: 1.22 22 | 23 | - uses: pnpm/action-setup@v3 24 | if: ${{ inputs.frontend == 'true' }} 25 | with: 26 | version: 8.6.1 27 | 28 | - uses: actions/setup-node@v4 29 | if: ${{ inputs.frontend == 'true' }} 30 | with: 31 | node-version: 18 32 | cache: 'pnpm' 33 | cache-dependency-path: frontend 34 | 35 | - name: Install Wails 36 | if: ${{ inputs.backend == 'true' && inputs.skip-wails != 'true' }} 37 | shell: bash 38 | run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.9.2 39 | 40 | # Any go operation on linux will require these 41 | - name: Install additional dependencies 42 | if: ${{ runner.os == 'Linux' && inputs.backend == 'true' }} 43 | shell: bash 44 | run: | 45 | sudo apt update 46 | sudo apt -y install libgtk-3-dev libwebkit2gtk-4.1-dev patchelf 47 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Push 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | uses: ./.github/workflows/build-base.yml 12 | secrets: 13 | SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} 14 | 15 | lint-backend: 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: [ubuntu-latest, windows-latest, macos-latest] 20 | runs-on: ${{ matrix.os }} 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - uses: ./.github/actions/setup 25 | with: 26 | frontend: false 27 | skip-wails: true 28 | 29 | - name: Create frontend/build embed directory 30 | run: | 31 | mkdir -p frontend/build 32 | echo "" > frontend/build/.gitkeep 33 | 34 | - uses: golangci/golangci-lint-action@v4 35 | with: 36 | version: v1.61 37 | only-new-issues: true 38 | skip-pkg-cache: true 39 | skip-build-cache: true 40 | 41 | lint-frontend: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v4 45 | 46 | - uses: ./.github/actions/setup 47 | 48 | - name: Generate wails bindings 49 | run: | 50 | # Apparently only wails build generates the embedded directory 51 | mkdir -p frontend/build 52 | touch frontend/build/.gitkeep 53 | wails generate module -tags webkit2_41 54 | 55 | - name: Install dependencies 56 | working-directory: frontend 57 | run: pnpm install 58 | 59 | - name: Lint 60 | working-directory: frontend 61 | run: | 62 | pnpm lint 63 | pnpm check 64 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | uses: ./.github/workflows/build-base.yml 11 | with: 12 | goreleaser_args: '' 13 | secrets: 14 | SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} 15 | -------------------------------------------------------------------------------- /.github/workflows/translations.yml: -------------------------------------------------------------------------------- 1 | name: Update translations 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | update-translations: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: ./.github/actions/setup 15 | with: 16 | backend: false 17 | 18 | - name: Install dependencies 19 | working-directory: frontend 20 | run: pnpm install 21 | 22 | - name: Update translation keys 23 | working-directory: frontend 24 | run: | 25 | pnpm translations:compare -ak ${{ secrets.TOLGEE_KEY }} 26 | pnpm translations:sync -ak ${{ secrets.TOLGEE_KEY }} -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | wrapcheck: 3 | ignorePackageGlobs: 4 | - github.com/satisfactorymodding/SatisfactoryModManager/* 5 | 6 | govet: 7 | enable-all: true 8 | disable: 9 | - shadow 10 | - fieldalignment 11 | 12 | gocritic: 13 | disabled-checks: 14 | - ifElseChain 15 | 16 | gci: 17 | custom-order: true 18 | sections: 19 | - standard 20 | - default 21 | - prefix(github.com/satisfactorymodding/SatisfactoryModManager) 22 | - blank 23 | - dot 24 | 25 | issues: 26 | exclude-rules: 27 | - linters: 28 | - typecheck 29 | text: "no matching files found" 30 | exclude-dirs: 31 | - frontend 32 | 33 | run: 34 | timeout: 5m 35 | build-tags: 36 | - webkit2_41 37 | 38 | linters: 39 | disable-all: true 40 | enable: 41 | - errcheck 42 | - gosimple 43 | - govet 44 | - ineffassign 45 | - staticcheck 46 | - typecheck 47 | - unused 48 | - bidichk 49 | - contextcheck 50 | - durationcheck 51 | - errorlint 52 | - goconst 53 | - goimports 54 | - revive 55 | - misspell 56 | - prealloc 57 | - whitespace 58 | - wrapcheck 59 | - gci 60 | - gocritic 61 | - gofumpt 62 | - nonamedreturns 63 | -------------------------------------------------------------------------------- /.vscode/SatisfactoryModManager.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | // Needed for vscode ESLint to work 3 | // https://stackoverflow.com/questions/47405315/visual-studio-code-and-subfolder-specific-settings 4 | "folders": [ 5 | { 6 | "name": "root", 7 | "path": "../." 8 | }, 9 | { 10 | "path": "../frontend" 11 | }, 12 | { 13 | "path": "../backend" 14 | } 15 | ], 16 | "settings": { 17 | "files.exclude": { 18 | "**/.git": true, 19 | "backend": true, 20 | "frontend": true 21 | }, 22 | }, 23 | "extensions": { 24 | "recommendations": [ 25 | "svelte.svelte-vscode", 26 | "streetsidesoftware.code-spell-checker", 27 | "dbaeumer.vscode-eslint", 28 | "davidanson.vscode-markdownlint", 29 | "herrmannplatz.npm-dependency-links", 30 | "medo64.render-crlf", 31 | "redhat.vscode-yaml", 32 | "golang.go" 33 | ] 34 | } 35 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Launch Wails Development Server", 8 | "type": "shell", 9 | "command": "wails dev", 10 | "problemMatcher": [] 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://taskfile.dev/schema.json 2 | --- 3 | version: 3 4 | 5 | tasks: 6 | build: 7 | silent: true 8 | vars: 9 | OUTPUT_FULL_PATH: '{{ $args := (splitArgs .CLI_ARGS) }}{{ range $i, $a := $args }}{{ if eq $a "-o" }}{{ index $args (add $i 1) }}{{ end }}{{ end }}' 10 | OUTPUT_FILENAME: '{{ if ne .OUTPUT_FULL_PATH "" }}{{ base (toSlash .OUTPUT_FULL_PATH) }}{{ end }}' 11 | WAILS_ARGS: ' 12 | {{ $rawArgs := (splitArgs .CLI_ARGS) }} 13 | {{ $args := without $rawArgs "." "-o" .OUTPUT_FULL_PATH }} 14 | {{ $GOOS := (env "GOOS") }} 15 | {{ $GOARCH := (env "GOARCH") }} 16 | {{ range $args }} 17 | {{ shellQuote . }} 18 | {{ end }} 19 | {{ if ne $GOOS "" }}-platform {{ $GOOS }}{{ if ne $GOARCH "" }}/{{ $GOARCH }}{{ end }}{{ end }} 20 | {{ if ne .OUTPUT_FILENAME "" }}-o {{ .OUTPUT_FILENAME }}{{ end }} 21 | ' 22 | cmds: 23 | - GOOS="" GOARCH="" wails build {{.WAILS_ARGS}} 24 | - platforms: 25 | - darwin 26 | cmd: | 27 | {{ if ne .OUTPUT_FILENAME "" }} 28 | pushd "build/bin" || exit 1 29 | codesign --force --deep -s - "{{.OUTPUT_FILENAME}}.app" # If a universal binary is generated, the signature is lost 30 | zip -r "{{.OUTPUT_FILENAME}}.zip" "{{.OUTPUT_FILENAME}}.app" # zip would add the .zip extension anyway 31 | mv "{{.OUTPUT_FILENAME}}.zip" "{{.OUTPUT_FILENAME}}" 32 | popd || exit 1 33 | {{ end }} 34 | - cmd: | 35 | {{ if ne .OUTPUT_FILENAME "" }} 36 | mkdir -p "{{ osDir .OUTPUT_FULL_PATH }}" 37 | cp -r "build/bin/{{.OUTPUT_FILENAME}}" "{{.OUTPUT_FULL_PATH}}" 38 | {{ end }} 39 | 40 | build:linux:appimage: 41 | silent: true 42 | platforms: [linux] 43 | vars: 44 | OUTPUT_FULL_PATH: '{{ $args := (splitArgs .CLI_ARGS) }}{{ range $i, $a := $args }}{{ if eq $a "-o" }}{{ index $args (add $i 1) }}{{ end }}{{ end }}' 45 | OUTPUT_FILENAME: '{{ if ne .OUTPUT_FULL_PATH "" }}{{ base (toSlash .OUTPUT_FULL_PATH) }}{{ end }}' 46 | WAILS_ARGS: ' 47 | {{ $rawArgs := (splitArgs .CLI_ARGS) }} 48 | {{ $args := without $rawArgs "." "-o" .OUTPUT_FULL_PATH }} 49 | {{ $GOOS := (env "GOOS") }} 50 | {{ $GOARCH := (env "GOARCH") }} 51 | {{ range $args }} 52 | {{ shellQuote . }} 53 | {{ end }} 54 | {{ if ne $GOOS "" }}-platform {{ $GOOS }}{{ if ne $GOARCH "" }}/{{ $GOARCH }}{{ end }}{{ end }} 55 | {{ if ne .OUTPUT_FILENAME "" }}-o {{ .OUTPUT_FILENAME }}{{ end }} 56 | ' 57 | cmds: 58 | - GOOS="" GOARCH="" wails build {{.WAILS_ARGS}} 59 | - chmod +x "build/bin/{{.OUTPUT_FILENAME}}" 60 | - mv "build/bin/{{.OUTPUT_FILENAME}}" "build/bin/appimage-bin-tmp" 61 | - chmod +x "build/linux/appimage.sh" # Make sure the script is executable 62 | - build/linux/appimage.sh "build/bin/appimage-bin-tmp" "{{.OUTPUT_FULL_PATH}}" 63 | - rm "build/bin/appimage-bin-tmp" 64 | 65 | lint: 66 | silent: true 67 | preconditions: 68 | - golangci-lint --version | grep -q 1.61 69 | cmds: 70 | - GOOS=windows golangci-lint run 71 | - GOOS=linux golangci-lint run 72 | - GOOS=darwin golangci-lint run -------------------------------------------------------------------------------- /backend/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" 5 | 6 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/common" 7 | ) 8 | 9 | type app struct { 10 | IsExpanded bool 11 | 12 | Restart bool 13 | 14 | stopSizeWatcher chan bool 15 | } 16 | 17 | var App = &app{} 18 | 19 | func (a *app) CloseAndRestart() { 20 | a.Restart = true 21 | wailsRuntime.Quit(common.AppContext) 22 | } 23 | -------------------------------------------------------------------------------- /backend/app/info.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/spf13/viper" 7 | ) 8 | 9 | func (a *app) GetVersion() string { 10 | return viper.GetString("version") 11 | } 12 | 13 | func (a *app) GetCommit() string { 14 | return viper.GetString("commit") 15 | } 16 | 17 | func (a *app) GetDate() string { 18 | return viper.GetString("date") 19 | } 20 | 21 | func (a *app) GetAPIEndpoint() string { 22 | return viper.GetString("api-base") + viper.GetString("graphql-api") 23 | } 24 | 25 | func (a *app) GetSiteEndpoint() string { 26 | return strings.Replace(viper.GetString("api-base"), "api.", "", 1) 27 | } 28 | -------------------------------------------------------------------------------- /backend/args.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/app" 10 | ) 11 | 12 | func ProcessArguments(args []string) { 13 | if len(args) < 1 { 14 | return 15 | } 16 | if strings.HasPrefix(args[0], "smmanager://") { 17 | uri := args[0] 18 | err := handleURI(uri) 19 | if err != nil { 20 | slog.Error("failed to handle smmanager:// URI", slog.Any("error", err), slog.String("uri", uri)) 21 | } 22 | } else { 23 | err := handleFile(args[0]) 24 | if err != nil { 25 | slog.Error("failed to handle file", slog.Any("error", err), slog.String("path", args[0])) 26 | } 27 | } 28 | app.App.Show() 29 | } 30 | 31 | func handleURI(uri string) error { 32 | u, err := url.Parse(uri) 33 | if err != nil { 34 | return fmt.Errorf("failed to parse URI: %w", err) 35 | } 36 | switch u.Host { 37 | case "install": 38 | modID := u.Query().Get("modID") 39 | version := u.Query().Get("version") 40 | app.App.ExternalInstallMod(modID, version) 41 | return nil 42 | default: 43 | return fmt.Errorf("unknown URI action %s", u.Host) 44 | } 45 | } 46 | 47 | func handleFile(path string) error { 48 | if strings.HasSuffix(path, ".smmprofile") { 49 | println(path) 50 | app.App.ExternalImportProfile(path) 51 | return nil 52 | } 53 | return fmt.Errorf("unknown file type %s", path) 54 | } 55 | -------------------------------------------------------------------------------- /backend/autoupdate/apply/appimage.go: -------------------------------------------------------------------------------- 1 | package apply 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "github.com/minio/selfupdate" 9 | 10 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/utils" 11 | ) 12 | 13 | type AppImageApply struct{} 14 | 15 | func MakeAppImageApply() *AppImageApply { 16 | return &AppImageApply{} 17 | } 18 | 19 | func (a *AppImageApply) Download(file io.Reader, checksum []byte) error { 20 | appimagePath := os.Getenv("APPIMAGE") 21 | if appimagePath == "" { 22 | return fmt.Errorf("APPIMAGE environment variable not set, executable not an appimage") 23 | } 24 | 25 | err := selfupdate.PrepareAndCheckBinary(file, selfupdate.Options{ 26 | Checksum: checksum, 27 | TargetPath: appimagePath, 28 | }) 29 | if err != nil { 30 | return fmt.Errorf("failed to download AppImage update: %w", err) 31 | } 32 | return nil 33 | } 34 | 35 | func (a *AppImageApply) Apply(restart bool) error { 36 | appimagePath := os.Getenv("APPIMAGE") 37 | if appimagePath == "" { 38 | return fmt.Errorf("APPIMAGE environment variable not set, executable not an appimage") 39 | } 40 | 41 | err := selfupdate.CommitBinary(selfupdate.Options{ 42 | TargetPath: appimagePath, 43 | }) 44 | if err != nil { 45 | return fmt.Errorf("failed to commit AppImage update: %w", err) 46 | } 47 | if restart { 48 | err := utils.Restart() 49 | if err != nil { 50 | return fmt.Errorf("failed to relaunch after update: %w", err) 51 | } 52 | } 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /backend/autoupdate/apply/nsis.go: -------------------------------------------------------------------------------- 1 | package apply 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | 10 | "github.com/minio/selfupdate" 11 | ) 12 | 13 | type NsisApply struct { 14 | config NsisApplyConfig 15 | } 16 | 17 | type NsisApplyConfig struct { 18 | InstallerDownloadPath string 19 | IsAllUsers bool 20 | } 21 | 22 | func MakeNsisApply(config NsisApplyConfig) *NsisApply { 23 | return &NsisApply{ 24 | config: config, 25 | } 26 | } 27 | 28 | func (a *NsisApply) Download(file io.Reader, checksum []byte) error { 29 | // Use selfupdate to download and verify the update 30 | err := selfupdate.PrepareAndCheckBinary(file, selfupdate.Options{ 31 | TargetPath: a.config.InstallerDownloadPath, 32 | Checksum: checksum, 33 | }) 34 | if err != nil { 35 | return fmt.Errorf("failed to download nsis update: %w", err) 36 | } 37 | 38 | // Variables as used by selfupdate 39 | updateDir := filepath.Dir(a.config.InstallerDownloadPath) 40 | filename := filepath.Base(a.config.InstallerDownloadPath) 41 | newPath := filepath.Join(updateDir, fmt.Sprintf(".%s.new", filename)) 42 | 43 | // Ensure that the installer is actually at the expected path 44 | err = os.Rename(newPath, a.config.InstallerDownloadPath) 45 | if err != nil { 46 | return fmt.Errorf("failed to rename nsis update: %w", err) 47 | } 48 | return nil 49 | } 50 | 51 | func (a *NsisApply) Apply(restart bool) error { 52 | arguments := []string{"/S"} 53 | if a.config.IsAllUsers { 54 | arguments = append(arguments, "/AllUsers") 55 | } else { 56 | arguments = append(arguments, "/CurrentUser") 57 | } 58 | if restart { 59 | arguments = append(arguments, "/ForceRun") 60 | } 61 | cmd := exec.Command(a.config.InstallerDownloadPath, arguments...) 62 | err := cmd.Start() 63 | if err != nil { 64 | return fmt.Errorf("failed to start nsis installer: %w", err) 65 | } 66 | err = cmd.Process.Release() 67 | if err != nil { 68 | return fmt.Errorf("failed to release nsis installer process: %w", err) 69 | } 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /backend/autoupdate/apply/singlefile.go: -------------------------------------------------------------------------------- 1 | package apply 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/minio/selfupdate" 8 | 9 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/utils" 10 | ) 11 | 12 | type SingleFileApply struct{} 13 | 14 | func MakeSingleFileApply() *SingleFileApply { 15 | return &SingleFileApply{} 16 | } 17 | 18 | func (a *SingleFileApply) Download(file io.Reader, checksum []byte) error { 19 | err := selfupdate.PrepareAndCheckBinary(file, selfupdate.Options{ 20 | Checksum: checksum, 21 | }) 22 | if err != nil { 23 | return fmt.Errorf("failed to download singlefile update: %w", err) 24 | } 25 | return nil 26 | } 27 | 28 | func (a *SingleFileApply) Apply(restart bool) error { 29 | err := selfupdate.CommitBinary(selfupdate.Options{}) 30 | if err != nil { 31 | return fmt.Errorf("failed to commit singlefile update: %w", err) 32 | } 33 | if restart { 34 | err := utils.Restart() 35 | if err != nil { 36 | return fmt.Errorf("failed to relaunch after update: %w", err) 37 | } 38 | } 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /backend/autoupdate/checksum/goreleaser/goreleaser.go: -------------------------------------------------------------------------------- 1 | package goreleaser 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "strings" 9 | "text/template" 10 | 11 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/autoupdate/updater" 12 | ) 13 | 14 | type checksumSource struct { 15 | checksumArtifactTemplate *template.Template 16 | split bool 17 | } 18 | 19 | func MakeGoreleaserChecksumSource(checksumArtifactFormat string, split bool) updater.ChecksumSource { 20 | return &checksumSource{ 21 | checksumArtifactTemplate: template.Must(template.New("checksumArtifact").Parse(checksumArtifactFormat)), 22 | split: split, 23 | } 24 | } 25 | 26 | func (g *checksumSource) GetChecksumForFile(source updater.Source, version string, filename string) ([]byte, error) { 27 | var checksumFilenameBuilder strings.Builder 28 | err := g.checksumArtifactTemplate.Execute(&checksumFilenameBuilder, map[string]string{"ArtifactName": filename, "Version": version}) 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to build checksum filename: %w", err) 31 | } 32 | checksumFilename := checksumFilenameBuilder.String() 33 | chesumFile, _, err := source.GetFile(version, checksumFilename) 34 | if err != nil { 35 | return nil, fmt.Errorf("failed to get checksum file: %w", err) 36 | } 37 | defer chesumFile.Close() 38 | checksum, err := io.ReadAll(chesumFile) 39 | if err != nil { 40 | return nil, fmt.Errorf("failed to read checksum: %w", err) 41 | } 42 | 43 | if g.split { 44 | // Checksum file will only contain one hex string 45 | sum, err := hex.DecodeString(strings.TrimSpace(string(checksum))) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to decode checksum: %w", err) 48 | } 49 | return sum, nil 50 | } 51 | 52 | // Checksum file will contain multiple lines of {artifact} {hash} 53 | checksums := parseChecksumFile(checksum) 54 | if sum, ok := checksums[filename]; ok { 55 | return sum, nil 56 | } 57 | return nil, fmt.Errorf("failed to find checksum for file") 58 | } 59 | 60 | func parseChecksumFile(checksumFile []byte) map[string][]byte { 61 | checksums := make(map[string][]byte) 62 | lines := strings.Split(string(checksumFile), "\n") 63 | for _, line := range lines { 64 | if len(line) == 0 { 65 | // Skip empty lines 66 | continue 67 | } 68 | parts := strings.Split(line, " ") 69 | if len(parts) != 2 { 70 | slog.Debug("invalid checksum entry", slog.String("entry", line)) 71 | continue 72 | } 73 | hexSum := parts[0] 74 | filename := parts[1] 75 | sum, err := hex.DecodeString(hexSum) 76 | if err != nil { 77 | slog.Debug("failed to decode checksum", slog.String("checksum", hexSum), slog.String("filename", filename), slog.Any("error", err)) 78 | continue 79 | } 80 | checksums[parts[1]] = sum 81 | } 82 | return checksums 83 | } 84 | -------------------------------------------------------------------------------- /backend/autoupdate/update_mode.go: -------------------------------------------------------------------------------- 1 | package autoupdate 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | 6 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/autoupdate/updater" 7 | ) 8 | 9 | type UpdateType struct { 10 | ArtifactName string 11 | Apply updater.Apply 12 | } 13 | 14 | var updateTypes = map[string]func() UpdateType{} 15 | 16 | func registerUpdateType(updateMode string, updateType func() UpdateType) { 17 | updateTypes[updateMode] = updateType 18 | } 19 | 20 | func shouldUseUpdater() bool { 21 | return viper.Get("update-mode") != "none" 22 | } 23 | 24 | func getUpdateType() *UpdateType { 25 | getter := updateTypes[viper.GetString("update-mode")] 26 | if getter == nil { 27 | return nil 28 | } 29 | updateType := getter() 30 | return &updateType 31 | } 32 | -------------------------------------------------------------------------------- /backend/autoupdate/update_mode_darwin.go: -------------------------------------------------------------------------------- 1 | package autoupdate 2 | 3 | import ( 4 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/autoupdate/apply" 5 | ) 6 | 7 | func init() { 8 | registerUpdateType("standalone", func() UpdateType { 9 | return UpdateType{ 10 | ArtifactName: "SatisfactoryModManager_darwin_universal.zip", 11 | Apply: apply.MakeDarwinAppApply(apply.DarwinApplyConfig{ 12 | AppName: "SatisfactoryModManager", 13 | }), 14 | } 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /backend/autoupdate/update_mode_linux.go: -------------------------------------------------------------------------------- 1 | package autoupdate 2 | 3 | import ( 4 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/autoupdate/apply" 5 | ) 6 | 7 | func init() { 8 | registerUpdateType("standalone", func() UpdateType { 9 | return UpdateType{ 10 | ArtifactName: "SatisfactoryModManager_linux_amd64", 11 | Apply: apply.MakeSingleFileApply(), 12 | } 13 | }) 14 | registerUpdateType("appimage", func() UpdateType { 15 | return UpdateType{ 16 | ArtifactName: "SatisfactoryModManager_linux_amd64.AppImage", 17 | Apply: apply.MakeAppImageApply(), 18 | } 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /backend/autoupdate/update_mode_windows.go: -------------------------------------------------------------------------------- 1 | package autoupdate 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/spf13/viper" 8 | "golang.org/x/sys/windows/registry" 9 | 10 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/autoupdate/apply" 11 | ) 12 | 13 | func init() { 14 | // Currently there is no standalone release for Windows 15 | // registerUpdateType("standalone", func() UpdateType { 16 | // return UpdateType{ 17 | // ArtifactName: "SatisfactoryModManager.exe", 18 | // Apply: apply.MakeSingleFileApply(), 19 | // } 20 | // }) 21 | registerUpdateType("nsis", func() UpdateType { 22 | return UpdateType{ 23 | ArtifactName: "SatisfactoryModManager-Setup.exe", 24 | Apply: apply.MakeNsisApply(apply.NsisApplyConfig{ 25 | InstallerDownloadPath: filepath.Join(viper.GetString("smm-cache-dir"), "SatisfactoryModManager-Setup.exe"), 26 | IsAllUsers: isAllUsers(), 27 | }), 28 | } 29 | }) 30 | } 31 | 32 | func isAllUsers() bool { 33 | executable, _ := os.Executable() 34 | allUsersInstallPath := getInstallPath(registry.LOCAL_MACHINE) 35 | currentUserInstallPath := getInstallPath(registry.CURRENT_USER) 36 | if allUsersInstallPath != "" && currentUserInstallPath != "" { 37 | // Installed in both modes, so we need to check if the currently running executable is all-users or per-user 38 | return allUsersInstallPath == filepath.Dir(executable) 39 | } 40 | if allUsersInstallPath == "" && currentUserInstallPath == "" { 41 | // This should never happen, but since we don't know if the user has admin rights, we will default to per-user 42 | return false 43 | } 44 | return allUsersInstallPath != "" 45 | } 46 | 47 | func getInstallPath(key registry.Key) string { 48 | k, err := registry.OpenKey(key, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Satisfactory Mod Manager`, registry.READ) 49 | if err != nil { 50 | return "" 51 | } 52 | defer k.Close() 53 | installPath, _, err := k.GetStringValue("InstallLocation") 54 | if err != nil { 55 | return "" 56 | } 57 | return installPath 58 | } 59 | -------------------------------------------------------------------------------- /backend/autoupdate/updater/check.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/Masterminds/semver/v3" 8 | ) 9 | 10 | func (u *Updater) CheckForUpdate() error { 11 | if u.PendingUpdate != nil { 12 | u.UpdateFound.Dispatch(*u.PendingUpdate) 13 | if u.PendingUpdate.Ready { 14 | u.UpdateReady.Dispatch(nil) 15 | } 16 | } 17 | 18 | u.lock.Lock() 19 | defer u.lock.Unlock() 20 | 21 | latestVersion, err := u.config.Source.GetLatestVersion(u.config.IncludePrerelease) 22 | if err != nil { 23 | return fmt.Errorf("failed to get latest version: %w", err) 24 | } 25 | 26 | latestSemver, err := semver.NewVersion(latestVersion) 27 | if err != nil { 28 | return fmt.Errorf("failed to parse latest version %s: %w", latestVersion, err) 29 | } 30 | 31 | if u.PendingUpdate != nil && u.PendingUpdate.Version != nil && u.PendingUpdate.Ready { 32 | if !latestSemver.GreaterThan(u.PendingUpdate.Version) { 33 | return nil 34 | } 35 | } else { 36 | if !latestSemver.GreaterThan(u.config.CurrentVersion) { 37 | return nil 38 | } 39 | } 40 | 41 | changelogs, err := u.config.Source.GetChangelogs() 42 | if err != nil { 43 | return fmt.Errorf("failed to get changelogs: %w", err) 44 | } 45 | 46 | newChangelogs := make(map[string]string) 47 | for version, changelog := range changelogs { 48 | changelogSemver, err := semver.NewVersion(version) 49 | if err != nil { 50 | return fmt.Errorf("failed to parse version: %w", err) 51 | } 52 | if changelogSemver.GreaterThan(u.config.CurrentVersion) && changelogSemver.Compare(latestSemver) <= 0 { 53 | newChangelogs[version] = changelog 54 | } 55 | } 56 | 57 | u.PendingUpdate = &PendingUpdate{ 58 | Version: latestSemver, 59 | Changelogs: newChangelogs, 60 | Ready: false, 61 | } 62 | u.UpdateFound.Dispatch(*u.PendingUpdate) 63 | 64 | if u.config.File == "" || u.config.Apply == nil { 65 | slog.Debug("no update file or apply method specified, not downloading update") 66 | return nil 67 | } 68 | 69 | file, length, err := u.config.Source.GetFile(latestVersion, u.config.File) 70 | if err != nil { 71 | return fmt.Errorf("failed to get file %s of version %s: %w", u.config.File, latestVersion, err) 72 | } 73 | defer file.Close() 74 | 75 | var checksum []byte 76 | if u.config.Checksum != nil { 77 | checksum, err = u.config.Checksum.GetChecksumForFile(u.config.Source, latestVersion, u.config.File) 78 | if err != nil { 79 | return fmt.Errorf("failed to get checksum for file %s of version %s: %w", u.config.File, latestVersion, err) 80 | } 81 | } 82 | 83 | progress := func(bytesDownloaded, bytesTotal int64) { 84 | u.DownloadProgress.Dispatch(UpdateDownloadProgress{ 85 | BytesDownloaded: bytesDownloaded, 86 | BytesTotal: bytesTotal, 87 | }) 88 | } 89 | p := &progressReader{Reader: file, progressCallback: progress, contentLength: length} 90 | 91 | err = u.config.Apply.Download(p, checksum) 92 | if err != nil { 93 | return fmt.Errorf("failed to apply update: %w", err) 94 | } 95 | u.PendingUpdate.Ready = true 96 | u.UpdateReady.Dispatch(nil) 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /backend/autoupdate/updater/progress.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | type progressReader struct { 8 | io.Reader 9 | progressCallback func(int64, int64) 10 | contentLength int64 11 | downloaded int64 12 | } 13 | 14 | func (pr *progressReader) Read(p []byte) (int, error) { 15 | n, err := pr.Reader.Read(p) 16 | pr.downloaded += int64(n) 17 | 18 | if err != io.EOF { 19 | if pr.contentLength > 0 { 20 | pr.progressCallback(pr.downloaded, pr.contentLength) 21 | } else { 22 | pr.progressCallback(pr.downloaded, 0) 23 | } 24 | } else { 25 | pr.progressCallback(pr.downloaded, pr.downloaded) 26 | } 27 | 28 | return n, err //nolint:wrapcheck 29 | } 30 | -------------------------------------------------------------------------------- /backend/autoupdate/updater/types.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import "io" 4 | 5 | type Source interface { 6 | GetLatestVersion(includePrereleases bool) (string, error) 7 | GetChangelogs() (map[string]string, error) 8 | GetFile(version string, filename string) (io.ReadCloser, int64, error) 9 | } 10 | 11 | type ChecksumSource interface { 12 | GetChecksumForFile(source Source, version string, filename string) ([]byte, error) 13 | } 14 | 15 | type Apply interface { 16 | Download(file io.Reader, checksum []byte) error 17 | Apply(restart bool) error 18 | } 19 | -------------------------------------------------------------------------------- /backend/autoupdate/updater/updater.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/Masterminds/semver/v3" 8 | 9 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/utils" 10 | ) 11 | 12 | type UpdateDownloadProgress struct { 13 | BytesDownloaded, BytesTotal int64 14 | } 15 | 16 | type Updater struct { 17 | config Config 18 | lock sync.Mutex 19 | PendingUpdate *PendingUpdate 20 | 21 | UpdateFound utils.EventDispatcher[PendingUpdate] 22 | DownloadProgress utils.EventDispatcher[UpdateDownloadProgress] 23 | UpdateReady utils.EventDispatcher[interface{}] 24 | } 25 | 26 | type PendingUpdate struct { 27 | Version *semver.Version 28 | Changelogs map[string]string 29 | Ready bool 30 | } 31 | 32 | type Config struct { 33 | Source Source 34 | File string 35 | Checksum ChecksumSource 36 | Apply Apply 37 | CurrentVersion *semver.Version 38 | IncludePrerelease bool 39 | } 40 | 41 | func MakeUpdater(config Config) *Updater { 42 | return &Updater{ 43 | config: config, 44 | } 45 | } 46 | 47 | func (u *Updater) OnExit(restart bool) error { 48 | u.lock.Lock() 49 | defer u.lock.Unlock() 50 | if u.PendingUpdate == nil { 51 | if restart { 52 | return fmt.Errorf("restart requested but no update is present") 53 | } 54 | return nil 55 | } 56 | 57 | // We do have an update 58 | // and since we have the lock, we can be sure that no other update can be found while we're here 59 | 60 | // Though, applying the update might have errored, meaning the update is not actually ready 61 | if !u.PendingUpdate.Ready { 62 | if restart { 63 | return fmt.Errorf("restart requested but update is not ready") 64 | } 65 | return nil 66 | } 67 | 68 | // Now the update is definitely ready 69 | return u.config.Apply.Apply(restart) 70 | } 71 | -------------------------------------------------------------------------------- /backend/common/app.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "context" 4 | 5 | var AppContext context.Context 6 | -------------------------------------------------------------------------------- /backend/ficsitcli/launch_other.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package ficsitcli 4 | 5 | import ( 6 | "os/exec" 7 | ) 8 | 9 | func (f *ficsitCLI) executeLaunchCommand(launchPath []string) ([]byte, string, error) { 10 | cmd := exec.Command(launchPath[0], launchPath[1:]...) 11 | out, err := cmd.CombinedOutput() 12 | return out, cmd.String(), err 13 | } 14 | -------------------------------------------------------------------------------- /backend/ficsitcli/launch_windows.go: -------------------------------------------------------------------------------- 1 | package ficsitcli 2 | 3 | import ( 4 | "os/exec" 5 | "syscall" 6 | ) 7 | 8 | func (f *ficsitCLI) executeLaunchCommand(launchPath []string) ([]byte, string, error) { 9 | cmd := exec.Command(launchPath[0], launchPath[1:]...) 10 | cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} 11 | out, err := cmd.CombinedOutput() 12 | return out, cmd.String(), err 13 | } 14 | -------------------------------------------------------------------------------- /backend/ficsitcli/offline.go: -------------------------------------------------------------------------------- 1 | package ficsitcli 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "slices" 8 | "strings" 9 | 10 | ficsitcache "github.com/satisfactorymodding/ficsit-cli/cli/cache" 11 | "github.com/satisfactorymodding/ficsit-cli/cli/provider" 12 | resolver "github.com/satisfactorymodding/ficsit-resolver" 13 | 14 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/settings" 15 | ) 16 | 17 | func (f *ficsitCLI) GetOffline() bool { 18 | return f.ficsitCli.Provider.IsOffline() 19 | } 20 | 21 | func (f *ficsitCLI) SetOffline(offline bool) { 22 | f.ficsitCli.Provider.(*provider.MixedProvider).Offline = offline 23 | settings.Settings.Offline = offline 24 | _ = settings.SaveSettings() 25 | } 26 | 27 | type Mod struct { 28 | ModReference string `json:"mod_reference"` 29 | Name string `json:"name"` 30 | Logo *string `json:"logo"` // Base64 encoded 31 | Authors []string `json:"authors"` 32 | Versions []resolver.ModVersion `json:"versions"` 33 | } 34 | 35 | func (f *ficsitCLI) OfflineGetMods() ([]Mod, error) { 36 | cache, err := ficsitcache.GetCacheMods() 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to get cache: %w", err) 39 | } 40 | 41 | mods := make([]Mod, 0) 42 | cache.Range(func(_ string, mod ficsitcache.Mod) bool { 43 | mods = append(mods, f.convertCacheFileToMod(mod)) 44 | return true 45 | }) 46 | return mods, nil 47 | } 48 | 49 | func (f *ficsitCLI) OfflineGetModsByReferences(modReferences []string) ([]Mod, error) { 50 | cache, err := ficsitcache.GetCacheMods() 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to get cache: %w", err) 53 | } 54 | 55 | mods := make([]Mod, 0) 56 | cache.Range(func(modReference string, mod ficsitcache.Mod) bool { 57 | if !slices.Contains(modReferences, modReference) { 58 | return true 59 | } 60 | mods = append(mods, f.convertCacheFileToMod(mod)) 61 | return true 62 | }) 63 | return mods, nil 64 | } 65 | 66 | func (f *ficsitCLI) OfflineGetMod(modReference string) (Mod, error) { 67 | mod, err := ficsitcache.GetCacheMod(modReference) 68 | if err != nil { 69 | return Mod{}, fmt.Errorf("failed to get cache: %w", err) 70 | } 71 | return f.convertCacheFileToMod(mod), nil 72 | } 73 | 74 | func (f *ficsitCLI) convertCacheFileToMod(mod ficsitcache.Mod) Mod { 75 | if mod.ModReference == "" { 76 | return Mod{} 77 | } 78 | 79 | authors := make([]string, 0) 80 | 81 | for _, author := range strings.Split(mod.Author, ",") { 82 | authors = append(authors, strings.TrimSpace(author)) 83 | } 84 | 85 | modVersions, err := f.ficsitCli.Provider.ModVersionsWithDependencies(context.TODO(), mod.ModReference) 86 | if err != nil { 87 | slog.Warn("failed to get mod versions", slog.String("mod", mod.ModReference), slog.Any("error", err)) 88 | } 89 | 90 | return Mod{ 91 | Name: mod.Name, 92 | ModReference: mod.ModReference, 93 | Authors: authors, 94 | Logo: mod.Icon, 95 | Versions: modVersions, 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /backend/ficsitcli/remote_servers.go: -------------------------------------------------------------------------------- 1 | package ficsitcli 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 8 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/settings" 9 | ) 10 | 11 | func (f *ficsitCLI) GetRemoteInstallations() []string { 12 | paths := make([]string, 0, f.installationMetadata.Size()) 13 | for _, install := range f.GetInstallations() { 14 | meta, ok := f.installationMetadata.Load(install) 15 | if ok && meta.Info != nil { 16 | if meta.Info.Location != common.LocationTypeRemote { 17 | continue 18 | } 19 | } 20 | // Missing metadata means an unavailable remote installation 21 | paths = append(paths, install) 22 | } 23 | return paths 24 | } 25 | 26 | func (f *ficsitCLI) AddRemoteServer(path string, name string) error { 27 | if f.ficsitCli.Installations.GetInstallation(path) != nil { 28 | return fmt.Errorf("installation already exists") 29 | } 30 | l := slog.With(slog.String("task", "addRemoteServer"), slog.String("path", path)) 31 | 32 | installation, err := f.ficsitCli.Installations.AddInstallation(f.ficsitCli, path, f.GetFallbackProfile()) 33 | if err != nil { 34 | return fmt.Errorf("failed to add installation: %w", err) 35 | } 36 | 37 | err = f.ficsitCli.Installations.Save() 38 | if err != nil { 39 | l.Error("failed to save installations", slog.Any("error", err)) 40 | } 41 | 42 | if name != "" { 43 | settings.Settings.RemoteNames[remoteKey(installation.Path)] = name 44 | } 45 | 46 | meta, err := f.getRemoteServerMetadata(installation) 47 | if err != nil { 48 | if name != "" { 49 | delete(settings.Settings.RemoteNames, remoteKey(installation.Path)) 50 | } 51 | return fmt.Errorf("failed to get remote server metadata: %w", err) 52 | } 53 | 54 | _ = settings.SaveSettings() 55 | 56 | f.installationMetadata.Store(path, installationMetadata{ 57 | State: InstallStateValid, 58 | Info: meta, 59 | }) 60 | 61 | f.EmitGlobals() 62 | 63 | return nil 64 | } 65 | 66 | func (f *ficsitCLI) RemoveRemoteServer(path string) error { 67 | metadata, ok := f.installationMetadata.Load(path) 68 | if !ok { 69 | return fmt.Errorf("installation not found") 70 | } 71 | if metadata.State == InstallStateLoading { 72 | return fmt.Errorf("installation is still loading") 73 | } 74 | if metadata.Info != nil && metadata.Info.Location != common.LocationTypeRemote { 75 | return fmt.Errorf("installation is not remote") 76 | } 77 | err := f.ficsitCli.Installations.DeleteInstallation(path) 78 | if err != nil { 79 | return fmt.Errorf("failed to delete installation: %w", err) 80 | } 81 | err = f.ficsitCli.Installations.Save() 82 | if err != nil { 83 | slog.Error("failed to save installations", slog.Any("error", err)) 84 | } 85 | f.installationMetadata.Delete(path) 86 | 87 | delete(settings.Settings.RemoteNames, remoteKey(path)) 88 | _ = settings.SaveSettings() 89 | 90 | f.EmitGlobals() 91 | 92 | return nil 93 | } 94 | -------------------------------------------------------------------------------- /backend/ficsitcli/types.go: -------------------------------------------------------------------------------- 1 | package ficsitcli 2 | 3 | import ( 4 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 5 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/utils" 6 | ) 7 | 8 | type InstallState string 9 | 10 | const ( 11 | InstallStateUnknown InstallState = "unknown" 12 | InstallStateLoading InstallState = "loading" 13 | InstallStateInvalid InstallState = "invalid" 14 | InstallStateValid InstallState = "valid" 15 | ) 16 | 17 | type installationMetadata struct { 18 | State InstallState `json:"state"` 19 | Info *common.Installation `json:"info"` 20 | } 21 | 22 | type Action string 23 | 24 | const ( 25 | ActionInstall Action = "install" 26 | ActionUninstall Action = "uninstall" 27 | ActionEnable Action = "enable" 28 | ActionDisable Action = "disable" 29 | ActionSelectInstall Action = "selectInstall" 30 | ActionToggleMods Action = "toggleMods" 31 | ActionSelectProfile Action = "selectProfile" 32 | ActionImportProfile Action = "importProfile" 33 | ActionUpdate Action = "update" 34 | ActionApply Action = "apply" 35 | ) 36 | 37 | type Progress struct { 38 | Action Action `json:"action"` 39 | Item ProgressItem `json:"item"` 40 | Tasks map[string]utils.Progress `json:"tasks"` 41 | } 42 | 43 | type ProgressItem struct { 44 | Name string `json:"name"` 45 | Version string `json:"version"` 46 | } 47 | 48 | var noItem = ProgressItem{} 49 | 50 | func newSimpleItem(name string) ProgressItem { 51 | return ProgressItem{ 52 | Name: name, 53 | } 54 | } 55 | 56 | func newItem(name, version string) ProgressItem { 57 | return ProgressItem{ 58 | Name: name, 59 | Version: version, 60 | } 61 | } 62 | 63 | func newProgress(action Action, item ProgressItem) *Progress { 64 | return &Progress{ 65 | Action: action, 66 | Item: item, 67 | Tasks: make(map[string]utils.Progress), 68 | } 69 | } 70 | 71 | var AllInstallationStates = []struct { 72 | Value InstallState 73 | TSName string 74 | }{ 75 | {InstallStateUnknown, "UNKNOWN"}, 76 | {InstallStateLoading, "LOADING"}, 77 | {InstallStateInvalid, "INVALID"}, 78 | {InstallStateValid, "VALID"}, 79 | } 80 | 81 | var AllActionTypes = []struct { 82 | Value Action 83 | TSName string 84 | }{ 85 | {ActionInstall, "INSTALL"}, 86 | {ActionUninstall, "UNINSTALL"}, 87 | {ActionEnable, "ENABLE"}, 88 | {ActionDisable, "DISABLE"}, 89 | {ActionSelectInstall, "SELECT_INSTALL"}, 90 | {ActionToggleMods, "TOGGLE_MODS"}, 91 | {ActionSelectProfile, "SELECT_PROFILE"}, 92 | {ActionImportProfile, "IMPORT_PROFILE"}, 93 | {ActionUpdate, "UPDATE"}, 94 | {ActionApply, "APPLY"}, 95 | } 96 | -------------------------------------------------------------------------------- /backend/installfinders/common/helpers.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "log/slog" 5 | "path/filepath" 6 | "runtime" 7 | "strings" 8 | ) 9 | 10 | func OsPathEqual(path1, path2 string) bool { 11 | path1 = realPath(path1) 12 | path2 = realPath(path2) 13 | if runtime.GOOS == "windows" { 14 | return strings.EqualFold(path1, path2) 15 | } 16 | return path1 == path2 17 | } 18 | 19 | func realPath(path string) string { 20 | newPath, err := filepath.EvalSymlinks(path) 21 | if err != nil { 22 | slog.Warn("failed to evaluate symlink, using original path", slog.String("path", path), slog.Any("error", err)) 23 | return path 24 | } 25 | return newPath 26 | } 27 | 28 | func FindAll(finders ...InstallFinderFunc) ([]*Installation, []error) { 29 | installs := make([]*Installation, 0) 30 | var errors []error 31 | for _, finder := range finders { 32 | foundInstalls, foundErrors := finder() 33 | for _, install := range foundInstalls { 34 | existing := false 35 | for i := range installs { 36 | if OsPathEqual(installs[i].Path, install.Path) { 37 | existing = true 38 | break 39 | } 40 | } 41 | if !existing { 42 | installs = append(installs, install) 43 | } 44 | } 45 | errors = append(errors, foundErrors...) 46 | } 47 | return installs, errors 48 | } 49 | -------------------------------------------------------------------------------- /backend/installfinders/common/launcherplatform.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | type Platform interface { 4 | ProcessPath(path string) string 5 | CacheDir() (string, error) 6 | Os() string 7 | } 8 | 9 | type LauncherPlatform struct { 10 | Platform 11 | launcherCommand func(arg string) []string 12 | } 13 | 14 | func MakeLauncherPlatform(platform Platform, launcherCommand func(arg string) []string) LauncherPlatform { 15 | return LauncherPlatform{Platform: platform, launcherCommand: launcherCommand} 16 | } 17 | 18 | func (p LauncherPlatform) LauncherCommand(arg string) []string { 19 | if p.launcherCommand != nil { 20 | return p.launcherCommand(arg) 21 | } 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /backend/installfinders/common/launcherplatform_native.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | ) 7 | 8 | type nativePlatform struct{} 9 | 10 | func NativePlatform() Platform { 11 | return nativePlatform{} 12 | } 13 | 14 | func (p nativePlatform) ProcessPath(path string) string { 15 | return path 16 | } 17 | 18 | func (p nativePlatform) CacheDir() (string, error) { 19 | return os.UserCacheDir() //nolint:wrapcheck 20 | } 21 | 22 | func (p nativePlatform) Os() string { 23 | return runtime.GOOS 24 | } 25 | -------------------------------------------------------------------------------- /backend/installfinders/common/launcherplatform_wine.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/user" 8 | "path/filepath" 9 | "strings" 10 | 11 | "gopkg.in/ini.v1" 12 | ) 13 | 14 | type winePlatform struct { 15 | winePrefix string 16 | } 17 | 18 | func WineLauncherPlatform(winePrefix string) Platform { 19 | return winePlatform{winePrefix: winePrefix} 20 | } 21 | 22 | func (p winePlatform) ProcessPath(path string) string { 23 | return filepath.Join(p.winePrefix, "dosdevices", strings.ToLower(path[0:1])+strings.ReplaceAll(path[1:], "\\", "/")) 24 | } 25 | 26 | func (p winePlatform) CacheDir() (string, error) { 27 | regCacheDir, err := p.getRegCacheDir() 28 | if err == nil { 29 | return regCacheDir, nil 30 | } 31 | if errors.Is(err, os.ErrNotExist) { 32 | return p.getDefaultCacheDir() 33 | } 34 | return "", err 35 | } 36 | 37 | func (p winePlatform) Os() string { 38 | return "windows" 39 | } 40 | 41 | func (p winePlatform) getRegCacheDir() (string, error) { 42 | userRegPath := filepath.Join(p.winePrefix, "user.reg") 43 | userRegBytes, err := os.ReadFile(userRegPath) 44 | if err != nil { 45 | return "", fmt.Errorf("failed to read user.reg: %w", err) 46 | } 47 | userRegText := string(userRegBytes) 48 | if strings.HasPrefix(userRegText, "WINE REGISTRY") { 49 | newLineIndex := strings.Index(userRegText, "\n") 50 | userRegText = userRegText[newLineIndex+1:] 51 | } 52 | userReg, err := ini.Load(strings.NewReader(userRegText)) 53 | if err != nil { 54 | return "", fmt.Errorf("failed to load user.reg: %w", err) 55 | } 56 | return p.ProcessPath(userReg.Section(`Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders`).Key("Local AppData").String()), nil 57 | } 58 | 59 | func (p winePlatform) getDefaultCacheDir() (string, error) { 60 | // Default can be either 61 | // modern: C:\Users\\AppData\Local 62 | // legacy: C:\Users\\Local Settings\Application Data 63 | currentUser, err := user.Current() 64 | if err != nil { 65 | return "", fmt.Errorf("failed to get current user: %w", err) 66 | } 67 | modernPath := p.ProcessPath(fmt.Sprintf("C:\\Users\\%s\\AppData\\Local", currentUser.Name)) 68 | legacyPath := p.ProcessPath(fmt.Sprintf("C:\\Users\\%s\\Local Settings\\Application Data", currentUser.Name)) 69 | 70 | if _, err := os.Stat(modernPath); err == nil { 71 | return modernPath, nil 72 | } 73 | 74 | return legacyPath, nil 75 | } 76 | -------------------------------------------------------------------------------- /backend/installfinders/common/types.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | type GameBranch string 4 | 5 | var ( 6 | BranchStable GameBranch = "Stable" 7 | BranchExperimental GameBranch = "Experimental" 8 | ) 9 | 10 | type InstallType string 11 | 12 | var ( 13 | InstallTypeWindowsClient InstallType = "WindowsClient" 14 | InstallTypeWindowsServer InstallType = "WindowsServer" 15 | InstallTypeLinuxServer InstallType = "LinuxServer" 16 | ) 17 | 18 | type LocationType string 19 | 20 | var ( 21 | LocationTypeLocal LocationType = "Local" 22 | LocationTypeRemote LocationType = "Remote" 23 | ) 24 | 25 | type Installation struct { 26 | Path string `json:"path"` 27 | Version int `json:"version"` 28 | Type InstallType `json:"type"` 29 | Location LocationType `json:"location"` 30 | Branch GameBranch `json:"branch"` 31 | Launcher string `json:"launcher"` 32 | LaunchPath []string `json:"launchPath"` 33 | SavedPath string `json:"-"` 34 | } 35 | 36 | type InstallFindError struct { 37 | Inner error `json:"cause"` 38 | Path string `json:"path"` 39 | } 40 | 41 | func (e InstallFindError) Error() string { 42 | return e.Path + ": " + e.Inner.Error() 43 | } 44 | 45 | func (e InstallFindError) Cause() error { 46 | return e.Inner 47 | } 48 | 49 | type InstallFinderFunc func() ([]*Installation, []error) 50 | 51 | var AllInstallTypes = []struct { 52 | Value InstallType 53 | TSName string 54 | }{ 55 | {InstallTypeWindowsClient, "WINDOWS"}, 56 | {InstallTypeWindowsServer, "WINDOWS_SERVER"}, 57 | {InstallTypeLinuxServer, "LINUX_SERVER"}, 58 | } 59 | 60 | var AllBranches = []struct { 61 | Value GameBranch 62 | TSName string 63 | }{ 64 | {BranchStable, "STABLE"}, 65 | {BranchExperimental, "EXPERIMENTAL"}, 66 | } 67 | 68 | var AllLocationTypes = []struct { 69 | Value LocationType 70 | TSName string 71 | }{ 72 | {LocationTypeLocal, "LOCAL"}, 73 | {LocationTypeRemote, "REMOTE"}, 74 | } 75 | -------------------------------------------------------------------------------- /backend/installfinders/findinstalls.go: -------------------------------------------------------------------------------- 1 | package installfinders 2 | 3 | import ( 4 | "log/slog" 5 | "strings" 6 | 7 | "golang.org/x/exp/maps" 8 | 9 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 10 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers" 11 | 12 | _ "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers/all" // register all launchers 13 | ) 14 | 15 | func FindInstallations() ([]*common.Installation, []error) { 16 | registrations := launchers.GetInstallFinders() 17 | 18 | slog.Debug("finding installations", slog.String("launchers", strings.Join(maps.Keys(registrations), ","))) 19 | 20 | return common.FindAll(maps.Values(registrations)...) 21 | } 22 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/all/all.go: -------------------------------------------------------------------------------- 1 | package all 2 | 3 | import ( 4 | _ "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers/crossover" // register crossover 5 | _ "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers/epic" // register epic 6 | _ "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers/heroic" // register heroic 7 | _ "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers/legendary" // register legendary 8 | _ "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers/lutris" // register lutris 9 | _ "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers/steam" // register steam 10 | _ "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers/whisky" // register whisky 11 | ) 12 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/crossover/crossover_darwin.go: -------------------------------------------------------------------------------- 1 | package crossover 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "path/filepath" 8 | 9 | "howett.net/plist" 10 | 11 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 12 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers" 13 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers/steam" 14 | ) 15 | 16 | var ( 17 | crossoverConfigRelativePath = filepath.Join("Library", "Preferences", "com.codeweavers.CrossOver.plist") 18 | crossoverDefaultBottlesRelativePath = filepath.Join("Library", "Application Support", "Crossover", "Bottles") 19 | ) 20 | 21 | func init() { 22 | launchers.Add("CrossOver", crossover) 23 | } 24 | 25 | func crossover() ([]*common.Installation, []error) { 26 | bottlesPath, err := getCrossoverBottlesPath() 27 | if err != nil { 28 | return nil, []error{fmt.Errorf("failed to get CrossOver bottles path: %w", err)} 29 | } 30 | 31 | if _, err := os.Stat(bottlesPath); os.IsNotExist(err) { 32 | return nil, []error{fmt.Errorf("crossover not installed")} 33 | } 34 | 35 | bottles, err := os.ReadDir(bottlesPath) 36 | if err != nil { 37 | return nil, []error{fmt.Errorf("failed to list Crossover bottles: %w", err)} 38 | } 39 | 40 | installations := make([]*common.Installation, 0) 41 | errors := make([]error, 0) 42 | for _, bottle := range bottles { 43 | if !bottle.IsDir() { 44 | continue 45 | } 46 | bottleRoot := filepath.Join(bottlesPath, bottle.Name()) 47 | bottleInstalls, bottleErrs := steam.FindInstallationsWine(bottleRoot, "CrossOver", nil) 48 | installations = append(installations, bottleInstalls...) 49 | if bottleErrs != nil { 50 | errors = append(errors, bottleErrs...) 51 | } 52 | } 53 | 54 | return installations, errors 55 | } 56 | 57 | func getCrossoverBottlesPath() (string, error) { 58 | homeDir, err := os.UserHomeDir() 59 | if err != nil { 60 | return "", fmt.Errorf("failed to get user home dir: %w", err) 61 | } 62 | 63 | defaultBottlesPath := filepath.Join(homeDir, crossoverDefaultBottlesRelativePath) 64 | 65 | var bottlesPath string 66 | 67 | configPath := filepath.Join(homeDir, crossoverConfigRelativePath) 68 | configBytes, err := os.ReadFile(configPath) 69 | if err != nil { 70 | if os.IsNotExist(err) { 71 | slog.Info("CrossOver config file missing") 72 | } else { 73 | slog.Error("failed to read CrossOver config file", slog.Any("error", err)) 74 | } 75 | } else { 76 | var config crossoverPlist 77 | _, err := plist.Unmarshal(configBytes, &config) 78 | if err != nil { 79 | slog.Error("failed to parse CrossOver config file", slog.Any("error", err)) 80 | } else { 81 | bottlesPath = config.BottleDir 82 | } 83 | } 84 | 85 | if bottlesPath == "" { 86 | bottlesPath = defaultBottlesPath 87 | } 88 | 89 | return bottlesPath, nil 90 | } 91 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/crossover/crossover_noop.go: -------------------------------------------------------------------------------- 1 | package crossover 2 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/crossover/types_darwin.go: -------------------------------------------------------------------------------- 1 | package crossover 2 | 3 | type crossoverPlist struct { 4 | BottleDir string `plist:"BottleDir"` 5 | } 6 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/epic/epic_windows.go: -------------------------------------------------------------------------------- 1 | package epic 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "golang.org/x/sys/windows" 8 | 9 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 10 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers" 11 | ) 12 | 13 | var epicProgramDataManifestsFolder = filepath.Join("Epic", "EpicGamesLauncher", "Data", "Manifests") 14 | 15 | func init() { 16 | launchers.Add("EpicGames", func() ([]*common.Installation, []error) { 17 | programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, 0) 18 | if err != nil { 19 | return nil, []error{fmt.Errorf("failed to get ProgramData folder: %w", err)} 20 | } 21 | 22 | return FindInstallationsEpic( 23 | filepath.Join(programData, epicProgramDataManifestsFolder), 24 | "Epic Games", 25 | common.MakeLauncherPlatform( 26 | common.NativePlatform(), 27 | func(appName string) []string { 28 | return []string{ 29 | "cmd", 30 | "/C", 31 | `start`, 32 | ``, 33 | // The extra space at the end is required for exec to escape the argument with double quotes 34 | // Otherwise, the & is interpreted as a command sequence 35 | `com.epicgames.launcher://apps/` + appName + `?action=launch&silent=true `, 36 | } 37 | }, 38 | ), 39 | ) 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/epic/epic_wine_unix.go: -------------------------------------------------------------------------------- 1 | //go:build unix 2 | 3 | package epic 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 11 | ) 12 | 13 | var epicWineManifestPath = filepath.Join("c:", "ProgramData", "Epic", "EpicGamesLauncher", "Data", "Manifests") 14 | 15 | func FindInstallationsWine(winePrefix string, launcher string, launchPath []string) ([]*common.Installation, []error) { 16 | platform := common.WineLauncherPlatform(winePrefix) 17 | 18 | if _, err := os.Stat(platform.ProcessPath(epicWineManifestPath)); os.IsNotExist(err) { 19 | return nil, []error{fmt.Errorf("Epic is not installed in %s", winePrefix)} 20 | } 21 | 22 | return FindInstallationsEpic( 23 | epicWineManifestPath, 24 | launcher, 25 | common.MakeLauncherPlatform(platform, func(_ string) []string { return launchPath }), 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/heroic/heroic_flatpak_linux.go: -------------------------------------------------------------------------------- 1 | package heroic 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 9 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers" 10 | ) 11 | 12 | func init() { 13 | launchers.Add("Heroic-flatpak", func() ([]*common.Installation, []error) { 14 | homeDir, err := os.UserHomeDir() 15 | if err != nil { 16 | return nil, []error{fmt.Errorf("failed to get user home dir: %w", err)} 17 | } 18 | flatpakXdgConfigHome := filepath.Join(homeDir, ".var", "app", "com.heroicgameslauncher.hgl", "config") 19 | 20 | return findInstallationsHeroic(false, flatpakXdgConfigHome, "Heroic") 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/heroic/heroic_native_all.go: -------------------------------------------------------------------------------- 1 | package heroic 2 | 3 | import ( 4 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 5 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers" 6 | ) 7 | 8 | func init() { 9 | launchers.Add("Heroic", func() ([]*common.Installation, []error) { 10 | return findInstallationsHeroic(false, "", "Heroic") 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/heroic/heroic_snap_linux.go: -------------------------------------------------------------------------------- 1 | package heroic 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strconv" 8 | 9 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 10 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers" 11 | ) 12 | 13 | func init() { 14 | launchers.Add("Heroic-snap", func() ([]*common.Installation, []error) { 15 | snapPath, err := getSnapPath() 16 | if err != nil { 17 | return nil, []error{fmt.Errorf("failed to get snap path: %w", err)} 18 | } 19 | 20 | return findInstallationsHeroic(true, filepath.Join(snapPath, ".config"), "Heroic") 21 | }) 22 | } 23 | 24 | func getSnapPath() (string, error) { 25 | homeDir, err := os.UserHomeDir() 26 | if err != nil { 27 | return "", fmt.Errorf("failed to get user home dir: %w", err) 28 | } 29 | snapAppDir := filepath.Join(homeDir, "snap", "heroic") 30 | var latestSnapRevision int 31 | var latestSnapDirName string 32 | items, err := os.ReadDir(snapAppDir) 33 | if err != nil { 34 | return "", fmt.Errorf("failed to read heroic snap dir: %w", err) 35 | } 36 | for _, item := range items { 37 | if item.IsDir() { 38 | folderName := item.Name() 39 | var revision int 40 | if folderName[0] == 'x' { 41 | revision, err = strconv.Atoi(folderName[1:]) 42 | if err != nil { 43 | continue 44 | } 45 | } else { 46 | revision, err = strconv.Atoi(folderName) 47 | if err != nil { 48 | continue 49 | } 50 | } 51 | if latestSnapDirName == "" || revision > latestSnapRevision { 52 | latestSnapRevision = revision 53 | latestSnapDirName = folderName 54 | } 55 | } 56 | } 57 | if latestSnapDirName == "" { 58 | return "", fmt.Errorf("no heroic snap folders found") 59 | } 60 | return filepath.Join(snapAppDir, latestSnapDirName), nil 61 | } 62 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/legendary/legendary_all.go: -------------------------------------------------------------------------------- 1 | package legendary 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | 7 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 8 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers" 9 | ) 10 | 11 | func init() { 12 | launchers.Add("Legendary", func() ([]*common.Installation, []error) { 13 | legendaryDataPath, err := getGlobalLegendaryDataPath("") 14 | if err != nil { 15 | return nil, []error{fmt.Errorf("failed to get legendary config path: %w", err)} 16 | } 17 | 18 | _, err = exec.LookPath("legendary") 19 | canLaunchLegendary := err == nil 20 | 21 | return FindInstallationsIn( 22 | legendaryDataPath, 23 | "Legendary", 24 | nil, 25 | common.MakeLauncherPlatform( 26 | common.NativePlatform(), 27 | func(appName string) []string { 28 | if !canLaunchLegendary { 29 | return nil 30 | } 31 | return []string{"legendary", "launch", appName} 32 | }, 33 | ), 34 | ) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/lutris/lutris_linux.go: -------------------------------------------------------------------------------- 1 | package lutris 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 11 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers" 12 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers/epic" 13 | ) 14 | 15 | type Game struct { 16 | ID int `json:"id"` 17 | Slug string `json:"slug"` 18 | Name string `json:"name"` 19 | Runner string `json:"runner"` 20 | Directory string `json:"directory"` 21 | } 22 | 23 | func init() { 24 | launchers.Add("Lutris", func() ([]*common.Installation, []error) { 25 | return findInstallations([]string{"lutris"}, "Lutris") 26 | }) 27 | launchers.Add("Lutris-flatpak", func() ([]*common.Installation, []error) { 28 | return findInstallations([]string{"flatpak", "run", "net.lutris.Lutris"}, "Lutris") 29 | }) 30 | } 31 | 32 | func findInstallations(lutrisCmd []string, launcher string) ([]*common.Installation, []error) { 33 | lutrisLjCmd := makeLutrisCmd(lutrisCmd, "-lj") 34 | lutrisLj := exec.Command(lutrisLjCmd[0], lutrisLjCmd[1:]...) 35 | lutrisLj.Env = os.Environ() 36 | lutrisLj.Env = append(lutrisLj.Env, "LUTRIS_SKIP_INIT=1") 37 | if os.Getenv("APPIMAGE") != "" { 38 | // Must clear the APPIMAGE added entries of LD_LIBRARY_PATH, as well as PYTHONHOME and PYTHONPATH 39 | // otherwise lutris will load some appimage libraries and some system libraries, which are incompatible 40 | lutrisLj.Env = append(lutrisLj.Env, "PYTHONHOME=", "PYTHONPATH=") 41 | 42 | appdir := os.Getenv("APPDIR") 43 | ldLibraryPathEntries := strings.Split(os.Getenv("LD_LIBRARY_PATH"), ":") 44 | newLdLibraryPathEntries := []string{} 45 | for _, entry := range ldLibraryPathEntries { 46 | if !strings.HasPrefix(entry, appdir) { 47 | newLdLibraryPathEntries = append(newLdLibraryPathEntries, entry) 48 | } 49 | } 50 | lutrisLj.Env = append(lutrisLj.Env, "LD_LIBRARY_PATH="+strings.Join(newLdLibraryPathEntries, ":")) 51 | } 52 | outputBytes, err := lutrisLj.Output() 53 | if err != nil { 54 | return nil, []error{ 55 | fmt.Errorf("failed to run lutris -lj: %w", err), 56 | } 57 | } 58 | var lutrisGames []Game 59 | err = json.Unmarshal(outputBytes, &lutrisGames) 60 | if err != nil { 61 | return nil, []error{ 62 | fmt.Errorf("failed to parse lutris -lj output: %w", err), 63 | } 64 | } 65 | 66 | installs := []*common.Installation{} 67 | findErrors := []error{} 68 | for _, lutrisGame := range lutrisGames { 69 | currentInstalls, errs := epic.FindInstallationsWine(lutrisGame.Directory, launcher+" - "+lutrisGame.Name, makeLutrisCmd(lutrisCmd, "lutris:rungame/"+lutrisGame.Slug)) 70 | installs = append(installs, currentInstalls...) 71 | if errs != nil { 72 | findErrors = append(findErrors, errs...) 73 | } 74 | } 75 | return installs, findErrors 76 | } 77 | 78 | func makeLutrisCmd(lutrisCmd []string, args ...string) []string { 79 | return append(lutrisCmd, args...) 80 | } 81 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/lutris/lutris_noop.go: -------------------------------------------------------------------------------- 1 | package lutris 2 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/registry.go: -------------------------------------------------------------------------------- 1 | package launchers 2 | 3 | import "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 4 | 5 | var finders map[string]common.InstallFinderFunc 6 | 7 | func Add(id string, f common.InstallFinderFunc) { 8 | if finders == nil { 9 | finders = make(map[string]common.InstallFinderFunc) 10 | } 11 | if _, ok := finders[id]; ok { 12 | panic("launcher already registered") 13 | } 14 | finders[id] = f 15 | } 16 | 17 | func GetInstallFinders() map[string]common.InstallFinderFunc { 18 | return finders 19 | } 20 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/steam/steam_flatpak_linux.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 9 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers" 10 | ) 11 | 12 | func init() { 13 | launchers.Add("Steam-flatpak", func() ([]*common.Installation, []error) { 14 | homeDir, err := os.UserHomeDir() 15 | if err != nil { 16 | return nil, []error{fmt.Errorf("failed to get user home dir: %w", err)} 17 | } 18 | 19 | steamPath := filepath.Join(homeDir, ".var", "app", "com.valvesoftware.Steam", ".steam", "steam") 20 | if _, err := os.Stat(steamPath); os.IsNotExist(err) { 21 | return nil, []error{fmt.Errorf("steam-flatpak not installed")} 22 | } 23 | return FindInstallationsSteam( 24 | steamPath, 25 | "Steam", 26 | common.MakeLauncherPlatform( 27 | common.NativePlatform(), 28 | func(steamApp string) []string { 29 | return []string{ 30 | "flatpak", 31 | "run", 32 | "com.valvesoftware.Steam", 33 | steamApp, 34 | } 35 | }), 36 | ) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/steam/steam_native_unix.go: -------------------------------------------------------------------------------- 1 | //go:build unix 2 | 3 | package steam 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 11 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers" 12 | ) 13 | 14 | func init() { 15 | launchers.Add("Steam", func() ([]*common.Installation, []error) { 16 | homeDir, err := os.UserHomeDir() 17 | if err != nil { 18 | return nil, []error{fmt.Errorf("failed to get user home dir: %w", err)} 19 | } 20 | 21 | steamPath := filepath.Join(homeDir, ".steam", "steam") 22 | if _, err := os.Stat(steamPath); os.IsNotExist(err) { 23 | return nil, []error{fmt.Errorf("steam not installed")} 24 | } 25 | 26 | return FindInstallationsSteam( 27 | steamPath, 28 | "Steam", 29 | common.MakeLauncherPlatform( 30 | common.NativePlatform(), 31 | func(steamApp string) []string { 32 | return []string{ 33 | "steam", 34 | steamApp, 35 | } 36 | }), 37 | ) 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/steam/steam_snap_linux.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 9 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers" 10 | ) 11 | 12 | func init() { 13 | launchers.Add("Steam-snap", func() ([]*common.Installation, []error) { 14 | homeDir, err := os.UserHomeDir() 15 | if err != nil { 16 | return nil, []error{fmt.Errorf("failed to get user home dir: %w", err)} 17 | } 18 | steamPath := filepath.Join(homeDir, "snap", "steam", "common", ".local", "share", "Steam") 19 | if _, err := os.Stat(steamPath); os.IsNotExist(err) { 20 | return nil, []error{fmt.Errorf("steam-snap not installed")} 21 | } 22 | return FindInstallationsSteam( 23 | steamPath, 24 | "Steam", 25 | common.MakeLauncherPlatform( 26 | common.NativePlatform(), 27 | func(steamApp string) []string { 28 | return []string{ 29 | "snap", 30 | "run", 31 | "steam", 32 | steamApp, 33 | } 34 | }), 35 | ) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/steam/steam_windows.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "golang.org/x/sys/windows/registry" 8 | 9 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 10 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/launchers" 11 | ) 12 | 13 | func init() { 14 | launchers.Add("Steam", func() ([]*common.Installation, []error) { 15 | steamPath, err := getSteamPath() 16 | if err != nil { 17 | return nil, []error{err} 18 | } 19 | 20 | return FindInstallationsSteam( 21 | steamPath, 22 | "Steam", 23 | common.MakeLauncherPlatform( 24 | common.NativePlatform(), 25 | func(steamApp string) []string { 26 | return []string{ 27 | "cmd", 28 | "/C", 29 | "start", 30 | "", 31 | steamApp, 32 | } 33 | }), 34 | ) 35 | }) 36 | } 37 | 38 | func getSteamPath() (string, error) { 39 | key, err := registry.OpenKey(registry.CURRENT_USER, `Software\Valve\Steam`, registry.QUERY_VALUE) 40 | if err != nil { 41 | return "", fmt.Errorf("failed to open Steam registry key: %w", err) 42 | } 43 | defer key.Close() 44 | 45 | steamExePath, _, err := key.GetStringValue("SteamExe") 46 | if err != nil { 47 | steamExePath = `C:\Program Files (x86)\Steam\steam.exe` 48 | } 49 | 50 | return filepath.Dir(steamExePath), nil 51 | } 52 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/steam/steam_wine_darwin.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/installfinders/common" 9 | ) 10 | 11 | // Will get run through processPath, so it will be added to the dosdevices path 12 | // Theoretically this could be configured on custom wine prefixes (so would require parsing the wine registry), 13 | // but the supported launchers don't, and have no reason to 14 | var steamWinePath = filepath.Join("c:", "Program Files (x86)", "Steam") 15 | 16 | func FindInstallationsWine(winePrefix string, launcher string, launchPath []string) ([]*common.Installation, []error) { 17 | platform := common.WineLauncherPlatform(winePrefix) 18 | 19 | if _, err := os.Stat(platform.ProcessPath(steamWinePath)); os.IsNotExist(err) { 20 | return nil, []error{fmt.Errorf("Steam is not installed in %s", winePrefix)} 21 | } 22 | 23 | return FindInstallationsSteam( 24 | steamWinePath, 25 | launcher, 26 | common.MakeLauncherPlatform(platform, func(_ string) []string { return launchPath }), 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/whisky/types_darwin.go: -------------------------------------------------------------------------------- 1 | package whisky 2 | 3 | type whiskyPlist struct { 4 | DefaultBottleLocation string `plist:"defaultBottleLocation"` 5 | } 6 | 7 | type bottleVMPlist struct { 8 | Paths []urlPlist `plist:"paths"` 9 | } 10 | 11 | type urlPlist struct { 12 | Relative string `plist:"relative"` 13 | } 14 | -------------------------------------------------------------------------------- /backend/installfinders/launchers/whisky/whisky_noop.go: -------------------------------------------------------------------------------- 1 | package whisky 2 | -------------------------------------------------------------------------------- /backend/logging/logging.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "time" 7 | 8 | "github.com/lmittmann/tint" 9 | slogmulti "github.com/samber/slog-multi" 10 | "github.com/spf13/viper" 11 | "gopkg.in/natefinch/lumberjack.v2" 12 | 13 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/settings" 14 | ) 15 | 16 | func Init() { 17 | handlers := make([]slog.Handler, 0) 18 | 19 | if _, err := os.Stdout.Stat(); err == nil { 20 | // Only add the stdout handler if it is writable. 21 | // Otherwise, the fanout handler would have the first handler error, 22 | // and will not get to use the file handler. 23 | handlers = append(handlers, tint.NewHandler(os.Stdout, &tint.Options{ 24 | Level: settingsLogLevel{}, 25 | AddSource: true, 26 | TimeFormat: time.RFC3339, 27 | })) 28 | } 29 | 30 | if viper.GetString("log-file") != "" { 31 | logFile := &lumberjack.Logger{ 32 | Filename: viper.GetString("log-file"), 33 | MaxSize: 10, // megabytes 34 | MaxBackups: 5, 35 | MaxAge: 30, // days 36 | } 37 | 38 | handlers = append(handlers, slog.NewJSONHandler(logFile, &slog.HandlerOptions{ 39 | Level: settingsLogLevel{}, 40 | })) 41 | } 42 | 43 | slog.SetDefault( 44 | slog.New( 45 | slogmulti. 46 | Pipe(newRedactGamePathCredentialsMiddleware()). 47 | Handler(slogmulti.Fanout(handlers...)), 48 | ), 49 | ) 50 | } 51 | 52 | type settingsLogLevel struct{} 53 | 54 | func (v settingsLogLevel) Level() slog.Level { 55 | if settings.Settings.Debug { 56 | return slog.LevelDebug 57 | } 58 | return slog.LevelInfo 59 | } 60 | -------------------------------------------------------------------------------- /backend/logging/redaction.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/samber/lo" 8 | slogmulti "github.com/samber/slog-multi" 9 | 10 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/ficsitcli" 11 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/utils" 12 | ) 13 | 14 | type redactGamePathCredentialsMiddleware struct { 15 | next slog.Handler 16 | } 17 | 18 | func newRedactGamePathCredentialsMiddleware() slogmulti.Middleware { 19 | return func(next slog.Handler) slog.Handler { 20 | return &redactGamePathCredentialsMiddleware{ 21 | next: next, 22 | } 23 | } 24 | } 25 | 26 | func (r redactGamePathCredentialsMiddleware) Enabled(ctx context.Context, level slog.Level) bool { 27 | return r.next.Enabled(ctx, level) 28 | } 29 | 30 | func (r redactGamePathCredentialsMiddleware) Handle(ctx context.Context, record slog.Record) error { 31 | attrs := make([]slog.Attr, 0, record.NumAttrs()) 32 | 33 | record.Attrs(func(attr slog.Attr) bool { 34 | attrs = append(attrs, redactPaths(attr)) 35 | return true 36 | }) 37 | 38 | // new record with redacted paths 39 | record = slog.NewRecord(record.Time, record.Level, record.Message, record.PC) 40 | record.AddAttrs(attrs...) 41 | 42 | return r.next.Handle(ctx, record) //nolint:wrapcheck 43 | } 44 | 45 | func (r redactGamePathCredentialsMiddleware) WithAttrs(attrs []slog.Attr) slog.Handler { 46 | for i := range attrs { 47 | attrs[i] = redactPaths(attrs[i]) 48 | } 49 | return &redactGamePathCredentialsMiddleware{ 50 | next: r.next.WithAttrs(attrs), 51 | } 52 | } 53 | 54 | func (r redactGamePathCredentialsMiddleware) WithGroup(name string) slog.Handler { 55 | return &redactGamePathCredentialsMiddleware{ 56 | next: r.next.WithGroup(name), 57 | } 58 | } 59 | 60 | func redactPaths(attr slog.Attr) slog.Attr { 61 | k := attr.Key 62 | v := attr.Value 63 | kind := attr.Value.Kind() 64 | 65 | switch kind { 66 | case slog.KindGroup: 67 | attrs := v.Group() 68 | for i := range attrs { 69 | attrs[i] = redactPaths(attrs[i]) 70 | } 71 | return slog.Group(k, lo.ToAnySlice(attrs)...) 72 | case slog.KindString: 73 | if isGamePath(v.String()) { 74 | return slog.String(k, utils.RedactPath(v.String())) 75 | } 76 | default: 77 | break 78 | } 79 | return attr 80 | } 81 | 82 | func isGamePath(str string) bool { 83 | if ficsitcli.FicsitCLI != nil { 84 | return ficsitcli.FicsitCLI.GetInstallation(str) != nil 85 | } 86 | // if ficsitcli is not initialized, we can't know if it's a game path 87 | // so any code running before that should not log game paths 88 | return false 89 | } 90 | -------------------------------------------------------------------------------- /backend/migration/migration.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | type migration struct { 14 | smm2Dir string 15 | migrationSuccessMarkerPath string 16 | } 17 | 18 | var Migration *migration 19 | 20 | func Init() { 21 | if Migration == nil { 22 | Migration = &migration{} 23 | Migration.smm2Dir = filepath.Join(viper.GetString("smm-local-dir"), "profiles") 24 | Migration.migrationSuccessMarkerPath = filepath.Join(Migration.smm2Dir, migrationSuccessMarkerFile) 25 | } 26 | } 27 | 28 | const migrationSuccessMarkerFile = ".smm3_migration_acknowledged" 29 | 30 | // https://stackoverflow.com/questions/12518876/how-to-check-if-a-file-exists-in-go 31 | func pathExists(path string) bool { 32 | _, err := os.Stat(path) 33 | if err == nil { 34 | return true 35 | } 36 | if errors.Is(err, os.ErrNotExist) { 37 | return false 38 | } 39 | slog.Warn("error when checking path exists, so assuming it does not exist", slog.String("path", path), slog.Any("error", err)) 40 | return false 41 | } 42 | 43 | func (m *migration) NeedsSmm2Migration() bool { 44 | if pathExists(m.smm2Dir) { 45 | return !pathExists(Migration.migrationSuccessMarkerPath) 46 | } 47 | return false 48 | } 49 | 50 | func (m *migration) MarkSmm2MigrationSuccess() error { 51 | file, err := os.Create(Migration.migrationSuccessMarkerPath) 52 | if err != nil { 53 | return fmt.Errorf("failed to create migration success marker file: %w", err) 54 | } 55 | err = file.Close() 56 | if err != nil { 57 | return fmt.Errorf("failed to close file: %w", err) 58 | } 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /backend/utils/display.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "image" 5 | "runtime" 6 | 7 | "github.com/kbinani/screenshot" 8 | ) 9 | 10 | func GetDisplayBounds() []image.Rectangle { 11 | n := screenshot.NumActiveDisplays() 12 | 13 | bounds := make([]image.Rectangle, 0, n) 14 | 15 | for i := 0; i < n; i++ { 16 | bounds = append(bounds, screenshot.GetDisplayBounds(i)) 17 | } 18 | 19 | if runtime.GOOS == "linux" && n > 0 { 20 | // gdk_monitor_get_geometry considers 0,0 to be the corner of the bounding box of all the monitors, 21 | // not the 0,0 of the main monitor 22 | boundingBox := bounds[0] 23 | for _, b := range bounds { 24 | boundingBox = boundingBox.Union(b) 25 | } 26 | for i := range bounds { 27 | bounds[i] = bounds[i].Sub(boundingBox.Min) 28 | } 29 | } 30 | 31 | return bounds 32 | } 33 | 34 | func GetDisplayBoundsAt(x, y int) image.Rectangle { 35 | point := image.Pt(x, y) 36 | 37 | displays := GetDisplayBounds() 38 | 39 | curDisplay := image.Rect(0, 0, 0, 0) 40 | if len(displays) > 0 { 41 | curDisplay = displays[0] // use main display as fallback 42 | } 43 | 44 | for _, d := range displays { 45 | if point.In(d) { 46 | curDisplay = d 47 | break 48 | } 49 | } 50 | 51 | return curDisplay 52 | } 53 | -------------------------------------------------------------------------------- /backend/utils/events.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "slices" 4 | 5 | type eventListener[D any] *func(D) 6 | 7 | type EventDispatcher[D any] struct { 8 | listeners []eventListener[D] 9 | } 10 | 11 | func (ed *EventDispatcher[D]) On(f func(D)) func() { 12 | ed.listeners = append(ed.listeners, &f) 13 | return func() { 14 | ed.listeners = slices.DeleteFunc(ed.listeners, func(listener eventListener[D]) bool { 15 | return listener == &f 16 | }) 17 | } 18 | } 19 | 20 | func (ed *EventDispatcher[D]) Once(f func(D)) { 21 | var after func() 22 | after = ed.On(func(data D) { 23 | f(data) 24 | after() 25 | }) 26 | } 27 | 28 | func (ed *EventDispatcher[D]) Dispatch(data D) { 29 | for _, listener := range ed.listeners { 30 | if listener == nil { 31 | continue 32 | } 33 | (*listener)(data) 34 | } 35 | ed.listeners = slices.DeleteFunc(ed.listeners, func(listener eventListener[D]) bool { 36 | return listener == nil 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /backend/utils/json.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "strings" 7 | ) 8 | 9 | func JSONMarshal(v any, indentSize int) ([]byte, error) { 10 | buf := new(bytes.Buffer) 11 | enc := json.NewEncoder(buf) 12 | enc.SetEscapeHTML(false) 13 | enc.SetIndent("", strings.Repeat(" ", indentSize)) 14 | err := enc.Encode(v) 15 | if err != nil { 16 | return nil, err // nolint:wrapcheck 17 | } 18 | return buf.Bytes(), nil 19 | } 20 | -------------------------------------------------------------------------------- /backend/utils/os.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | func IsIn(dir, path string) bool { 11 | rel, err := filepath.Rel(dir, path) 12 | if err != nil { 13 | return false 14 | } 15 | return filepath.IsLocal(rel) 16 | } 17 | 18 | func CopyRecursive(from, to string) error { 19 | return filepath.Walk(from, func(path string, info os.FileInfo, err error) error { //nolint:wrapcheck 20 | if err != nil { 21 | return err 22 | } 23 | if IsIn(to, path) { 24 | return nil 25 | } 26 | relPath, err := filepath.Rel(from, path) 27 | if err != nil { 28 | return err //nolint:wrapcheck 29 | } 30 | newPath := filepath.Join(to, relPath) 31 | if info.IsDir() { 32 | err := os.Mkdir(newPath, 0o755) 33 | if err != nil && !os.IsExist(err) { 34 | return err //nolint:wrapcheck 35 | } 36 | return nil 37 | } 38 | f, err := os.Open(path) 39 | if err != nil { 40 | return err //nolint:wrapcheck 41 | } 42 | defer f.Close() 43 | f2, err := os.Create(newPath) 44 | if err != nil { 45 | return err //nolint:wrapcheck 46 | } 47 | defer f2.Close() 48 | _, err = io.Copy(f2, f) 49 | return err //nolint:wrapcheck 50 | }) 51 | } 52 | 53 | func MoveRecursive(from, to string) (bool, error) { 54 | err := CopyRecursive(from, to) 55 | if err != nil { 56 | return false, fmt.Errorf("failed to copy %s to %s: %w", from, to, err) 57 | } 58 | err = filepath.Walk(from, func(path string, _ os.FileInfo, err error) error { 59 | if err != nil { 60 | if !os.IsNotExist(err) { 61 | return err 62 | } 63 | return nil 64 | } 65 | if IsIn(path, to) { 66 | // Skip parent directories of destination 67 | return nil 68 | } 69 | if IsIn(to, path) { 70 | // Skip contents of destination 71 | return nil 72 | } 73 | err = os.RemoveAll(path) 74 | if err != nil { 75 | if !os.IsNotExist(err) { 76 | return err //nolint:wrapcheck 77 | } 78 | } 79 | return nil 80 | }) 81 | if err != nil { 82 | return true, fmt.Errorf("failed to remove %s: %w", from, err) 83 | } 84 | return true, nil 85 | } 86 | -------------------------------------------------------------------------------- /backend/utils/paths.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "os" 7 | ) 8 | 9 | func EnsureDirExists(path string) error { 10 | _, err := os.Stat(path) 11 | if err != nil { 12 | if !os.IsNotExist(err) { 13 | return fmt.Errorf("failed to stat path %s: %w", path, err) 14 | } 15 | 16 | err = os.MkdirAll(path, 0o755) 17 | if err != nil { 18 | return fmt.Errorf("failed to create directory %s: %w", path, err) 19 | } 20 | } 21 | return nil 22 | } 23 | 24 | func RedactPath(path string) string { 25 | parsed, err := url.Parse(path) 26 | if err != nil { 27 | return "***INVALID PATH FOR REDACTION***" 28 | } 29 | // For remote servers, they might contain a username, password, and host, all of which should be redacted when logging 30 | if parsed.User != nil { 31 | // "*" would be encoded to %2A in usernames and passwords 32 | parsed.User = url.UserPassword("user", "pass") 33 | } 34 | if parsed.Host != "" { 35 | parsed.Host = "******" 36 | } 37 | return parsed.String() 38 | } 39 | -------------------------------------------------------------------------------- /backend/utils/progress.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | type Progress struct { 4 | Current int64 `json:"current"` 5 | Total int64 `json:"total"` 6 | } 7 | -------------------------------------------------------------------------------- /backend/utils/restart.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "syscall" 8 | ) 9 | 10 | func Restart() error { 11 | wd, err := os.Getwd() 12 | if err != nil { 13 | return fmt.Errorf("failed to get working directory: %w", err) 14 | } 15 | 16 | executable, err := getExecutable() 17 | if err != nil { 18 | return err 19 | } 20 | 21 | _, err = os.StartProcess(executable, os.Args, &os.ProcAttr{ 22 | Dir: wd, 23 | Env: os.Environ(), 24 | Files: []*os.File{os.Stdin, os.Stdout, os.Stderr}, 25 | Sys: &syscall.SysProcAttr{}, 26 | }) 27 | if err != nil { 28 | return fmt.Errorf("failed to start process: %w", err) 29 | } 30 | 31 | return nil 32 | } 33 | 34 | func getExecutable() (string, error) { 35 | if appimagePath := os.Getenv("APPIMAGE"); appimagePath != "" { 36 | return appimagePath, nil 37 | } 38 | 39 | executable, err := exec.LookPath(os.Args[0]) 40 | if err != nil { 41 | return "", fmt.Errorf("failed to get executable path: %w", err) 42 | } 43 | return executable, nil 44 | } 45 | -------------------------------------------------------------------------------- /backend/utils/sizes.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | type Size struct { 4 | Width int `json:"width"` 5 | Height int `json:"height"` 6 | } 7 | 8 | type Position struct { 9 | X int `json:"x"` 10 | Y int `json:"y"` 11 | } 12 | 13 | var ( 14 | UnexpandedMin = Size{Width: 660, Height: 350} 15 | UnexpandedMax = Size{Width: 0, Height: 0} 16 | UnexpandedDefault = Size{Width: 950, Height: 950} 17 | ExpandedMin = Size{Width: 720, Height: 350} 18 | ExpandedMax = Size{Width: 0, Height: 0} 19 | ExpandedDefault = Size{Width: 1300, Height: 950} 20 | ) 21 | -------------------------------------------------------------------------------- /backend/utils/zip.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "archive/zip" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | func AddFileToZip(writer *zip.Writer, path string, zipPath string) error { 12 | file, err := os.Open(path) 13 | if err != nil { 14 | return fmt.Errorf("failed to open file: %w", err) 15 | } 16 | defer file.Close() 17 | 18 | fileInfo, err := file.Stat() 19 | if err != nil { 20 | return fmt.Errorf("failed to get file info: %w", err) 21 | } 22 | 23 | if fileInfo.IsDir() { 24 | return fmt.Errorf("file is a directory") 25 | } 26 | 27 | header, err := zip.FileInfoHeader(fileInfo) 28 | if err != nil { 29 | return fmt.Errorf("failed to create header: %w", err) 30 | } 31 | 32 | header.Method = zip.Deflate 33 | header.Name = zipPath 34 | 35 | fileWriter, err := writer.CreateHeader(header) 36 | if err != nil { 37 | return fmt.Errorf("failed to create file writer: %w", err) 38 | } 39 | 40 | _, err = io.Copy(fileWriter, file) 41 | if err != nil { 42 | return fmt.Errorf("failed to copy file: %w", err) 43 | } 44 | return nil 45 | } 46 | 47 | func ExtractZip(zipPath string, dst string) error { 48 | archive, err := zip.OpenReader(zipPath) 49 | if err != nil { 50 | return fmt.Errorf("failed to open zip: %w", err) 51 | } 52 | defer archive.Close() 53 | 54 | for _, f := range archive.File { 55 | filePath := filepath.Join(dst, f.Name) 56 | if f.FileInfo().IsDir() { 57 | if err := os.MkdirAll(filePath, os.ModePerm); err != nil { 58 | return fmt.Errorf("failed to create directory: %w", err) 59 | } 60 | continue 61 | } 62 | 63 | if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil { 64 | return fmt.Errorf("failed to create directory: %w", err) 65 | } 66 | 67 | // Wrap in a function to ensure defer is called before the next iteration 68 | err = func() error { 69 | dstFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 70 | if err != nil { 71 | return fmt.Errorf("failed to open file: %w", err) 72 | } 73 | defer dstFile.Close() 74 | 75 | fileInArchive, err := f.Open() 76 | if err != nil { 77 | return fmt.Errorf("failed to open file in archive: %w", err) 78 | } 79 | defer fileInArchive.Close() 80 | 81 | if _, err := io.Copy(dstFile, fileInArchive); err != nil { 82 | return fmt.Errorf("failed to copy file: %w", err) 83 | } 84 | return nil 85 | }() 86 | if err != nil { 87 | return err 88 | } 89 | } 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /backend/wails_logger.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | ) 7 | 8 | type WailsZeroLogLogger struct{} 9 | 10 | func (l WailsZeroLogLogger) Print(message string) { 11 | slog.Debug(message) 12 | } 13 | 14 | func (l WailsZeroLogLogger) Trace(message string) { 15 | slog.Debug(message) 16 | } 17 | 18 | func (l WailsZeroLogLogger) Debug(message string) { 19 | slog.Debug(message) 20 | } 21 | 22 | func (l WailsZeroLogLogger) Info(message string) { 23 | slog.Info(message) 24 | } 25 | 26 | func (l WailsZeroLogLogger) Warning(message string) { 27 | slog.Warn(message) 28 | } 29 | 30 | func (l WailsZeroLogLogger) Error(message string) { 31 | slog.Error(message) 32 | } 33 | 34 | func (l WailsZeroLogLogger) Fatal(message string) { 35 | slog.Error(message) 36 | os.Exit(1) 37 | } 38 | -------------------------------------------------------------------------------- /backend/wailsextras/patch_user_agent.go: -------------------------------------------------------------------------------- 1 | package wailsextras 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "unsafe" 7 | 8 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/common" 9 | ) 10 | 11 | func AddUserAgent(userAgentName, userAgentVersion string) { 12 | fullUserAgent := strings.Join([]string{userAgentName, userAgentVersion}, "/") 13 | fullUserAgent = strings.TrimSuffix(fullUserAgent, "/") // in case no version is provided 14 | addUserAgent(fullUserAgent) 15 | } 16 | 17 | func getFrontendReflected() reflect.Value { 18 | frontend := common.AppContext.Value("frontend") 19 | return getInnermostFrontend(reflect.ValueOf(frontend)) 20 | } 21 | 22 | func getInnermostFrontend(frontend reflect.Value) reflect.Value { 23 | for i := 0; i < reflect.Indirect(frontend).NumField(); i++ { 24 | if reflect.Indirect(frontend).Field(i).Type().String() == "frontend.Frontend" { 25 | return getInnermostFrontend(reflect.Indirect(frontend).Field(i).Elem()) 26 | } 27 | } 28 | return frontend 29 | } 30 | 31 | func allowUnexportedFieldAccess(field reflect.Value) reflect.Value { 32 | return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem() 33 | } 34 | -------------------------------------------------------------------------------- /backend/wailsextras/patch_user_agent_darwin.go: -------------------------------------------------------------------------------- 1 | package wailsextras 2 | 3 | /* 4 | #cgo CFLAGS: -x objective-c 5 | #cgo LDFLAGS: -framework Foundation -framework Cocoa -framework WebKit 6 | #import 7 | #import "WailsContext.h" 8 | 9 | void add_user_agent(void *inctx, char *newUserAgent) { 10 | WailsContext *context = (__bridge WailsContext*) inctx; 11 | [context.webview evaluateJavaScript:@"navigator.userAgent" completionHandler:^(NSString *aUserAgent, NSError *aError) { 12 | NSString *sCustomUserAgent = @(newUserAgent); 13 | 14 | if (aUserAgent.length > 0 && aError == nil) { 15 | sCustomUserAgent = [NSString stringWithFormat:@"%@ %@", aUserAgent, sCustomUserAgent]; 16 | } 17 | 18 | context.webview.customUserAgent = sCustomUserAgent; 19 | }]; 20 | } 21 | */ 22 | import "C" 23 | 24 | import ( 25 | "reflect" 26 | "unsafe" 27 | ) 28 | 29 | func addUserAgent(newUserAgent string) { 30 | frontendRef := getFrontendReflected() 31 | mainWindowRef := reflect.Indirect(frontendRef).FieldByName("mainWindow") 32 | contextRef := reflect.Indirect(mainWindowRef).FieldByName("context") 33 | readableContextRef := allowUnexportedFieldAccess(contextRef) 34 | context := readableContextRef.Interface().(unsafe.Pointer) 35 | C.add_user_agent(context, C.CString(newUserAgent)) 36 | } 37 | -------------------------------------------------------------------------------- /backend/wailsextras/patch_user_agent_linux.go: -------------------------------------------------------------------------------- 1 | package wailsextras 2 | 3 | /* 4 | #cgo linux pkg-config: gtk+-3.0 5 | #cgo !webkit2_41 pkg-config: webkit2gtk-4.0 6 | #cgo webkit2_41 pkg-config: webkit2gtk-4.1 7 | 8 | #include 9 | #include 10 | 11 | void add_user_agent(GtkWidget *webview, const char *newUserAgent) { 12 | WebKitSettings *settings = webkit_web_view_get_settings(WEBKIT_WEB_VIEW(webview)); 13 | const gchar * userAgent = webkit_settings_get_user_agent(settings); 14 | gchar *newUserAgentWithApp = g_strconcat(userAgent, " ", newUserAgent, NULL); 15 | webkit_settings_set_user_agent(settings, newUserAgentWithApp); 16 | } 17 | */ 18 | import "C" 19 | 20 | import ( 21 | "reflect" 22 | "unsafe" 23 | ) 24 | 25 | func addUserAgent(newUserAgent string) { 26 | frontendRef := getFrontendReflected() 27 | mainWindowRef := reflect.Indirect(frontendRef).FieldByName("mainWindow") 28 | webviewRef := reflect.Indirect(mainWindowRef).FieldByName("webview") 29 | readableWebviewRef := allowUnexportedFieldAccess(webviewRef) 30 | webview := readableWebviewRef.Interface().(unsafe.Pointer) 31 | C.add_user_agent((*C.GtkWidget)(webview), C.CString(newUserAgent)) 32 | } 33 | -------------------------------------------------------------------------------- /backend/wailsextras/patch_user_agent_noop.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !cgo 2 | 3 | // Cannot cross-platform lint the darwin and linux versions because CGO is disabled when cross-compiling 4 | 5 | package wailsextras 6 | 7 | func addUserAgent(_ string) { 8 | _ = allowUnexportedFieldAccess(getFrontendReflected()) // So that other functions are not marked as unused 9 | panic("this should never be reached") 10 | } 11 | -------------------------------------------------------------------------------- /backend/wailsextras/patch_user_agent_windows.go: -------------------------------------------------------------------------------- 1 | package wailsextras 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | 7 | "github.com/wailsapp/go-webview2/pkg/edge" 8 | ) 9 | 10 | func addUserAgent(newUserAgent string) { 11 | frontendRef := getFrontendReflected() 12 | chromiumRef := reflect.Indirect(frontendRef).FieldByName("chromium") 13 | callbackRef := reflect.Indirect(chromiumRef).FieldByName("WebResourceRequestedCallback") 14 | readableCallbackRef := allowUnexportedFieldAccess(callbackRef) 15 | 16 | prevCallback := readableCallbackRef.Interface().(func(req *edge.ICoreWebView2WebResourceRequest, args *edge.ICoreWebView2WebResourceRequestedEventArgs)) 17 | 18 | readableCallbackRef.Set(reflect.ValueOf(func(req *edge.ICoreWebView2WebResourceRequest, args *edge.ICoreWebView2WebResourceRequestedEventArgs) { 19 | // Setting the UserAgent on the CoreWebView2Settings clears the whole default UserAgent of the Edge browser, but 20 | // we want to just append our ApplicationIdentifier. So we adjust the UserAgent for every request. 21 | if reqHeaders, err := req.GetHeaders(); err == nil { 22 | useragent, _ := reqHeaders.GetHeader("User-Agent") 23 | useragent = strings.Join([]string{useragent, newUserAgent}, " ") 24 | _ = reqHeaders.SetHeader("User-Agent", useragent) 25 | _ = reqHeaders.Release() 26 | } 27 | 28 | prevCallback(req, args) 29 | })) 30 | } 31 | -------------------------------------------------------------------------------- /backend/wailsextras/window.go: -------------------------------------------------------------------------------- 1 | package wailsextras 2 | 3 | import ( 4 | "context" 5 | "runtime" 6 | 7 | wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" 8 | 9 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/utils" 10 | ) 11 | 12 | // WindowSetPosition wraps Wails's WindowSetPosition, 13 | // but ensures that WindowSetPosition(WindowGetPosition()) 14 | // will result in the window remaining stationary 15 | // regardless of OS's behaviour with multiple displays 16 | func WindowSetPosition(ctx context.Context, x, y int) { 17 | if runtime.GOOS == "windows" || runtime.GOOS == "linux" { 18 | // WindowSetPosition expects relative to the current monitor, 19 | // but WindowGetPosition returns absolute 20 | curX, curY := wailsRuntime.WindowGetPosition(ctx) 21 | display := utils.GetDisplayBoundsAt(curX, curY) 22 | x -= display.Min.X 23 | y -= display.Min.Y 24 | } 25 | 26 | // It appears that on darwin Wails gets and sets the position 27 | // with values relative to the current monitor 28 | 29 | wailsRuntime.WindowSetPosition(ctx, x, y) 30 | } 31 | -------------------------------------------------------------------------------- /backend/websocket/websocket.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | 7 | "github.com/spf13/viper" 8 | engineio_types "github.com/zishang520/engine.io/types" 9 | "github.com/zishang520/socket.io/socket" 10 | 11 | "github.com/satisfactorymodding/SatisfactoryModManager/backend/ficsitcli" 12 | ) 13 | 14 | func ListenAndServeWebsocket() { 15 | httpMux := http.NewServeMux() 16 | 17 | httpServer := &http.Server{ 18 | Addr: "localhost:" + viper.GetString("websocket-port"), 19 | Handler: httpMux, 20 | } 21 | 22 | options := &socket.ServerOptions{} 23 | options.SetCors(&engineio_types.Cors{ 24 | Origin: true, // Allow any origin 25 | }) 26 | io := socket.NewServer(nil, options) 27 | httpMux.Handle("/socket.io/", io.ServeHandler(nil)) 28 | 29 | _ = io.On("connection", func(data ...any) { 30 | client := data[0].(*socket.Socket) 31 | _ = client.On("installedMods", func(_ ...any) { 32 | lockfile, err := ficsitcli.FicsitCLI.GetSelectedInstallLockfile() 33 | if err != nil { 34 | slog.Error("failed to get lockfile", slog.Any("error", err)) 35 | return 36 | } 37 | if lockfile == nil { 38 | slog.Error("no lockfile found for websocket call", slog.Any("error", err)) 39 | return 40 | } 41 | installedMods := make(map[string]string) 42 | for modReference, info := range lockfile.Mods { 43 | installedMods[modReference] = info.Version 44 | } 45 | _ = client.Emit("installedMods", installedMods) 46 | }) 47 | }) 48 | 49 | err := httpServer.ListenAndServe() 50 | if err != nil { 51 | slog.Error("failed to start websocket server", slog.Any("err", err)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /build/README.md: -------------------------------------------------------------------------------- 1 | # Build Directory 2 | 3 | The build directory is used to house all the build files and assets for your application. 4 | 5 | The structure is: 6 | 7 | * bin - Output directory 8 | * dialog - Icons for dialogs 9 | * tray - Icons for the system tray 10 | * mac - MacOS specific files 11 | * linux - Linux specific files 12 | * windows - Windows specific files 13 | 14 | ## Dialog Icons 15 | 16 | Place any PNG file in this directory to be able to use them in message dialogs. 17 | The files should have names in the following format: `name[-(light|dark)][2x].png` 18 | 19 | Examples: 20 | 21 | * `mypic.png` - Standard definition icon with ID `mypic` 22 | * `mypic-light.png` - Standard definition icon with ID `mypic`, used when system theme is light 23 | * `mypic-dark.png` - Standard definition icon with ID `mypic`, used when system theme is dark 24 | * `mypic2x.png` - High definition icon with ID `mypic` 25 | * `mypic-light2x.png` - High definition icon with ID `mypic`, used when system theme is light 26 | * `mypic-dark2x.png` - High definition icon with ID `mypic`, used when system theme is dark 27 | 28 | ### Order of preference 29 | 30 | Icons are selected with the following order of preference: 31 | 32 | For High Definition displays: 33 | * name-(theme)2x.png 34 | * name2x.png 35 | * name-(theme).png 36 | * name.png 37 | 38 | For Standard Definition displays: 39 | * name-(theme).png 40 | * name.png 41 | 42 | ## Tray 43 | 44 | Place any PNG file in this directory to be able to use them as tray icons. 45 | The name of the filename will be the ID to reference the image. 46 | 47 | Example: 48 | 49 | * `mypic.png` - May be referenced using `runtime.Tray.SetIcon("mypic")` 50 | 51 | ## Mac 52 | 53 | The `darwin` directory holds files specific to Mac builds, such as `Info.plist`. 54 | These may be customised and used as part of the build. To return these files to the default state, simply delete them and 55 | build with the `-package` flag. 56 | 57 | ## Windows 58 | 59 | The `windows` directory contains the manifest and rc files used when building with the `-package` flag. 60 | These may be customised for your application. To return these files to the default state, simply delete them and 61 | build with the `-package` flag. -------------------------------------------------------------------------------- /build/appicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/appicon.png -------------------------------------------------------------------------------- /build/darwin/Info.dev.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CFBundlePackageType 5 | APPL 6 | CFBundleName 7 | {{.Info.ProductName}} 8 | CFBundleExecutable 9 | {{.Name}} 10 | CFBundleIdentifier 11 | com.wails.{{.Name}} 12 | CFBundleVersion 13 | {{.Info.ProductVersion}} 14 | CFBundleGetInfoString 15 | {{.Info.Comments}} 16 | CFBundleShortVersionString 17 | {{.Info.ProductVersion}} 18 | CFBundleIconFile 19 | iconfile 20 | LSMinimumSystemVersion 21 | 10.13.0 22 | NSHighResolutionCapable 23 | true 24 | NSHumanReadableCopyright 25 | {{.Info.Copyright}} 26 | {{if .Info.FileAssociations}} 27 | CFBundleDocumentTypes 28 | 29 | {{range .Info.FileAssociations}} 30 | 31 | CFBundleTypeExtensions 32 | 33 | {{.Ext}} 34 | 35 | CFBundleTypeName 36 | {{.Name}} 37 | CFBundleTypeRole 38 | {{.Role}} 39 | CFBundleTypeIconFile 40 | {{.IconName}} 41 | 42 | {{end}} 43 | 44 | {{end}} 45 | {{if .Info.Protocols}} 46 | CFBundleURLTypes 47 | 48 | {{range .Info.Protocols}} 49 | 50 | CFBundleURLName 51 | com.wails.{{.Scheme}} 52 | CFBundleURLSchemes 53 | 54 | {{.Scheme}} 55 | 56 | CFBundleTypeRole 57 | {{.Role}} 58 | 59 | {{end}} 60 | 61 | {{end}} 62 | NSAppTransportSecurity 63 | 64 | NSAllowsLocalNetworking 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /build/darwin/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CFBundlePackageType 5 | APPL 6 | CFBundleName 7 | {{.Info.ProductName}} 8 | CFBundleExecutable 9 | {{.Name}} 10 | CFBundleIdentifier 11 | com.wails.{{.Name}} 12 | CFBundleVersion 13 | {{.Info.ProductVersion}} 14 | CFBundleGetInfoString 15 | {{.Info.Comments}} 16 | CFBundleShortVersionString 17 | {{.Info.ProductVersion}} 18 | CFBundleIconFile 19 | iconfile 20 | LSMinimumSystemVersion 21 | 10.13.0 22 | NSHighResolutionCapable 23 | true 24 | NSHumanReadableCopyright 25 | {{.Info.Copyright}} 26 | {{if .Info.FileAssociations}} 27 | CFBundleDocumentTypes 28 | 29 | {{range .Info.FileAssociations}} 30 | 31 | CFBundleTypeExtensions 32 | 33 | {{.Ext}} 34 | 35 | CFBundleTypeName 36 | {{.Name}} 37 | CFBundleTypeRole 38 | {{.Role}} 39 | CFBundleTypeIconFile 40 | {{.IconName}} 41 | 42 | {{end}} 43 | 44 | {{end}} 45 | {{if .Info.Protocols}} 46 | CFBundleURLTypes 47 | 48 | {{range .Info.Protocols}} 49 | 50 | CFBundleURLName 51 | com.wails.{{.Scheme}} 52 | CFBundleURLSchemes 53 | 54 | {{.Scheme}} 55 | 56 | CFBundleTypeRole 57 | {{.Role}} 58 | 59 | {{end}} 60 | 61 | {{end}} 62 | 63 | -------------------------------------------------------------------------------- /build/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/icons/128x128.png -------------------------------------------------------------------------------- /build/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/icons/16x16.png -------------------------------------------------------------------------------- /build/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/icons/256x256.png -------------------------------------------------------------------------------- /build/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/icons/32x32.png -------------------------------------------------------------------------------- /build/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/icons/512x512.png -------------------------------------------------------------------------------- /build/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/icons/64x64.png -------------------------------------------------------------------------------- /build/linux/SatisfactoryModManager.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Satisfactory Mod Manager 3 | Exec=SatisfactoryModManager %u 4 | Terminal=false 5 | Type=Application 6 | Icon=SatisfactoryModManager 7 | MimeType=x-scheme-handler/smmanager; 8 | Categories=Game; -------------------------------------------------------------------------------- /build/linux/appimage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | APPNAME="SatisfactoryModManager" 4 | export ARCH="x86_64" # Export because linuxdeploy gtk plugin copies i386 libraries too, so linuxdeploy can't decide on architecture 5 | SCRIPT_DIR=$(dirname "$0") 6 | BUILD_DIR="$SCRIPT_DIR/.." 7 | 8 | BINARY=$(realpath "$1") 9 | OUTPUT=$2 10 | 11 | TMPDIR=$(mktemp -d) 12 | APPDIR="$SCRIPT_DIR/../bin/$APPNAME.AppDir" 13 | 14 | if [ -d "$APPDIR" ]; then 15 | rm -rf "$APPDIR" 16 | fi 17 | 18 | mkdir -p "$APPDIR" 19 | 20 | mkdir -p "$APPDIR/usr/bin" 21 | mkdir -p "$APPDIR/usr/lib" 22 | mkdir -p "$APPDIR/usr/lib64" 23 | 24 | ( 25 | cd "$APPDIR" || exit 26 | cp "$BINARY" "usr/bin/$APPNAME" 27 | cp "$BUILD_DIR/appicon.png" "$APPNAME.png" 28 | cp "$BUILD_DIR/appicon.png" ".DirIcon" 29 | 30 | icons=(16 32 64 128 256 512) 31 | for i in "${icons[@]}"; do 32 | mkdir -p "usr/share/icons/hicolor/${i}x${i}/apps" 33 | cp "$BUILD_DIR/icons/${i}x${i}.png" "usr/share/icons/hicolor/${i}x${i}/apps/$APPNAME.png" 34 | done 35 | 36 | mkdir -p "usr/share/applications" 37 | cp "$SCRIPT_DIR/$APPNAME.desktop" "usr/share/applications/$APPNAME.desktop" 38 | ln -sf "usr/share/applications/$APPNAME.desktop" "$APPNAME.desktop" 39 | ) 40 | 41 | ( 42 | cd "$APPDIR" || exit 43 | 44 | # Copy webkit2gtk libraries 45 | find -L /usr/lib* -name WebKitNetworkProcess -exec mkdir -p "$(dirname '{}')" \; -exec cp --parents '{}' "." \; || true 46 | find -L /usr/lib* -name WebKitWebProcess -exec mkdir -p "$(dirname '{}')" \; -exec cp --parents '{}' "." \; || true 47 | find -L /usr/lib* -name libwebkit2gtkinjectedbundle.so -exec mkdir -p "$(dirname '{}')" \; -exec cp --parents '{}' "." \; || true 48 | 49 | # Download AppRun 50 | wget -O AppRun https://github.com/AppImage/AppImageKit/releases/download/continuous/AppRun-${ARCH} 51 | chmod +x AppRun 52 | ) 53 | 54 | ( 55 | cd "$TMPDIR" || exit 56 | 57 | wget https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh 58 | chmod +x linuxdeploy-plugin-gtk.sh 59 | 60 | wget https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gstreamer/master/linuxdeploy-plugin-gstreamer.sh 61 | chmod +x linuxdeploy-plugin-gstreamer.sh 62 | 63 | wget -O linuxdeploy.AppImage https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage 64 | chmod +x linuxdeploy.AppImage 65 | ) 66 | 67 | mkdir -p "$(dirname "$OUTPUT")" 68 | 69 | LDAI_OUTPUT="$OUTPUT" DEPLOY_GTK_VERSION="3" "$TMPDIR/linuxdeploy.AppImage" --appimage-extract-and-run --appdir "$APPDIR" --plugin gtk --plugin gstreamer --output appimage 70 | 71 | rm -rf "$TMPDIR" 72 | rm -rf "$APPDIR" 73 | -------------------------------------------------------------------------------- /build/smm2/latest-linux.yml.tmpl: -------------------------------------------------------------------------------- 1 | {{- $buildArtifactName := "SatisfactoryModManager.AppImage" }} 2 | {{- $uploadableArtifactName := "SatisfactoryModManager_linux_amd64.AppImage" }} 3 | {{- /* checksum is set on uploadable binaries, size is set on build binaries */ -}} 4 | {{- $buildArtifact := 0 }} 5 | {{- $uploadableArtifact := 0 }} 6 | {{- range .Artifacts }} 7 | {{- if and (eq .Name $uploadableArtifactName) (eq .Type 2)}}{{- /* type 2 = UploadableBinary */ -}} 8 | {{- $uploadableArtifact = . }} 9 | {{- end }} 10 | {{- if and (eq .Name $buildArtifactName) (eq .Type 4)}}{{- /* type 4 = Binary */ -}} 11 | {{- $buildArtifact = . }} 12 | {{- end }} 13 | {{- end -}} 14 | 15 | version: {{ .Version }} 16 | files: 17 | - url: {{ $uploadableArtifactName }} 18 | sha2: {{ trimprefix $uploadableArtifact.Extra.Checksum "sha256:" }} 19 | size: {{ $buildArtifact.Extra.Size }} 20 | path: {{ $uploadableArtifactName }} 21 | sha2: {{ trimprefix $uploadableArtifact.Extra.Checksum "sha256:" }} 22 | releaseDate: '{{ .Date }}' 23 | -------------------------------------------------------------------------------- /build/smm2/latest.yml.tmpl: -------------------------------------------------------------------------------- 1 | {{- $buildArtifactName := "SatisfactoryModManager-Setup.exe" }} 2 | {{- $uploadableArtifactName := "SatisfactoryModManager-Setup.exe" }} 3 | {{- /* checksum is set on uploadable binaries, size is set on build binaries */ -}} 4 | {{- $buildArtifact := 0 }} 5 | {{- $uploadableArtifact := 0 }} 6 | {{- range .Artifacts }} 7 | {{- if and (eq .Name $uploadableArtifactName) (eq .Type 2)}}{{- /* type 2 = UploadableBinary */ -}} 8 | {{- $uploadableArtifact = . }} 9 | {{- end }} 10 | {{- if and (eq .Name $buildArtifactName) (eq .Type 4)}}{{- /* type 4 = Binary */ -}} 11 | {{- $buildArtifact = . }} 12 | {{- end }} 13 | {{- end -}} 14 | 15 | version: {{ .Version }} 16 | files: 17 | - url: {{ $uploadableArtifactName }} 18 | sha2: {{ trimprefix $uploadableArtifact.Extra.Checksum "sha256:" }} 19 | size: {{ $buildArtifact.Extra.Size }} 20 | path: {{ $uploadableArtifactName }} 21 | sha2: {{ trimprefix $uploadableArtifact.Extra.Checksum "sha256:" }} 22 | releaseDate: '{{ .Date }}' 23 | -------------------------------------------------------------------------------- /build/smmprofile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/smmprofile.png -------------------------------------------------------------------------------- /build/windows/.gitignore: -------------------------------------------------------------------------------- 1 | # winres data file, that we do not need to modify 2 | info.json -------------------------------------------------------------------------------- /build/windows/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/windows/icon.ico -------------------------------------------------------------------------------- /build/windows/icons/smmprofile.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/windows/icons/smmprofile.ico -------------------------------------------------------------------------------- /build/windows/installer/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated on each build 2 | wails_tools.nsh 3 | vi_version.nsh -------------------------------------------------------------------------------- /build/windows/installer/NsisMultiUser/.gitignore: -------------------------------------------------------------------------------- 1 | !*.dll -------------------------------------------------------------------------------- /build/windows/installer/NsisMultiUser/License.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Richard Drizin, Alex Mitev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /build/windows/installer/NsisMultiUser/Plugins/x86-ansi/StdUtils.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/windows/installer/NsisMultiUser/Plugins/x86-ansi/StdUtils.dll -------------------------------------------------------------------------------- /build/windows/installer/NsisMultiUser/Plugins/x86-ansi/UAC.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/windows/installer/NsisMultiUser/Plugins/x86-ansi/UAC.dll -------------------------------------------------------------------------------- /build/windows/installer/NsisMultiUser/Plugins/x86-unicode/StdUtils.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/windows/installer/NsisMultiUser/Plugins/x86-unicode/StdUtils.dll -------------------------------------------------------------------------------- /build/windows/installer/NsisMultiUser/Plugins/x86-unicode/UAC.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/windows/installer/NsisMultiUser/Plugins/x86-unicode/UAC.dll -------------------------------------------------------------------------------- /build/windows/installer/checkRunning.nsh: -------------------------------------------------------------------------------- 1 | !macro CHECK_APP_RUNNING 2 | ; If silent (updating), wait for the app to close 3 | IfSilent 0 +2 4 | Sleep 1000 5 | 6 | ; Retry 7 | StrCpy $R1 0 8 | 9 | loop: 10 | nsExec::Exec `"$SYSDIR\cmd.exe" /c tasklist /FI "IMAGENAME eq ${PROGEXE}" /FO csv | "$SYSDIR\find.exe" "${PROGEXE}"` 11 | Pop $R0 12 | ${If} $R0 == 0 ; No error, running 13 | IfSilent closeProcess 14 | 15 | ${If} $R1 == 0 16 | MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "${PRODUCT_NAME} is running. Press OK to close it." /SD IDOK IDOK closeProcess 17 | ${Else} 18 | MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "Could not stop ${PRODUCT_NAME}. Please close it manually." /SD IDOK IDOK closeProcess 19 | ${EndIf} 20 | Quit 21 | 22 | closeProcess: 23 | ; Abort after 3 attempts if silent 24 | ${If} $R1 > 2 25 | Quit 26 | ${EndIf} 27 | 28 | DetailPrint `Closing "${PROGEXE}"...` 29 | 30 | nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /im "${PROGEXE}"` ; No /F to allow graceful shutdown 31 | Sleep 1000 ; Wait for the process to close 32 | 33 | IntOp $R1 $R1 + 1 34 | 35 | IfSilent 0 loop 36 | ${EndIf} 37 | done: 38 | !macroend -------------------------------------------------------------------------------- /build/windows/installer/utils.nsh: -------------------------------------------------------------------------------- 1 | Function isEmptyDir 2 | # Stack -> # Stack: 3 | Exch $0 # Stack: $0 4 | Push $1 # Stack: $1, $0 5 | FindFirst $0 $1 "$0\*.*" 6 | strcmp $1 "." 0 _notempty 7 | FindNext $0 $1 8 | strcmp $1 ".." 0 _notempty 9 | ClearErrors 10 | FindNext $0 $1 11 | IfErrors 0 _notempty 12 | FindClose $0 13 | Pop $1 # Stack: $0 14 | StrCpy $0 1 15 | Exch $0 # Stack: 1 (true) 16 | goto _end 17 | _notempty: 18 | FindClose $0 19 | ClearErrors 20 | Pop $1 # Stack: $0 21 | StrCpy $0 0 22 | Exch $0 # Stack: 0 (false) 23 | _end: 24 | FunctionEnd -------------------------------------------------------------------------------- /build/windows/installer_version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | var ( 12 | wailsJSONFilePath = "wails.json" 13 | viVersionFilePath = "build/windows/installer/vi_version.nsh" 14 | ) 15 | 16 | type Info struct { 17 | CompanyName string `json:"companyName"` 18 | ProductName string `json:"productName"` 19 | ProductVersion string `json:"productVersion"` 20 | Copyright *string `json:"copyright"` 21 | Comments *string `json:"comments"` 22 | } 23 | 24 | type Project struct { 25 | Name string `json:"name"` 26 | Info Info `json:"info"` 27 | } 28 | 29 | func main() { 30 | wailsJSONFile, err := os.Open(wailsJSONFilePath) 31 | if err != nil { 32 | panic(err) 33 | } 34 | defer wailsJSONFile.Close() 35 | 36 | projectFile, err := io.ReadAll(wailsJSONFile) 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | var project Project 42 | err = json.Unmarshal(projectFile, &project) 43 | if err != nil { 44 | panic(err) 45 | } 46 | 47 | f, err := os.OpenFile(viVersionFilePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o777) 48 | if err != nil { 49 | panic(err) 50 | } 51 | defer f.Close() 52 | 53 | version, _, _ := strings.Cut(project.Info.ProductVersion, "-") 54 | version, _, _ = strings.Cut(version, "+") 55 | 56 | for strings.Count(version, ".") < 3 { 57 | version += ".0" 58 | } 59 | 60 | _, _ = f.WriteString("# DO NOT EDIT - Generated automatically by build/windows/installer_version.go\n\n") 61 | _, _ = f.WriteString(fmt.Sprintf("!define VI_VERSION \"%s\"\n", version)) 62 | } 63 | -------------------------------------------------------------------------------- /build/windows/smmprofile.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/build/windows/smmprofile.ico -------------------------------------------------------------------------------- /build/windows/wails.exe.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | true/pm 12 | permonitorv2,permonitor 13 | 14 | 15 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | // https://cspell.org/configuration/ 2 | { 3 | // Version of the setting file. Always 0.2 4 | "version": "0.2", 5 | // language - current active spelling language 6 | "language": "en", 7 | // words - list of words to be always considered correct 8 | "words": [ 9 | "clazz", 10 | "Favorited", 11 | "ficsit", 12 | "ficsitcli", 13 | "Goland", 14 | "golangci", 15 | "graphqlrc", 16 | "Konami", 17 | "Maximised", 18 | "Minimised", 19 | "mircearoata", 20 | "noclose", 21 | "Nyan", 22 | "smmanager", 23 | "smmprofile", 24 | "SMUI", 25 | "Tolgee", 26 | "Unexpand", 27 | "Unignore", 28 | "urql", 29 | "wailsjs", 30 | "wailsjsdir", 31 | "xsync" 32 | ], 33 | // flagWords - list of words to be always considered incorrect 34 | // This is useful for offensive words and common spelling errors. 35 | // cSpell:disable (don't complain about the words we listed here) 36 | "flagWords": [ 37 | "hte" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /docs/images/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/docs/images/preview.gif -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | VITE_TOLGEE_API_URL=https://translate.ficsit.app 2 | VITE_TOLGEE_API_KEY=tgpak_grpxa5lfgvzdsnluovswknluonrtc3rxnzzw2nttmjww6 3 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | graphql.schema.json 3 | 4 | # Files intended for local dev usage only, ex. .env.local 5 | *.local 6 | 7 | # wails generated files 8 | package.json.md5 9 | src/lib/generated/wailsjs 10 | 11 | # vite temp files 12 | vite.config.ts.timestamp* 13 | 14 | # VS Code 15 | .vscode/* 16 | !.vscode/settings.json 17 | !.vscode/tasks.json 18 | !.vscode/launch.json 19 | !.vscode/extensions.json 20 | !.vscode/*.code-snippets 21 | 22 | # Local History for Visual Studio Code 23 | .history/ 24 | 25 | # Built Visual Studio Code Extensions 26 | *.vsix 27 | 28 | # SvelteKit 29 | .svelte-kit 30 | 31 | # Projects 32 | dist/ 33 | logs/ 34 | .cache 35 | .DS_Store 36 | 37 | # Logs 38 | logs 39 | *.log 40 | npm-debug.log* 41 | yarn-debug.log* 42 | yarn-error.log* 43 | 44 | # Runtime data 45 | pids 46 | *.pid 47 | *.seed 48 | *.pid.lock 49 | 50 | # Directory for instrumented libs generated by jscoverage/JSCover 51 | lib-cov 52 | 53 | # Coverage directory used by tools like istanbul 54 | coverage 55 | 56 | # nyc test coverage 57 | .nyc_output 58 | 59 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 60 | .grunt 61 | 62 | # Bower dependency directory (https://bower.io/) 63 | bower_components 64 | 65 | # node-waf configuration 66 | .lock-wscript 67 | 68 | # Compiled binary addons (https://nodejs.org/api/addons.html) 69 | build/Release 70 | 71 | # Dependency directories 72 | node_modules/ 73 | jspm_packages/ 74 | 75 | # TypeScript v1 declaration files 76 | typings/ 77 | 78 | # Optional npm cache directory 79 | .npm 80 | 81 | # Optional eslint cache 82 | .eslintcache 83 | 84 | # Optional REPL history 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | *.tgz 89 | 90 | # Yarn Integrity file 91 | .yarn-integrity 92 | 93 | # next.js build output 94 | .next 95 | -------------------------------------------------------------------------------- /frontend/.graphqlrc.yml: -------------------------------------------------------------------------------- 1 | schema: 'https://api.ficsit.app/v2/query' 2 | documents: 'src/**/*.graphql' 3 | extensions: 4 | codegen: 5 | overwrite: true 6 | generates: 7 | ./src/lib/generated/graphql/graphql.ts: 8 | plugins: 9 | - add: 10 | content: '/* eslint-disable */' 11 | - 'typescript' 12 | - 'typescript-operations' 13 | - 'typed-document-node' 14 | config: 15 | useTypeImports: true 16 | ./src/lib/generated/graphql/graphql.schema.urql.json: 17 | plugins: 18 | - 'urql-introspection' 19 | config: 20 | module: commonjs 21 | ./graphql.schema.json: 22 | plugins: 23 | - 'introspection' 24 | config: 25 | scalars: 26 | ModID: string 27 | ModReference: string 28 | VersionID: string 29 | TagID: string 30 | AnnouncementID: string 31 | BootstrapVersionID: string 32 | GuideID: string 33 | UserID: string 34 | -------------------------------------------------------------------------------- /frontend/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /frontend/.tolgeerc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://tolgee.io/cli-schema.json", 3 | "projectId": 4, 4 | "apiUrl": "https://translate.ficsit.app", 5 | "patterns": ["./src/**/*.ts?(x)", "./src/**/*.svelte"], 6 | "pull": { 7 | "path": "./src/lib/generated/i18n" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.alwaysShowStatus": true, 3 | "eslint.lintTask.enable": true, 4 | "editor.tabSize": 2, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": "explicit" 7 | }, 8 | "eslint.validate": [ 9 | "javascript", 10 | "svelte" 11 | ], 12 | "javascript.preferences.importModuleSpecifier": "non-relative", 13 | "typescript.preferences.importModuleSpecifier": "non-relative" 14 | } 15 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const importUrl = require('postcss-import-url'); 2 | const postcssPresetEnv = require('postcss-preset-env'); 3 | const tailwindCSS = require('tailwindcss'); 4 | const tailwindCSSNesting = require('tailwindcss/nesting'); 5 | 6 | module.exports = { 7 | plugins: [ 8 | postcssPresetEnv({ 9 | stage: 4, 10 | features: { 11 | 'nesting-rules': true, 12 | }, 13 | }), 14 | 15 | importUrl({ 16 | modernBrowser: true, 17 | }), 18 | tailwindCSSNesting(), 19 | tailwindCSS(), 20 | ], 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/src/gql/announcements/getAnnouncements.graphql: -------------------------------------------------------------------------------- 1 | query GetAnnouncements { 2 | getAnnouncements { 3 | id 4 | message 5 | importance 6 | } 7 | } -------------------------------------------------------------------------------- /frontend/src/gql/healthcheck/healthcheck.graphql: -------------------------------------------------------------------------------- 1 | query SMRHealthcheck { 2 | getMods { 3 | count 4 | } 5 | } -------------------------------------------------------------------------------- /frontend/src/gql/mods/modCount.graphql: -------------------------------------------------------------------------------- 1 | query GetModCount { 2 | getMods { 3 | count 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/gql/mods/modDetails.graphql: -------------------------------------------------------------------------------- 1 | query GetModDetails($modReference: ModReference!) { 2 | mod: getModByReference(modReference: $modReference) { 3 | ...ModKey 4 | name 5 | logo 6 | logo_thumbhash 7 | mod_reference 8 | full_description 9 | created_at 10 | last_version_date 11 | downloads 12 | views 13 | hidden 14 | compatibility { 15 | EA { 16 | state 17 | note 18 | } 19 | EXP { 20 | state 21 | note 22 | } 23 | } 24 | authors { 25 | user { 26 | id 27 | username 28 | avatar 29 | } 30 | role 31 | } 32 | versions(filter: {limit: 100}) { 33 | id 34 | version 35 | size 36 | changelog 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/gql/mods/modKeyFragment.graphql: -------------------------------------------------------------------------------- 1 | fragment ModKey on Mod { 2 | id 3 | mod_reference 4 | } -------------------------------------------------------------------------------- /frontend/src/gql/mods/modName.graphql: -------------------------------------------------------------------------------- 1 | query GetModName($modReference: ModReference!) { 2 | getModByReference(modReference: $modReference) { 3 | ...ModKey 4 | name 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/gql/mods/modNames.graphql: -------------------------------------------------------------------------------- 1 | query GetModNames($modReferences: [String!]!) { 2 | getMods(filter: { references: $modReferences }) { 3 | mods { 4 | ...ModKey 5 | name 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/gql/mods/modReference.graphql: -------------------------------------------------------------------------------- 1 | query GetModReference($modIdOrReference: String!) { 2 | getModByIdOrReference(modIdOrReference: $modIdOrReference) { 3 | ...ModKey 4 | mod_reference 5 | } 6 | } -------------------------------------------------------------------------------- /frontend/src/gql/mods/modSummary.graphql: -------------------------------------------------------------------------------- 1 | query GetModSummary($modReference: ModReference!) { 2 | mod: getModByReference(modReference: $modReference) { 3 | ...ModKey 4 | name 5 | logo 6 | mod_reference 7 | created_at 8 | downloads 9 | views 10 | short_description 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/gql/mods/mods.graphql: -------------------------------------------------------------------------------- 1 | query GetMods($offset: Int!, $limit: Int!) { 2 | getMods(filter: { limit: $limit, offset: $offset }) { 3 | count 4 | mods { 5 | ...ModKey 6 | mod_reference 7 | name 8 | logo 9 | logo_thumbhash 10 | short_description 11 | hidden 12 | popularity 13 | hotness 14 | views 15 | downloads 16 | last_version_date 17 | tags { 18 | id 19 | name 20 | } 21 | authors { 22 | user { 23 | id 24 | username 25 | } 26 | role 27 | } 28 | compatibility { 29 | EA { 30 | state 31 | note 32 | } 33 | EXP { 34 | state 35 | note 36 | } 37 | } 38 | versions { 39 | id 40 | version 41 | game_version 42 | required_on_remote 43 | dependencies { 44 | mod_reference 45 | condition 46 | } 47 | targets { 48 | targetName 49 | } 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/gql/update/getChangelog.graphql: -------------------------------------------------------------------------------- 1 | query GetChangelog($modReference: ModReference!) { 2 | getModByReference(modReference: $modReference) { 3 | ...ModKey 4 | name 5 | versions(filter: { limit: 100 }) { 6 | id 7 | version 8 | changelog 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/gql/versionCompatibility/getModReportedCompatibility.graphql: -------------------------------------------------------------------------------- 1 | query ModReportedCompatibility($modReference: ModReference!) { 2 | getModByReference(modReference: $modReference) { 3 | ...ModKey 4 | compatibility { 5 | EA { 6 | state 7 | note 8 | } 9 | EXP { 10 | state 11 | note 12 | } 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /frontend/src/gql/versionCompatibility/getModVersionsCompatibility.graphql: -------------------------------------------------------------------------------- 1 | query ModVersionsCompatibility($modReference: ModReference!) { 2 | getModByReference(modReference: $modReference) { 3 | ...ModKey 4 | versions { 5 | id 6 | version 7 | game_version 8 | required_on_remote 9 | targets { 10 | targetName 11 | } 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /frontend/src/lib/components/Markdown.svelte: -------------------------------------------------------------------------------- 1 | 70 | 71 | 72 | 73 |
78 | 79 | {@html rendered} 80 |
81 | -------------------------------------------------------------------------------- /frontend/src/lib/components/Marquee.svelte: -------------------------------------------------------------------------------- 1 | 68 | 69 | running = true} 74 | on:mouseout={() => running = false} 75 | on:focus={() => running = true} 76 | on:blur={() => running = false} 77 | > 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ResponsiveButton.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 38 | 39 | {#if visible} 40 |
41 | 52 |
53 | 54 | 55 | {display.tooltip} 56 | {#if display.tooltipMarkdown} 57 | 58 | {/if} 59 | 60 | 61 | {:else} 62 |
63 | {/if} 64 | -------------------------------------------------------------------------------- /frontend/src/lib/components/SVGIcon.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/lib/components/T.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 85 | 86 | {#each contentParts as part} 87 | {#if typeof part === 'string'} 88 | {part} 89 | {:else if 'element' in part.component} 90 | {part.content} 91 | {:else} 92 | {part.content} 93 | {/if} 94 | {/each} -------------------------------------------------------------------------------- /frontend/src/lib/components/Thumbhash.svelte: -------------------------------------------------------------------------------- 1 | 36 |
37 | (imageLoaded = true)} /> 45 | {#if !imageLoaded && thumbHashData} 46 | (thumbnailLoaded = true)} 53 | in:fade={{ duration: 200 }} 54 | out:fade={{ duration: 200, delay: 100 }} /> 55 | {/if} 56 |
-------------------------------------------------------------------------------- /frontend/src/lib/components/TitleBar.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 |
38 | 39 | 40 | 41 |
42 | SMM Icon 43 |
44 | Satisfactory Mod Manager v{$version} 45 |
46 |
47 | 48 | 49 | 50 |
51 | 52 |
53 | 54 | 55 |
56 | 57 |
58 | 59 | 60 |
61 | 62 |
63 |
64 | 65 | 71 | -------------------------------------------------------------------------------- /frontend/src/lib/components/Tooltip.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
14 | 19 | 20 |
21 | -------------------------------------------------------------------------------- /frontend/src/lib/components/VirtualList.svelte: -------------------------------------------------------------------------------- 1 | 61 | 62 |
67 |
71 |
77 | {#each vitems as row, idx (row.index)} 78 |
82 | Missing template 83 |
84 | {/each} 85 |
86 |
87 |
88 | -------------------------------------------------------------------------------- /frontend/src/lib/components/announcements/Announcement.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 |
27 |
28 | 29 |
30 | 31 | 32 | 33 |
34 |
35 |
36 | 37 | 38 | 82 | -------------------------------------------------------------------------------- /frontend/src/lib/components/left-bar/Updates.svelte: -------------------------------------------------------------------------------- 1 | 42 | 43 | 60 | 61 | 76 | -------------------------------------------------------------------------------- /frontend/src/lib/components/mod-details/ModDetailsEntry.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | {label}: 8 | 9 |
10 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/ErrorModal.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
15 | 16 | 17 | 18 |
19 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/ModChangelog.svelte: -------------------------------------------------------------------------------- 1 | 40 | 41 |
42 |
43 | {mod} Changelog'} 45 | keyName="mod-changelog.title" 46 | params={{ mod: modData?.name ?? ' ' }} 47 | parts={[ 48 | translationElementPart('span', { class: !modData?.name ? 'animate-pulse placeholder' : '' }), 49 | ]} 50 | /> 51 |
52 |
53 | {#each changelogs as changelog} 54 |
v{changelog.version}
55 | 56 |
57 | {/each} 58 |
59 |
60 | 65 |
66 |
67 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/ModImage.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | Mod 13 |
14 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/ProgressModal.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 45 | 46 |
47 |
48 | {$progressTitle} 49 |
50 |
51 | {#if $progress} 52 |

{$progressMessage}

53 | 58 | {/if} 59 |
60 |
61 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/first-time-setup/LanguageSelector.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/modalsRegistry.ts: -------------------------------------------------------------------------------- 1 | import type { ModalComponent } from '@skeletonlabs/skeleton'; 2 | 3 | import ProgressModal from './ProgressModal.svelte'; 4 | import ServerManager from './ServerManager.svelte'; 5 | import AddProfile from './profiles/AddProfile.svelte'; 6 | import ImportProfile from './profiles/ImportProfile.svelte'; 7 | import CacheLocationPicker from './settings/CacheLocationPicker.svelte'; 8 | import Proxy from './settings/Proxy.svelte'; 9 | import SMMUpdateDownload from './smmUpdate/SMMUpdateDownload.svelte'; 10 | import SMMUpdateReady from './smmUpdate/SMMUpdateReady.svelte'; 11 | import UpdatesModal from './updates/UpdatesModal.svelte'; 12 | 13 | // We can only store here modals (or modal instances) that do not require additional props 14 | export const modalRegistry = { 15 | progress: { ref: ProgressModal } as ModalComponent, 16 | serverManager: { ref: ServerManager } as ModalComponent, 17 | cacheLocationPicker: { ref: CacheLocationPicker } as ModalComponent, 18 | addProfile: { ref: AddProfile } as ModalComponent, 19 | importProfile: { ref: ImportProfile } as ModalComponent, 20 | modUpdates: { ref: UpdatesModal } as ModalComponent, 21 | smmUpdateDownload: { ref: SMMUpdateDownload } as ModalComponent, 22 | smmUpdateReady: { ref: SMMUpdateReady } as ModalComponent, 23 | proxy: { ref: Proxy } as ModalComponent, 24 | }; 25 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/profiles/AddProfile.svelte: -------------------------------------------------------------------------------- 1 | 37 | 38 |
39 |
40 | 41 |
42 |
43 | 51 |
52 |
53 | 58 | 64 |
65 |
66 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/profiles/DeleteProfile.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 |
27 |
28 | 29 |
30 |
31 | 39 |
40 |
41 | 46 | 51 |
52 |
53 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/profiles/RenameProfile.svelte: -------------------------------------------------------------------------------- 1 | 35 | 36 |
37 |
38 | 39 |
40 |
41 | 50 | 59 |
60 |
61 | 66 | 72 |
73 |
74 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/profiles/addProfile.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | export const newProfileName = writable(''); 4 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/profiles/importProfile.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | export const profileFilepath = writable(''); 4 | export const profileName = writable(''); 5 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/profiles/renameProfile.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | export const newProfileName = writable(''); 4 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/settings/Proxy.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 |
25 |
26 | 27 |
28 |
29 | 47 |
48 |
49 | 56 | 62 |
63 |
64 | 65 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/settings/cacheLocationPicker.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | import { cacheDir } from '$lib/store/settingsStore'; 4 | 5 | export const newCacheLocation = writable(); 6 | cacheDir.subscribe((value) => { 7 | newCacheLocation.set(value); 8 | }); 9 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/smmUpdate/SMMUpdateDownload.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 |
41 |
42 | 43 |
44 | {#if !$smmUpdateReady && $smmUpdateProgress} 45 |
46 |
47 | 48 |
49 | 54 |
55 | = 0 ? secondsToAppropriate(eta) : 'soon™' }} 63 | /> 64 |
65 |
66 | {/if} 67 |
68 | {#each changelogs ?? [] as changelog} 69 |
{changelog.version}
70 | 71 |
72 | {/each} 73 |
74 | {#if !$isUpdateOnStart} 75 |
76 | 81 |
82 | {/if} 83 |
84 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/smmUpdate/SMMUpdateReady.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 |
12 | SMM Update Ready - {$smmUpdate?.version} 13 |
14 |
15 | Update ready to install 16 |
17 |
18 | {#if !$isUpdateOnStart} 19 | 24 | {/if} 25 | 30 |
31 |
32 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/smmUpdate/smmUpdate.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | export const isUpdateOnStart = writable(false); 4 | -------------------------------------------------------------------------------- /frontend/src/lib/components/modals/updates/updatesStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | import type { ficsitcli } from '$wailsjs/go/models'; 4 | 5 | // Because skeleton only ever keeps one modal loaded, we want to store this state between modals 6 | export const selectedUpdates = writable([]); 7 | export const showIgnored = writable(false); 8 | -------------------------------------------------------------------------------- /frontend/src/lib/core/graphql.ts: -------------------------------------------------------------------------------- 1 | import { cacheExchange } from '@urql/exchange-graphcache'; 2 | import { persistedExchange } from '@urql/exchange-persisted'; 3 | import type { Client } from '@urql/svelte'; 4 | import { createClient, fetchExchange } from '@urql/svelte'; 5 | 6 | import { schema } from '$lib/generated'; 7 | 8 | export function initializeGraphQLClient(apiEndpointURL: string): Client { 9 | return createClient({ 10 | url: apiEndpointURL, 11 | exchanges: [ 12 | cacheExchange({ 13 | schema, 14 | keys: { 15 | GetMods: () => null, 16 | LatestVersions: () => null, 17 | UserMod: () => null, 18 | GetGuides: () => null, 19 | OAuthOptions: () => null, 20 | UserRoles: () => null, 21 | Compatibility: () => null, 22 | CompatibilityInfo: () => null, 23 | VersionDependency: () => null, 24 | VersionTarget: () => null, 25 | Mod: (data) => data.mod_reference as string, 26 | }, 27 | resolvers: { 28 | Query: { 29 | getModByReference: (_parent, args) => { 30 | return { __typename: 'Mod', mod_reference: args.modReference }; 31 | }, 32 | }, 33 | }, 34 | }), 35 | persistedExchange({ 36 | preferGetForPersistedQueries: true, 37 | }), 38 | fetchExchange, 39 | ], 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/lib/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './graphql'; 2 | -------------------------------------------------------------------------------- /frontend/src/lib/generated/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !*/ 3 | 4 | !/index.ts 5 | !graphql/index.ts 6 | -------------------------------------------------------------------------------- /frontend/src/lib/generated/graphql/index.ts: -------------------------------------------------------------------------------- 1 | export * from './graphql'; 2 | import schema from './graphql.schema.urql.json'; 3 | 4 | export { schema }; 5 | -------------------------------------------------------------------------------- /frontend/src/lib/generated/index.ts: -------------------------------------------------------------------------------- 1 | export * from './graphql'; 2 | export * from './i18n'; 3 | -------------------------------------------------------------------------------- /frontend/src/lib/localization/index.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from '$lib/generated'; 2 | 3 | interface StringTable extends Record {} 4 | 5 | function allStrings(stringTable: StringTable): (string | null)[] { 6 | return Object.values(stringTable).map((entry) => { 7 | if (!entry) { 8 | return [null]; 9 | } 10 | if (typeof entry === 'string') { 11 | return [entry]; 12 | } 13 | return allStrings(entry); 14 | }).flat(); 15 | } 16 | 17 | export const languages = Object.entries(i18n).map(([lang, stringTable]) => { 18 | const strings = allStrings(stringTable); 19 | return { 20 | languageCode: lang, 21 | stringTable, 22 | name: new Intl.DisplayNames([lang], { type: 'language' }).of(lang) ?? lang, 23 | // TODO flag 24 | completeness: strings.filter((s) => !!s).length / strings.length, 25 | }; 26 | }).filter((lang) => lang.completeness > 0); -------------------------------------------------------------------------------- /frontend/src/lib/skeletonExtensions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './modal'; 2 | export * from './popup'; 3 | -------------------------------------------------------------------------------- /frontend/src/lib/skeletonExtensions/modal.ts: -------------------------------------------------------------------------------- 1 | import { type ModalSettings, type ModalStore as SkeletonModalStore, getModalStore as getSkeletonModalStore } from '@skeletonlabs/skeleton'; 2 | import _ from 'lodash'; 3 | import { getContext, setContext } from 'svelte'; 4 | import { get, writable } from 'svelte/store'; 5 | 6 | const MODAL_STORE_KEY = 'modalStore-extension'; 7 | 8 | type ModalStore = SkeletonModalStore & { 9 | trigger: (modal: ModalSettings, top?: boolean) => void; 10 | triggerUnique: (modal: ModalSettings, top?: boolean) => void; 11 | close: (component: string) => void; 12 | }; 13 | 14 | export function getModalStore(): ModalStore { 15 | const modalStore = getContext(MODAL_STORE_KEY); 16 | 17 | if (!modalStore) { 18 | throw new Error( 19 | 'modalStore is not initialized. Please ensure that `initializeModalStore()` is invoked in the root layout file of this app!', 20 | ); 21 | } 22 | 23 | return modalStore; 24 | } 25 | 26 | /** 27 | * Initializes the `modalStore`. 28 | */ 29 | export function initializeModalStore(): ModalStore { 30 | const modalStore = modalService(getSkeletonModalStore()); 31 | 32 | return setContext(MODAL_STORE_KEY, modalStore); 33 | } 34 | 35 | // For some reason setting the skeleton modalStore too often causes an unreferenced modal to exist in the DOM, 36 | // while the actual modal to be displayed is missing. The modal would show up on the next rerender, but that's weird. 37 | // So we'll use a proxy store that only flushes to the skeleton modalStore at most at 10ms intervals, which seems to not cause the issue. 38 | // 1ms also seems to work, but 10ms is not a noticeable delay. 39 | function modalService(skeletonModalStore: SkeletonModalStore) { 40 | const proxyStore = writable(get(skeletonModalStore)); 41 | 42 | const propagate = _.debounce(() => skeletonModalStore.set(get(proxyStore)), 10); 43 | 44 | return { 45 | // proxies 46 | subscribe: proxyStore.subscribe, 47 | set(mStore: ModalSettings[]) { 48 | proxyStore.set(mStore); 49 | propagate(); 50 | }, 51 | update(fn: (mStore: ModalSettings[]) => ModalSettings[]) { 52 | proxyStore.update(fn); 53 | propagate(); 54 | }, 55 | /** Append to end of queue. */ 56 | trigger(modal: ModalSettings, top = false) { 57 | proxyStore.update((mStore) => { 58 | if (top) { 59 | mStore.unshift(modal); 60 | } else { 61 | mStore.push(modal); 62 | } 63 | return mStore; 64 | }); 65 | propagate(); 66 | }, 67 | /** Remove first item in queue. */ 68 | close(component = '') { 69 | proxyStore.update((mStore) => { 70 | if(component) { 71 | return mStore.filter((m) => m.component !== component); 72 | } 73 | if (mStore.length > 0) mStore.shift(); 74 | return mStore; 75 | }); 76 | propagate(); 77 | }, 78 | /** Remove all items from queue. */ 79 | clear() { 80 | proxyStore.set([]); 81 | propagate(); 82 | }, 83 | 84 | // extensions 85 | triggerUnique(modal: ModalSettings, top = false) { 86 | const index = get(proxyStore).findIndex((m) => _.isEqual(m, modal)); 87 | if (index === -1) { 88 | this.trigger(modal, top); 89 | } 90 | }, 91 | } as ModalStore; 92 | } 93 | -------------------------------------------------------------------------------- /frontend/src/lib/store/actionQueue.ts: -------------------------------------------------------------------------------- 1 | import { queue } from 'async'; 2 | import { derived, get, writable } from 'svelte/store'; 3 | 4 | import { queueAutoStart } from './settingsStore'; 5 | 6 | interface QueuedAction { 7 | mod: string; 8 | action: 'install' | 'remove' | 'enable' | 'disable'; 9 | func: () => Promise; 10 | } 11 | 12 | export const hasPendingProfileChange = writable(false); 13 | 14 | const queuedActionsInternal = writable[]>([]); 15 | export const queuedMods = derived(queuedActionsInternal, (actions) => actions.map((a) => ({ ...a, func: undefined }))); 16 | export const modActionsQueue = queue((task: () => Promise, cb) => { 17 | const complete = (e?: Error) => { 18 | queuedActionsInternal.set(get(queuedActionsInternal).filter((a) => a.func !== task)); 19 | cb(e); 20 | hasPendingProfileChange.set(false); 21 | }; 22 | task().then(() => complete()).catch(complete); 23 | }); 24 | 25 | modActionsQueue.drain(() => { 26 | if(!get(queueAutoStart)) { 27 | modActionsQueue.pause(); 28 | } 29 | }); 30 | 31 | queueAutoStart.subscribe((val) => { 32 | if(val) { 33 | modActionsQueue.resume(); 34 | } else { 35 | modActionsQueue.pause(); 36 | } 37 | }); 38 | 39 | export function startQueue() { 40 | modActionsQueue.resume(); 41 | } 42 | 43 | export async function addQueuedModAction(mod: string, action: string, func: () => Promise): Promise { 44 | const queuedAction = { mod, action, func } as QueuedAction; 45 | queuedActionsInternal.set([ 46 | ...get(queuedActionsInternal), 47 | queuedAction, 48 | ]); 49 | if(get(queueAutoStart)) { 50 | startQueue(); 51 | } 52 | return modActionsQueue.pushAsync(func); 53 | } 54 | 55 | export function removeQueuedModAction(mod: string) { 56 | const queuedAction = get(queuedActionsInternal).find((a) => a.mod === mod); 57 | if(!queuedAction) { 58 | return; 59 | } 60 | modActionsQueue.remove((a) => a.data === queuedAction.func); 61 | queuedActionsInternal.set(get(queuedActionsInternal).filter((a) => a.mod !== mod)); 62 | } 63 | -------------------------------------------------------------------------------- /frontend/src/lib/store/generalStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | export const expandedMod = writable(null as string | null); 4 | export const error = writable(null); 5 | export const isLaunchingGame = writable(false); 6 | export const siteURL = writable('https://ficsit.app/'); 7 | export const hasFetchedMods = writable(false); 8 | -------------------------------------------------------------------------------- /frontend/src/lib/store/settingsStore.ts: -------------------------------------------------------------------------------- 1 | import { binding, bindingTwoWay, bindingTwoWayNoExcept } from './wailsStoreBindings'; 2 | 3 | import type { LaunchButtonType, ViewType } from '$lib/wailsTypesExtensions'; 4 | import { GetVersion } from '$wailsjs/go/app/app'; 5 | import { GetOffline, SetOffline } from '$wailsjs/go/ficsitcli/ficsitCLI'; 6 | import { 7 | GetCacheDir, 8 | GetDebug, 9 | GetIgnoredUpdates, 10 | GetKonami, 11 | GetLanguage, 12 | GetLaunchButton, 13 | GetProxy, 14 | GetQueueAutoStart, 15 | GetRestoreWindowPosition, 16 | GetStartView, 17 | GetUpdateCheckMode, 18 | GetViewedAnnouncements, 19 | SetCacheDir, 20 | SetDebug, 21 | SetKonami, 22 | SetLanguage, 23 | SetLaunchButton, 24 | SetProxy, 25 | SetQueueAutoStart, SetRestoreWindowPosition, 26 | SetStartView, 27 | SetUpdateCheckMode, 28 | } from '$wailsjs/go/settings/settings'; 29 | 30 | export const startView = bindingTwoWayNoExcept(null, { initialGet: GetStartView }, { updateFunction: SetStartView }); 31 | 32 | export const saveWindowPosition = bindingTwoWayNoExcept(true, { initialGet: GetRestoreWindowPosition }, { updateFunction: SetRestoreWindowPosition }); 33 | 34 | export const konami = bindingTwoWayNoExcept(false, { initialGet: GetKonami }, { updateFunction: SetKonami }); 35 | 36 | export const launchButton = bindingTwoWayNoExcept('normal', { initialGet: () => GetLaunchButton().then((l) => l as LaunchButtonType) }, { updateFunction: SetLaunchButton }); 37 | 38 | export const queueAutoStart = bindingTwoWayNoExcept(true, { initialGet: GetQueueAutoStart }, { updateFunction: SetQueueAutoStart }); 39 | 40 | export const offline = bindingTwoWayNoExcept(false, { initialGet: GetOffline }, { updateFunction: SetOffline }); 41 | 42 | export const proxy = bindingTwoWayNoExcept('', { initialGet: GetProxy }, { updateFunction: SetProxy }); 43 | 44 | export const updateCheckMode = bindingTwoWayNoExcept<'launch'|'exit'|'ask'>('launch', { initialGet: GetUpdateCheckMode }, { updateFunction: SetUpdateCheckMode }); 45 | 46 | export const viewedAnnouncements = binding([], { initialGet: GetViewedAnnouncements, updateEvent: 'viewedAnnouncements' }); 47 | 48 | export const ignoredUpdates = binding>({}, { initialGet: GetIgnoredUpdates, updateEvent: 'ignoredUpdates' }); 49 | 50 | export const cacheDir = bindingTwoWay(null, { initialGet: GetCacheDir, updateEvent: 'cacheDir' }, { updateFunction: SetCacheDir }); 51 | 52 | export const version = binding('0.0.0', { initialGet: GetVersion }); 53 | 54 | export const debug = bindingTwoWayNoExcept(false, { initialGet: GetDebug }, { updateFunction: SetDebug }); 55 | 56 | export const language = bindingTwoWayNoExcept('en', { initialGet: () => GetLanguage().then((l) => l ? l : 'en'), allowNull: false }, { updateFunction: SetLanguage }); 57 | -------------------------------------------------------------------------------- /frontend/src/lib/store/smmUpdateStore.ts: -------------------------------------------------------------------------------- 1 | import { compare } from 'semver'; 2 | import { derived, readable } from 'svelte/store'; 3 | 4 | import { binding } from './wailsStoreBindings'; 5 | 6 | import { progressStats } from '$lib/utils/progress'; 7 | import { PendingUpdate } from '$wailsjs/go/autoupdate/autoUpdate'; 8 | import type { autoupdate, utils } from '$wailsjs/go/models'; 9 | import { EventsOn } from '$wailsjs/runtime/runtime'; 10 | 11 | export const smmUpdate = binding(null, { initialGet: PendingUpdate, updateEvent: 'updateAvailable' }); 12 | 13 | export const smmUpdateChangelogs = derived(smmUpdate, ($smmUpdate) => { 14 | return $smmUpdate ? Object.entries($smmUpdate.changelogs).map(([version, changelog]) => ({ version, changelog })).sort((a, b) => -compare(a.version, b.version)) : null; 15 | }); 16 | 17 | export const smmUpdateProgress = binding(null, { updateEvent: 'updateDownloadProgress' }); 18 | 19 | export const smmUpdateProgressStats = progressStats(smmUpdateProgress); 20 | 21 | export const smmUpdateReady = readable(false, (set) => { 22 | EventsOn('updateReady', () => { 23 | set(true); 24 | }); 25 | EventsOn('updateAvailable', () => { 26 | set(false); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /frontend/src/lib/store/wailsStoreBindings.ts: -------------------------------------------------------------------------------- 1 | import { derived, writable } from 'svelte/store'; 2 | 3 | import { EventsOn } from '$wailsjs/runtime/runtime'; 4 | 5 | function oneWayWritableBinding(defaultValue: D, mainToRenderer: { 6 | initialGet?: () => Promise 7 | updateEvent?: string, 8 | allowNull?: boolean, 9 | }) { 10 | const { updateEvent, allowNull, initialGet } = { 11 | allowNull: true, 12 | ...mainToRenderer, 13 | }; 14 | 15 | const initialized = writable(false); 16 | 17 | const { subscribe, set } = writable(defaultValue as T | D); 18 | 19 | const setData = (data: T) => { 20 | if(data === null && !allowNull) { 21 | set(defaultValue); 22 | } else { 23 | set(data); 24 | } 25 | }; 26 | 27 | if (updateEvent) { 28 | EventsOn(updateEvent, setData); 29 | } 30 | 31 | if(initialGet) { 32 | initialGet().then(setData).then(() => initialized.set(true)); 33 | } else { 34 | initialized.set(true); 35 | } 36 | 37 | return { 38 | subscribe, 39 | isInit: derived(initialized, (i) => i), 40 | set: setData, 41 | }; 42 | } 43 | 44 | export function binding(defaultValue: T, 45 | mainToRenderer: { 46 | initialGet?: () => Promise 47 | updateEvent?: string, 48 | allowNull?: boolean, 49 | }, 50 | ) { 51 | const { subscribe, isInit } = oneWayWritableBinding(defaultValue, mainToRenderer); 52 | 53 | return { 54 | subscribe, 55 | isInit, 56 | }; 57 | } 58 | 59 | export function bindingTwoWay(defaultValue: D, 60 | mainToRenderer: { 61 | initialGet?: () => Promise 62 | updateEvent?: string, 63 | allowNull?: boolean, 64 | }, 65 | rendererToMain: { 66 | updateFunction: (value: T) => Promise, 67 | }, 68 | ) { 69 | const { subscribe, isInit, set } = oneWayWritableBinding(defaultValue, mainToRenderer); 70 | const { updateFunction } = rendererToMain; 71 | 72 | return { 73 | subscribe, 74 | isInit, 75 | asyncSet: async (value: T) => { 76 | set(value); 77 | await updateFunction(value); 78 | }, 79 | }; 80 | } 81 | 82 | export function bindingTwoWayNoExcept(defaultValue: T, 83 | mainToRenderer: { 84 | initialGet?: () => Promise 85 | updateEvent?: string, 86 | allowNull?: boolean, 87 | }, 88 | rendererToMain: { 89 | updateFunction: (value: T) => Promise, 90 | }, 91 | ) { 92 | const { subscribe, isInit, set } = oneWayWritableBinding(defaultValue, mainToRenderer); 93 | const { updateFunction } = rendererToMain; 94 | 95 | return { 96 | subscribe, 97 | isInit, 98 | set: (value: T) => { 99 | set(value); 100 | updateFunction(value); // must not throw 101 | }, 102 | }; 103 | } 104 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/dataFormats.ts: -------------------------------------------------------------------------------- 1 | export const largeNumberFormat = Intl.NumberFormat(undefined, { notation: 'compact' }).format; 2 | 3 | // We cannot use the units mode of NumberFormat, since it is not aware of different names for larger units 4 | // For 1 TB, it uses 1 BB (1 billion bytes), and for 1000 seconds it uses 1Ks (1 thousand seconds) 5 | 6 | export function roundWithDecimals(number: number, decimals = 0): number { 7 | return Math.round(number * (10 ** decimals)) / (10 ** decimals); 8 | } 9 | 10 | const sizeRanges = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 11 | 12 | export function bytesToAppropriate(bytes: number): string { 13 | let rangeNum = 0; 14 | while (bytes >= 1024 ** (rangeNum + 1)) { 15 | rangeNum += 1; 16 | } 17 | return `${roundWithDecimals(bytes / (1024 ** rangeNum), 2).toFixed(2)} ${sizeRanges[rangeNum]}`; 18 | } 19 | 20 | const timeRanges = { 21 | sec: 1, 22 | min: 60, 23 | h: 60 * 60, 24 | days: 60 * 60 * 24, 25 | }; 26 | 27 | export function secondsToAppropriate(seconds: number): string { 28 | const ranges = Object.keys(timeRanges) as (keyof (typeof timeRanges))[]; 29 | let rangeNum = 0; 30 | while (rangeNum < ranges.length - 1 && seconds >= timeRanges[ranges[rangeNum + 1]]) { 31 | rangeNum += 1; 32 | } 33 | return `${roundWithDecimals(seconds / timeRanges[ranges[rangeNum]], 0)}${ranges[rangeNum]}`; 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/getModAuthor.ts: -------------------------------------------------------------------------------- 1 | export function getAuthor(mod?: { authors: { role: string, user: { username: string } }[] } | null): string | undefined { 2 | return mod ? mod.authors.filter((author) => author.role === 'creator')[0]?.user?.username : undefined; 3 | } -------------------------------------------------------------------------------- /frontend/src/lib/utils/interval.ts: -------------------------------------------------------------------------------- 1 | export function setIntervalImmediate(handler: () => void, timeout: number): ReturnType { 2 | handler(); 3 | return setInterval(handler, timeout); 4 | } -------------------------------------------------------------------------------- /frontend/src/lib/utils/markdown.ts: -------------------------------------------------------------------------------- 1 | import DOMPurify from 'dompurify'; 2 | import { marked } from 'marked'; 3 | 4 | export const markdown = (md: string): string => { 5 | return DOMPurify.sanitize(marked(md) as string); 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/progress.ts: -------------------------------------------------------------------------------- 1 | import { type Readable, get, writable } from 'svelte/store'; 2 | 3 | import { timeSeries } from './timeSeries'; 4 | 5 | import type { utils } from '$wailsjs/go/models'; 6 | 7 | export function progressStats(progress: Readable, options?: { statsInterval?: number; updateInterval?: { speed?: number; eta?: number } }): Readable<{ speed: number; eta: number | undefined }> { 8 | const finalOptions = { 9 | statsInterval: 5000, 10 | ...options, 11 | updateInterval: { 12 | speed: 0, 13 | eta: 0, 14 | ...options?.updateInterval, 15 | }, 16 | }; 17 | 18 | const series = timeSeries(finalOptions.statsInterval); 19 | 20 | const stats = writable({ speed: 0, eta: 0 as number | undefined }); 21 | const lastStatsUpdate = { speed: 0, eta: 0 }; 22 | 23 | progress.subscribe(($progress) => { 24 | if (!$progress) { 25 | series.clear(); 26 | } else { 27 | if ((series.getLast() ?? -1) > $progress.current) { 28 | series.clear(); 29 | } 30 | series.addValue($progress.current); 31 | 32 | const speed = series.getDerivative() ?? 0; 33 | const eta = speed !== 0 ? ($progress.total - $progress.current) / speed : undefined; 34 | 35 | if (Date.now() - lastStatsUpdate.speed > finalOptions.updateInterval.speed) { 36 | stats.set({ speed: speed, eta: get(stats).eta }); 37 | lastStatsUpdate.speed = Date.now(); 38 | } 39 | if (Date.now() - lastStatsUpdate.eta > finalOptions.updateInterval.eta) { 40 | stats.set({ speed: get(stats).speed, eta: eta }); 41 | lastStatsUpdate.eta = Date.now(); 42 | } 43 | } 44 | }); 45 | 46 | return stats; 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/timeSeries.ts: -------------------------------------------------------------------------------- 1 | export interface TimeSeries { 2 | addValue: (value: number) => void; 3 | getAverage: () => number; 4 | getDerivative: () => number | undefined; 5 | getLast: () => number | undefined; 6 | clear: () => void; 7 | } 8 | 9 | export function timeSeries(millisecondsLifetime: number): TimeSeries { 10 | const items: { value: number, timestamp: number }[] = []; 11 | return { 12 | addValue: (value: number) => { 13 | items.push({ value, timestamp: Date.now() }); 14 | setTimeout(() => { 15 | items.shift(); 16 | }, millisecondsLifetime); 17 | }, 18 | getAverage: () => { 19 | return items.reduce((a, b) => a + b.value, 0) / items.length; 20 | }, 21 | getDerivative: () => { 22 | return (items[items.length - 1].value - items[0].value) / ((items[items.length - 1].timestamp - items[0].timestamp) / 1000); // per second 23 | }, 24 | getLast: () => { 25 | return items.length > 0 ? items[items.length - 1]?.value : undefined; 26 | }, 27 | clear: () => { 28 | items.length = 0; 29 | }, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/lib/wailsTypesExtensions.ts: -------------------------------------------------------------------------------- 1 | import { TargetName } from './generated'; 2 | 3 | import { common } from '$wailsjs/go/models'; 4 | 5 | export type ViewType = 'compact' | 'expanded'; 6 | 7 | export type LaunchButtonType = 'normal' | 'cat' | 'button'; 8 | 9 | export function installTypeToTargetName(installType: common.InstallType): TargetName { 10 | switch(installType) { 11 | case common.InstallType.WINDOWS: 12 | return TargetName.Windows; 13 | case common.InstallType.WINDOWS_SERVER: 14 | return TargetName.WindowsServer; 15 | case common.InstallType.LINUX_SERVER: 16 | return TargetName.LinuxServer; 17 | default: 18 | throw new Error('Invalid install type'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import App from './App.svelte'; 2 | 3 | import { GetAPIEndpoint, GetSiteEndpoint } from '$wailsjs/go/app/app'; 4 | 5 | const app = new App({ 6 | target: document.getElementById('app')!, 7 | props: { 8 | apiEndpointURL: await GetAPIEndpoint(), 9 | siteEndpointURL: await GetSiteEndpoint(), 10 | }, 11 | }); 12 | 13 | export default app; 14 | -------------------------------------------------------------------------------- /frontend/src/types/svelte-carousel.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'svelte-carousel' { 2 | import type { SvelteComponentTyped } from 'svelte'; 3 | 4 | interface CarouselProps { 5 | /** 6 | * Enables next/prev arrows 7 | */ 8 | arrows?: boolean; 9 | /** 10 | * Infinite looping 11 | */ 12 | infinite?: boolean; 13 | /** 14 | * Page to start on 15 | */ 16 | initialPageIndex?: number; 17 | /** 18 | * Transition duration (ms) 19 | */ 20 | duration?: number; 21 | /** 22 | * Enables autoplay of pages 23 | */ 24 | autoplay?: boolean; 25 | /** 26 | * Autoplay change interval (ms) 27 | */ 28 | autoplayDuration?: number; 29 | /** 30 | * Autoplay change direction (next or prev) 31 | */ 32 | autoplayDirection?: 'next' | 'prev'; 33 | /** 34 | * Pauses on focus (for touchable devices - tap the carousel to toggle the autoplay, for non-touchable devices - hover over the carousel to pause the autoplay) 35 | */ 36 | pauseOnFocus?: boolean; 37 | /** 38 | * Shows autoplay duration progress indicator 39 | */ 40 | autoplayProgressVisible?: boolean; 41 | /** 42 | * Current indicator dots 43 | */ 44 | dots?: boolean; 45 | /** 46 | * CSS animation timing function 47 | */ 48 | timingFunction?: string; 49 | /** 50 | * swiping 51 | */ 52 | swiping?: boolean; 53 | /** 54 | * Number elements to show 55 | */ 56 | particlesToShow?: number; 57 | /** 58 | * Number of elements to scroll 59 | */ 60 | particlesToScroll?: number; 61 | } 62 | 63 | interface CarouselEvents { 64 | pageChange: CustomEvent; 65 | } 66 | 67 | interface CarouselSlots { 68 | prev: { 69 | showPrevPage: () => void; 70 | }; 71 | next: { 72 | showNextPage: () => void; 73 | }; 74 | dots: { 75 | showPage: (pageIndex: number) => void; 76 | currentPageIndex: number; 77 | pagesCount: number; 78 | }; 79 | default: { 80 | showPrevPage: () => void; 81 | showNextPage: () => void; 82 | currentPageIndex: number; 83 | pagesCount: number; 84 | showPage: (pageIndex: number) => void; 85 | loaded: number[]; 86 | }; 87 | } 88 | 89 | export default class Carousel extends SvelteComponentTyped< 90 | CarouselProps, 91 | CarouselEvents, 92 | CarouselSlots 93 | > { 94 | goTo(pageIndex: number, options?: { animated?: boolean }): Promise; 95 | goToPrev(options?: { animated?: boolean }): Promise; 96 | goToNext(options?: { animated?: boolean }): Promise; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /frontend/static/images/launch/cat/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/frontend/static/images/launch/cat/bg.png -------------------------------------------------------------------------------- /frontend/static/images/launch/cat/cat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/frontend/static/images/launch/cat/cat.png -------------------------------------------------------------------------------- /frontend/static/images/launch/cat/cat_full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/frontend/static/images/launch/cat/cat_full.png -------------------------------------------------------------------------------- /frontend/static/images/launch/cat/sec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/frontend/static/images/launch/cat/sec.png -------------------------------------------------------------------------------- /frontend/static/images/launch/fun/launch_fun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/frontend/static/images/launch/fun/launch_fun.png -------------------------------------------------------------------------------- /frontend/static/images/launch/fun/launch_fun_button_normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/frontend/static/images/launch/fun/launch_fun_button_normal.png -------------------------------------------------------------------------------- /frontend/static/images/launch/fun/launch_fun_button_over.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/frontend/static/images/launch/fun/launch_fun_button_over.png -------------------------------------------------------------------------------- /frontend/static/images/launch/fun/launch_fun_button_press.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/frontend/static/images/launch/fun/launch_fun_button_press.png -------------------------------------------------------------------------------- /frontend/static/images/no_image.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/frontend/static/images/no_image.webp -------------------------------------------------------------------------------- /frontend/static/images/smm_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/frontend/static/images/smm_icon.png -------------------------------------------------------------------------------- /frontend/static/images/smm_icon_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/frontend/static/images/smm_icon_small.png -------------------------------------------------------------------------------- /frontend/svelte.config.js: -------------------------------------------------------------------------------- 1 | import preprocess from 'svelte-preprocess'; 2 | 3 | /** @type {import('@sveltejs/kit').Config} */ 4 | const config = { 5 | // Consult https://github.com/sveltejs/svelte-preprocess 6 | // for more information about preprocessors 7 | preprocess: preprocess({ 8 | postcss: true, 9 | }), 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | import { skeleton } from '@skeletonlabs/tw-plugin'; 4 | import containerQueries from '@tailwindcss/container-queries'; 5 | import type { Config } from 'tailwindcss'; 6 | 7 | import { myCustomTheme } from './smmTheme'; 8 | 9 | const config = { 10 | darkMode: 'class', 11 | content: [ 12 | './src/**/*.{html,js,svelte,ts}', 13 | join(require.resolve( 14 | '@skeletonlabs/skeleton'), 15 | '../**/*.{html,js,svelte,ts}', 16 | ), 17 | ], 18 | theme: { 19 | extend: { 20 | screens: { 21 | 'h-md': { raw: '(min-height: 875px)' }, 22 | 'h-lg': { raw: '(min-height: 950px)' }, 23 | }, 24 | }, 25 | }, 26 | plugins: [ 27 | containerQueries, 28 | skeleton({ 29 | themes: { 30 | custom: [ 31 | myCustomTheme, 32 | ], 33 | }, 34 | }), 35 | ], 36 | } satisfies Config; 37 | 38 | export default config; 39 | -------------------------------------------------------------------------------- /frontend/tools/translations.cjs: -------------------------------------------------------------------------------- 1 | const child_process = require('child_process'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | require('dotenv').config(); 5 | 6 | const generatedDir = path.join(__dirname, '../src/lib/generated'); 7 | const i18nDir = path.join(generatedDir, 'i18n'); 8 | const i18nFile = path.join(i18nDir, 'index.ts'); 9 | 10 | if (fs.existsSync(i18nDir)) { 11 | console.log('Clearing i18n directory'); 12 | fs.rmSync(i18nDir, { recursive: true }); 13 | fs.mkdirSync(i18nDir); 14 | } 15 | 16 | console.log('Pulling translations'); 17 | child_process.execSync(`pnpm translations:pull -ak ${process.env.VITE_TOLGEE_API_KEY}`, { stdio: 'inherit' }); 18 | 19 | const langs = fs.readdirSync(i18nDir).map(file => file.replace('.json', '')); 20 | console.log('Languages:', langs.join(', ')); 21 | 22 | const fileContent = '/* eslint-disable */\n' 23 | + langs.map(lang => `import ${lang.replace('-', '_')} from './${lang}.json';`).join('\n') 24 | + '\n\n' 25 | + 'export const i18n = {\n' 26 | + langs.map(lang => ` "${lang}": ${lang.replace('-', '_')},`).join('\n') 27 | + '\n};\n'; 28 | 29 | fs.writeFileSync(i18nFile, fileContent); 30 | console.log('Translations generated'); 31 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "paths": { 13 | "$lib": [ 14 | "./src/lib" 15 | ], 16 | "$lib/*": [ 17 | "./src/lib/*" 18 | ], 19 | "$wailsjs": [ 20 | "./src/lib/generated/wailsjs" 21 | ], 22 | "$wailsjs/*": [ 23 | "./src/lib/generated/wailsjs/*" 24 | ] 25 | }, 26 | "types": ["vite/client"], 27 | }, 28 | "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "*.ts"], 29 | "exclude": ["src/lib/generated/wailsjs/**/*"] 30 | } 31 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import { svelte } from '@sveltejs/vite-plugin-svelte'; 4 | import { defineConfig } from 'vite'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | svelte({ 10 | hot: true, 11 | onwarn: (warning, defaultHandler) => { 12 | if (warning.code === 'a11y-click-events-have-key-events') { 13 | return; 14 | } 15 | if (defaultHandler) { 16 | defaultHandler(warning); 17 | } 18 | }, 19 | }), 20 | ], 21 | optimizeDeps: { 22 | exclude: ['@urql/svelte'], 23 | include: ['lodash.get', 'lodash.isequal', 'lodash.clonedeep'], 24 | }, 25 | publicDir: 'static', 26 | resolve: { 27 | alias: { 28 | $wailsjs: path.resolve('./src/lib/generated/wailsjs'), 29 | $lib: path.resolve('./src/lib'), 30 | }, 31 | }, 32 | build: { 33 | outDir: 'build', 34 | target: 'esnext', 35 | }, 36 | server: { 37 | port: 3000, 38 | strictPort: true, 39 | watch: { 40 | ignored: [ 41 | '**/generated/wailsjs/runtime/*', 42 | ], 43 | }, 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/icons/128x128.png -------------------------------------------------------------------------------- /icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/icons/16x16.png -------------------------------------------------------------------------------- /icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/icons/256x256.png -------------------------------------------------------------------------------- /icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/icons/32x32.png -------------------------------------------------------------------------------- /icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/icons/512x512.png -------------------------------------------------------------------------------- /icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/icons/64x64.png -------------------------------------------------------------------------------- /icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/SatisfactoryModManager/26f8a7e283d8259bbecac331a61101e59925328d/icons/icon.ico -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | package main 4 | 5 | //go:generate go run ./build/windows/installer_version.go 6 | -------------------------------------------------------------------------------- /wails.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://wails.io/schemas/config.v2.json", 3 | "name": "SatisfactoryModManager", 4 | "outputfilename": "SatisfactoryModManager", 5 | "frontend:install": "pnpm install", 6 | "frontend:build": "pnpm build", 7 | "frontend:dev:watcher": "pnpm dev", 8 | "frontend:dev:serverUrl": "http://localhost:3000", 9 | "wailsjsdir": "frontend/src/lib/generated", 10 | "author": { 11 | "name": "mircearoata", 12 | "email": "mircearoatapalade@gmail.com" 13 | }, 14 | "info": { 15 | "productVersion": "3.0.3", 16 | "fileAssociations": [ 17 | { 18 | "ext": "smmprofile", 19 | "name": "Satisfactory Mod Manager Profile", 20 | "iconName": "smmprofile" 21 | } 22 | ], 23 | "protocols": [ 24 | { 25 | "scheme": "smmanager", 26 | "description": "Satisfactory Mod Manager" 27 | } 28 | ] 29 | }, 30 | "bindings": { 31 | "ts_generation": { 32 | "outputType": "interfaces" 33 | } 34 | } 35 | } 36 | --------------------------------------------------------------------------------