Compare commits

..

208 Commits

Author SHA1 Message Date
Novattz 1b8fdadbf2 version bump 2026-03-13 14:54:52 +00:00
Novattz ecd7b4dceb changelog 2026-03-13 14:54:15 +00:00
Novattz 640eb9a0d5 add types node to tsconfig 2026-03-13 14:51:50 +00:00
Novattz b42086ca27 Manually add DLC dialog #99 2026-03-13 14:51:33 +00:00
Novattz b9beb0d704 Fix steam api being nested too deep #100 2026-03-13 14:26:46 +00:00
Novattz 09e7bcac6f bump version 2026-01-18 09:43:08 +01:00
Novattz b7f219a25f changelog 2026-01-18 09:43:02 +01:00
Novattz 2b205d8376 reduce time to detect game bitness 2026-01-18 09:42:58 +01:00
Novattz 4cf1e2caf4 version bump 2026-01-17 20:49:22 +01:00
Novattz 0ee10d07fc changelog 2026-01-17 20:48:44 +01:00
Novattz 365063d30d fix notifications for smokeapi install 2026-01-17 20:48:15 +01:00
Novattz 61ad3f1d54 fix notifications 2026-01-17 20:30:29 +01:00
Novattz d3a91f5722 fix conflict detection 2026-01-17 20:30:14 +01:00
Novattz 9ba307f9f8 fix typo ELF magic number check 2026-01-17 20:29:54 +01:00
Novattz 1123012737 install smokeapi native #61 2026-01-17 17:58:14 +01:00
Novattz 7a07399946 cache validation 2026-01-17 17:57:49 +01:00
Novattz 40b9ec9b01 bitness detection 2026-01-17 17:57:17 +01:00
Novattz 05e4275962 unlocker selection styling #61 2026-01-17 17:56:57 +01:00
Novattz 03cae08df1 implement unlocker selection #61 2026-01-17 17:56:46 +01:00
Novattz 6b16ec6168 hook index 2026-01-17 17:56:20 +01:00
Novattz a786530572 game action hook 2026-01-17 17:56:07 +01:00
Novattz ef7dfdd6c5 unlocker select hook #61 2026-01-17 17:55:40 +01:00
Novattz 5998e77272 unlocker select dialog #61 2026-01-17 17:55:09 +01:00
Novattz fab29f5778 change download icon 2026-01-17 17:54:38 +01:00
Novattz bec190691b universal button 2026-01-17 17:54:04 +01:00
Novattz 58217d61d1 changelog 2026-01-09 20:44:10 +01:00
Novattz 0f4db7bbb7 gitignore 2026-01-09 20:44:02 +01:00
Novattz 22c8f41f93 bump version 2026-01-09 20:41:11 +01:00
Novattz 5ff51d1174 Remove reminder #92 2026-01-09 20:40:35 +01:00
Novattz 169b7d5edd redesign conflict dialog #92 2026-01-09 20:37:55 +01:00
Novattz 41da6731a7 update workflow 2026-01-03 00:37:31 +01:00
Novattz 5f8f389687 version bump 2026-01-03 00:31:25 +01:00
Novattz 1d8422dc65 changelog 2026-01-03 00:31:01 +01:00
Novattz 677e3ef12d disclaimer hook #87 2026-01-03 00:26:23 +01:00
Novattz 33266f3781 index #87 2026-01-03 00:26:00 +01:00
Novattz 9703f21209 disclaimer dialog & styles #87 2026-01-03 00:25:40 +01:00
Novattz 3459158d3f config types #88 2026-01-03 00:24:56 +01:00
Novattz 418b470d4a format 2026-01-03 00:24:23 +01:00
Novattz fd606cbc2e config manager #88 2026-01-03 00:23:47 +01:00
Tickbase 5845cf9bd8 Update README for clarity and corrections 2026-01-02 19:57:25 +01:00
Tickbase 6294b99a14 Update LICENSE.md 2026-01-01 21:44:50 +01:00
Novattz 595fe53254 version bump & changelog 2025-12-26 22:12:02 +01:00
Novattz 3801404138 index & hook #89 2025-12-26 22:11:44 +01:00
Novattz 919749d0ae conflict & reminder dialogs & styles #89 2025-12-26 22:11:07 +01:00
Novattz d4ae5d74e9 conflict backend stuff #89 2025-12-26 22:10:34 +01:00
Novattz 7fd3147f44 apperantly not a valid flag 2025-12-23 03:04:47 +01:00
Novattz 87dc328434 changelog 2025-12-23 03:01:42 +01:00
Novattz b227dff339 version bump 2025-12-23 03:01:28 +01:00
Novattz 04910e84cf Add response if we got any new dlcs or not #64 2025-12-23 02:59:12 +01:00
Novattz 7960019cd9 update creamlinux config #64 2025-12-23 02:42:19 +01:00
Novattz a00cc92b70 adjust settings dialog 2025-12-23 02:00:09 +01:00
Novattz 85520f8916 add settings button to game cards with smokeapi installed #67 2025-12-23 01:59:53 +01:00
Novattz ac96e7be69 smokeapi config backend implementation #67 2025-12-23 01:59:06 +01:00
Novattz 3675ff8fae add smokeapi settings dialog & styling #67 2025-12-23 01:58:30 +01:00
Novattz ab057b8d10 add dropdown component 2025-12-23 01:57:26 +01:00
Novattz 952749cc93 fix depraction warning 2025-12-23 01:56:46 +01:00
Tickbase 4c4e087be7 Merge pull request #86 from Novattz/dependabot/npm_and_yarn/multi-ed0ec66f32
Bump glob and semantic-release
2025-12-22 22:04:41 +01:00
dependabot[bot] 1e52c2071c Bump glob and semantic-release
Bumps [glob](https://github.com/isaacs/node-glob) and [semantic-release](https://github.com/semantic-release/semantic-release). These dependencies needed to be updated together.

Updates `glob` from 11.0.2 to 11.1.0
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v11.0.2...v11.1.0)

Updates `semantic-release` from 24.2.4 to 25.0.2
- [Release notes](https://github.com/semantic-release/semantic-release/releases)
- [Commits](https://github.com/semantic-release/semantic-release/compare/v24.2.4...v25.0.2)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 11.1.0
  dependency-type: direct:development
- dependency-name: semantic-release
  dependency-version: 25.0.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 21:04:04 +00:00
Tickbase fc8c69a915 Merge pull request #85 from Novattz/dependabot/npm_and_yarn/js-yaml-4.1.1
Bump js-yaml from 4.1.0 to 4.1.1
2025-12-22 22:02:31 +01:00
dependabot[bot] 2d7077a05b Bump js-yaml from 4.1.0 to 4.1.1
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 20:52:35 +00:00
Tickbase 081d61afc7 Merge pull request #84 from Novattz/dependabot/npm_and_yarn/vite-6.4.1 2025-12-22 20:32:44 +01:00
dependabot[bot] 0bfd36aea9 Bump vite from 6.3.5 to 6.4.1
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.4.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@6.4.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 19:31:54 +00:00
Tickbase d730fe61ae Delete .github/workflows/test-build.yml 2025-12-22 20:22:45 +01:00
Novattz f657c18c54 changelog 2025-12-22 20:21:47 +01:00
Novattz 4bf0d1819d workflow change 2025-12-22 20:21:41 +01:00
Novattz a05cce1c18 version bump 2025-12-22 20:21:32 +01:00
Novattz ae7dd9dbd0 version bump script 2025-12-22 20:21:19 +01:00
Novattz c484c8958c Creamlinux Refactor 2025-12-22 20:21:06 +01:00
Novattz 6f4f53f7f5 styling changes 2025-12-22 20:20:49 +01:00
Novattz cbf791a348 button and icon color fixes 2025-12-22 20:20:38 +01:00
Novattz 9397e1508f Button fixes 2025-12-22 20:20:22 +01:00
Novattz 294ab81211 Toast fixes & styling 2025-12-22 20:20:07 +01:00
Novattz 7a631543fa Icon colors 2025-12-22 20:19:45 +01:00
Novattz e0a62d72d4 New icons 2025-12-22 20:19:25 +01:00
Tickbase e54c71abed Add GitHub Actions workflow for Tauri test build 2025-12-22 12:26:06 +01:00
Tickbase e646858e43 Merge pull request #77 from sw4m/patch-1
Fixed git clone link in README
2025-12-13 17:14:57 +01:00
sw4m dc7c2682cf Fixed git clone link 2025-12-13 11:22:08 +00:00
Novattz 08282c8a22 fix progress bar color 2025-11-16 20:39:02 +01:00
Novattz 308b284d17 release title adjustment 2025-11-12 15:22:23 +01:00
Novattz 51c6b7337b version bump 2025-11-12 15:07:18 +01:00
Novattz bb73d535ce workflow 2025-11-12 15:05:22 +01:00
Novattz 38f536bc1c index and update screen 2025-11-12 15:04:17 +01:00
Novattz 686a5219eb Dynamically fetch version 2025-11-12 15:03:32 +01:00
Novattz 9f3cf1cb1f add progress bar component and styling 2025-11-12 15:03:07 +01:00
Novattz 0a5f00d3fb Remove redundant files 2025-11-12 15:02:13 +01:00
Novattz 931ecc0d92 implement updater 2025-11-12 15:01:19 +01:00
Novattz f7f70a0b8a add permissions 2025-11-12 15:00:45 +01:00
Novattz d280c6c5f3 Add tauri updater 2025-11-12 15:00:32 +01:00
Novattz 9bbe1c7de8 enable tracking again lol 2025-11-11 15:37:54 +01:00
Novattz 18a51e37a1 version bump 2025-11-11 15:36:41 +01:00
Novattz 1eb8f92946 Change "Manage DLCs" button to be icon only 2025-11-11 15:29:51 +01:00
Novattz 62b80cc565 Fix platform detection #70 2025-11-11 15:09:21 +01:00
Novattz 82bd475383 Stop tracking package lock 2025-11-11 14:50:53 +01:00
Tickbase 53be3e3bb2 Merge pull request #65 from Kven1/update-executable-name
Update creamlinux executable name in README.md
2025-10-29 18:26:20 +01:00
kven 69135fc4a4 Update creamlinux executable name in README.dm 2025-10-29 02:01:29 +03:00
Novattz d1871a5384 add deps 2025-10-17 13:31:41 +02:00
Tickbase acce153720 Rename workflow 2025-10-17 13:29:33 +02:00
Novattz a97dc69cee test build 2025-10-17 13:28:26 +02:00
Tickbase c8318ede9f Update build workflow to manual trigger only
Removed automatic triggers for push and pull_request events.
2025-10-17 13:22:47 +02:00
Novattz c7593b6c6c improve SmokeAPI detection and redesign loading screen 2025-10-17 12:36:17 +02:00
Novattz a460e9d3b7 use dynamic DLL matching for SmokeAPI installation 2025-09-27 20:58:23 +02:00
Novattz 6559b15894 version bump 2025-09-12 06:41:15 +02:00
Novattz 653c301ba9 correct SmokeAPI DLL name mapping during installation 2025-09-12 06:39:38 +02:00
Tickbase a6f21c34b1 Update README.md 2025-09-10 03:22:53 +02:00
Novattz a2d5a38f68 bruh x2 2025-09-10 03:01:17 +02:00
Novattz ec95d8e975 bruh 2025-09-10 02:41:58 +02:00
Novattz cd80e81d0b plugin fix 2025-09-10 02:31:57 +02:00
Novattz 2324afaa50 key fix 2025-09-10 02:18:49 +02:00
Novattz 68a458e612 fix 2025-09-10 02:07:38 +02:00
Novattz 5a6ec9e6cf test 2025-09-10 01:59:59 +02:00
Novattz 2c0e67eaf3 im ret 2025-09-10 01:49:46 +02:00
Novattz 039d0702c7 workflows 2025-09-10 01:47:09 +02:00
Novattz 37f872c6bd workflows 2025-09-10 01:45:29 +02:00
Novattz 2ad81160ba workflows 2025-09-10 01:44:26 +02:00
Tickbase caae074587 Update bug_report.md 2025-08-14 07:18:20 +02:00
Novattz 8d2da35a93 settings icon 2025-06-24 19:45:48 +02:00
Novattz 6d5b595883 settings button 2025-06-24 19:45:34 +02:00
Novattz 1ac1931a08 settings dialog 2025-06-24 19:45:29 +02:00
Novattz 41dba65879 update README.md 2025-06-24 19:45:01 +02:00
Novattz 0c57cb75c2 version bump 2025-06-24 19:44:52 +02:00
Novattz b7a850f2d5 gitignore 2025-06-24 19:32:45 +02:00
Tickbase b29bdef058 Update README.md 2025-06-16 04:18:31 +02:00
Novattz c6e671587b version bump 2025-06-09 21:22:23 +02:00
Novattz 5a89757855 Game detection logic 2025-06-09 21:20:45 +02:00
Novattz b701f7f63c Remove strictmode 2025-06-06 01:52:10 +02:00
Tickbase 116e2cfea0 Update feature_request.md 2025-05-23 01:48:20 +02:00
Tickbase 45dc70d4ae Update bug_report.md 2025-05-23 01:48:10 +02:00
Tickbase fd6ca8a158 Update README.md 2025-05-22 22:04:33 +02:00
Tickbase 2d4c87d1e7 Merge pull request #32 from Novattz/dependabot/npm_and_yarn/vite-6.3.5
Bump vite from 6.3.1 to 6.3.5
2025-05-19 04:13:11 +02:00
dependabot[bot] 4d1a0e2199 Bump vite from 6.3.1 to 6.3.5
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.1 to 6.3.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-19 02:12:14 +00:00
Tickbase 2a7999eae7 Update README.md 2025-05-19 03:57:51 +02:00
Tickbase 1e2cb52f6f feat: replace legacy Python CLI with GUI app 2025-05-19 03:41:04 +02:00
Tickbase e55f91a66d chore(release): v0.1.13 2025-05-19 01:25:40 +02:00
Tickbase b3775f70d3 chore(release): v0.1.12 2025-05-19 01:23:44 +02:00
Tickbase 7800933df9 chore(release): v0.1.11 2025-05-19 01:01:05 +02:00
Tickbase 509f90446d Remove semantic stuff 2025-05-19 00:55:08 +02:00
Tickbase ba96a29a0c Update readme 2025-05-19 00:54:57 +02:00
Tickbase 220dcc13de chore(release): v0.1.10 2025-05-19 00:37:35 +02:00
Tickbase 2c7a9a8237 commands 2025-05-19 00:04:16 +02:00
Tickbase 5e333e957b replaced 2025-05-19 00:04:08 +02:00
Tickbase e01207c19f gitignore 2025-05-19 00:03:55 +02:00
Tickbase 50e7d06a2f chore: Stop tracking for script 2025-05-19 00:03:41 +02:00
Tickbase 28f00b3f17 chore(release): v0.1.5 2025-05-18 23:56:30 +02:00
Tickbase 242d2c5e03 chore(release): v0.1.3 2025-05-18 23:40:33 +02:00
Tickbase 50536370ec feat: test 2025-05-18 23:37:55 +02:00
Tickbase ba94d3c272 chore(release): v0.1.2 2025-05-18 23:37:33 +02:00
Tickbase 2ec47c5936 feat: interesting stuff 2025-05-18 22:12:34 +02:00
Tickbase fde4a02376 feat: implement turai updater system 2025-05-18 22:05:30 +02:00
Tickbase 1bc102b456 chore: idk 2025-05-18 21:33:18 +02:00
Tickbase ab9a78185a chore: i shouldve finished school 2025-05-18 21:32:20 +02:00
Tickbase 429370cad0 chore: bug fixes 2025-05-18 21:21:44 +02:00
Tickbase 3f5e1c3fd7 dumb 2025-05-18 21:13:06 +02:00
Tickbase e633524465 feat: Started on updates and workflows 2025-05-18 21:05:32 +02:00
Tickbase 2376690230 chore: add changelog 2025-05-18 18:49:58 +02:00
Tickbase 81519e89b7 Formatting 2025-05-18 18:23:06 +02:00
Tickbase bbbd7482c1 unused var 2025-05-18 18:10:17 +02:00
Tickbase 7608a1138f Merge pull request #1 from Novattz/testing
Testing
2025-05-18 18:04:29 +02:00
Tickbase 79fd51c5e5 bits and bob 2025-05-18 17:57:36 +02:00
Tickbase 4b70cec6e9 Icons 2025-05-18 16:09:24 +02:00
Tickbase 07384e30cc styling 2025-05-18 12:36:15 +02:00
Tickbase 8bc4118a9d idk 2025-05-18 11:55:23 +02:00
Tickbase df815f39a8 fix errors 2025-05-18 11:55:17 +02:00
Tickbase cb88dd54fa button styling 2025-05-18 11:50:01 +02:00
Tickbase a6407c96c8 dlc dialog 2025-05-18 11:49:50 +02:00
Tickbase f5abcfdb6d Dialog fixes 2025-05-18 10:37:53 +02:00
Tickbase 3a8dccf7f8 fixed list items not filling the overlay 2025-05-18 10:06:32 +02:00
Tickbase 39b34c6620 fixed overlay filling entire screen 2025-05-18 10:06:17 +02:00
Tickbase accbd1e058 Fixed 2025-05-18 09:48:13 +02:00
Tickbase 0be15f83e7 Initial changes 2025-05-18 08:06:56 +02:00
Tickbase 19087c00da Revert "Broken animation changes but ill fix it"
This reverts commit e29f44bbd5.
2025-05-18 03:12:18 +02:00
Tickbase 03f00e5b09 Revert: undo broken animation patch (28a8677) 2025-05-18 03:10:47 +02:00
Tickbase 28a86771fa idk 2025-05-18 02:57:09 +02:00
Tickbase e29f44bbd5 Broken animation changes but ill fix it 2025-05-18 02:34:29 +02:00
Tickbase 985f804a16 re-add package-lock.json for CI caching 2025-05-18 01:53:00 +02:00
Tickbase 41753fd8aa patch 2025-05-18 01:50:27 +02:00
Tickbase 38bd9b4f8f bug fixes 2025-05-18 01:33:23 +02:00
Tickbase 3c26dd5eb9 release 2025-05-18 01:28:42 +02:00
Tickbase f9a5a00446 update github token 2025-05-18 00:56:43 +02:00
Tickbase 8cdce5569b update github token 2025-05-18 00:47:36 +02:00
Tickbase 28cea109db fix response url 2025-05-18 00:39:59 +02:00
Tickbase 3c32079b10 fix import statement 2025-05-18 00:32:07 +02:00
Tickbase fdbc2dbb33 fix import statement 2025-05-18 00:30:55 +02:00
Tickbase 4ee0cd4e0d fix import statement 2025-05-18 00:29:51 +02:00
Tickbase e70fe04417 version change 2025-05-18 00:25:38 +02:00
Tickbase fa4adbc0b6 cargo 2025-05-18 00:25:30 +02:00
Tickbase f5d8429f30 add release script 2025-05-18 00:25:25 +02:00
Tickbase c23f72ab02 add release docs 2025-05-18 00:25:20 +02:00
Tickbase 83ef4d1e11 add release workflow 2025-05-18 00:25:14 +02:00
Tickbase bcc61d88e5 add release command 2025-05-18 00:25:07 +02:00
Tickbase 528cf1bc8a update license 2025-05-17 23:31:32 +02:00
Tickbase 7aad9239d0 update readme 2025-05-17 23:31:23 +02:00
Tickbase 8ed9543da9 clean up unused deps 2025-05-17 23:09:30 +02:00
Tickbase ddb03f625c gitignore 2025-05-17 23:09:23 +02:00
Tickbase fe9fb5d490 Stop tracking Cargo.lock 2025-05-17 23:08:08 +02:00
Tickbase 596ddc25ce gitignore 2025-05-17 23:01:44 +02:00
Tickbase 2c81e4e77a Stop tracking package-lock.json 2025-05-17 23:00:00 +02:00
Tickbase ac0e86b9df added missing dependency 2025-05-17 22:56:50 +02:00
Tickbase c22dc26d1c node version 2025-05-17 22:54:39 +02:00
Tickbase 2911911c74 gitignore adjustment 2025-05-17 22:50:30 +02:00
Tickbase 76bfea819b formatting 2025-05-17 22:49:09 +02:00
Tickbase ecd05f1980 add prettierrc 2025-05-17 21:54:57 +02:00
Tickbase e5ab0533ac change version number 2025-05-17 21:46:30 +02:00
Tickbase 4c3079c6d6 change rust profile 2025-05-17 21:38:50 +02:00
Tickbase 182626e2f5 author 2025-05-17 21:38:00 +02:00
Tickbase c2d99c0ba5 version missmatch x2 2025-05-17 21:28:36 +02:00
Tickbase 8cb5b19f59 version missmatch 2025-05-17 21:21:40 +02:00
Tickbase d40de0558e fix? 2025-05-17 21:19:52 +02:00
Tickbase 329e058e1b Initial commit 2025-05-17 21:08:01 +02:00
202 changed files with 28091 additions and 1157 deletions
+53
View File
@@ -0,0 +1,53 @@
---
name: Bug Report
about: Create a report to help improve CreamLinux
title: '[BUG] '
labels: bug
assignees: 'Novattz'
---
## Bug Description
A clear and concise description of what the bug is.
## Steps To Reproduce
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
## Expected Behavior
A clear and concise description of what you expected to happen.
## Screenshots
If applicable, add screenshots to help explain your problem.
## System Information
- OS: [e.g. Ubuntu 22.04, Arch Linux, etc.]
- Desktop Environment: [e.g. GNOME, KDE, etc.]
- CreamLinux Version: [e.g. 0.1.0]
- Steam Version: [e.g. latest]
- Graphics card: [e.g. 2060 rtx]
## Game Information
- Game name:
- Game ID (if known):
- Native Linux or Proton:
- Steam installation path:
## Additional Context
Add any other context about the problem here.
## Logs
If possible, include the contents of `~/.cache/creamlinux/creamlinux.log` or attach the file.
```
Paste log content here
```
+18 -10
View File
@@ -1,20 +1,28 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[ Feature Request ]"
name: Feature Request
about: Suggest an idea for CreamLinux
title: '[FEATURE] '
labels: enhancement
assignees: Novattz
assignees: 'Novattz'
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
## Feature Description
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
## Problem This Feature Solves
Is your feature request related to a problem? Please describe.
Ex. I'm always frustrated when [...]
## Alternatives You've Considered
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
## Additional Context
Add any other context or screenshots about the feature request here.
## Implementation Ideas (Optional)
If you have any ideas on how this feature could be implemented, please share them here.
-29
View File
@@ -1,29 +0,0 @@
---
name: Report
about: Create a report to help us improve
assignees: Novattz
---
**Before submitting, have you tried:**
- Using smokeapi with Proton? [ ] Yes [ ] No
- Checking if `LD_PRELOAD` is blocked on your system? [ ] Yes [ ] No
**Describe the bug**
- A clear and concise description of what the bug is.
**Terminal output**
- Copy and paste the entire terminal output when running the script.
**Log File Output**
- If the script logged any errors, attach the log file **`script.log`** to this issue.
**Debug Output**
- Run the script with the **`--debug`** argument and attach the log file **`debug_script.log`** to this issue.
**Steam library path**
- Provide the path where your steam library is.
**Additional context**
Add any other context about the problem here.
+165
View File
@@ -0,0 +1,165 @@
name: 'Build and Release'
on:
workflow_dispatch: # Allows manual triggering
jobs:
create-release:
permissions:
contents: write
runs-on: 'ubuntu-24.04'
outputs:
release_id: ${{ steps.create-release.outputs.result }}
version: ${{ steps.get-version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'npm'
- name: get version
id: get-version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Package version: $VERSION"
- name: get changelog notes for version
id: changelog
env:
VERSION: ${{ steps.get-version.outputs.version }}
run: |
NOTES="$(awk -v ver="$VERSION" '
BEGIN { found=0 }
$0 ~ "^## \\[" ver "\\] - " { found=1 }
found {
if ($0 ~ "^## \\[" && $0 !~ "^## \\[" ver "\\] - " ) exit
print
}
' CHANGELOG.md)"
if [ -z "$NOTES" ]; then
echo "No changelog entry found for version $VERSION" >&2
exit 1
fi
{
echo "notes<<EOF"
echo "$NOTES"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: create draft release
id: create-release
uses: actions/github-script@v6
env:
VERSION: ${{ steps.get-version.outputs.version }}
NOTES: ${{ steps.changelog.outputs.notes }}
with:
script: |
const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `v${process.env.VERSION}`,
name: `v${process.env.VERSION}`,
body: process.env.NOTES,
draft: true,
prerelease: false
})
return data.id
build-tauri:
needs: create-release
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: 'ubuntu-24.04'
args: ''
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'npm'
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
- name: Install system dependencies (Ubuntu)
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-0=2.44.0-2 \
libwebkit2gtk-4.1-dev=2.44.0-2 \
libjavascriptcoregtk-4.1-0=2.44.0-2 \
libjavascriptcoregtk-4.1-dev=2.44.0-2 \
gir1.2-javascriptcoregtk-4.1=2.44.0-2 \
gir1.2-webkit2-4.1=2.44.0-2 \
libappindicator3-dev \
librsvg2-dev \
patchelf \
build-essential \
curl \
wget \
file \
libssl-dev \
libgtk-3-dev
- name: Install frontend dependencies
run: npm ci
- name: Build Tauri app with updater
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
projectPath: '.'
includeDebug: false
includeRelease: true
includeUpdaterJson: true
tauriScript: 'npm run tauri'
args: ${{ matrix.args }}
publish-release:
name: Publish release
needs: [create-release, build-tauri]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Publish GitHub release (unset draft)
uses: actions/github-script@v6
with:
script: |
const release_id = Number("${{ needs.create-release.outputs.release_id }}");
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id,
draft: false
});
+27
View File
@@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
docs
*.local
*.lock
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
dist
node_modules
src-tauri/target
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "es5"
}
+71
View File
@@ -0,0 +1,71 @@
## [1.4.2] - 13-03-2026
### Added
- Added a dialog so users can manually add DLC's incase they are missing from the steam api
### Fixed
- Fixed an issue where if the libsteam_api.so file is nested too deeply in a game causing the app to not find it.
## [1.4.1] - 18-01-2026
### Added
- Dramatically reduced the time that bitness detection takes to detect game bitness
## [1.4.0] - 17-01-2026
### Added
- Unlocker selection dialog for native games, allowing users to choose between CreamLinux and SmokeAPI
- Game bitness detection
### Fixed
- Cache now validates if expected files are missing.
## [1.3.5] - 09-01-2026
### Changed
- Redesigned conflict detection dialog to show all conflicts at once
- Integrated Steam launch option reminder directly into the conflict dialog
### Fixed
- Improved UX by allowing users to resolve conflicts in any order or defer to later
## [1.3.4] - 03-01-2026
### Added
- Disclaimer dialog explaining that CreamLinux Installer manages DLC IDs, not actual DLC files
- User config stored in `~/.config/creamlinux/config.json`
- **"Don't show again" option**: Users can permanently dismiss the disclaimer via checkbox
## [1.3.3] - 26-12-2025
### Added
- Platform conflict detection
- Automatic removal of incompatible unlocker files when switching between Native/Proton
- Reminder dialog for steam launch options after creamlinux removal
- Conflict dialog to show which game had the conflict
## [1.3.2] - 23-12-2025
### Added
- New dropdown component
- Settings dialog for SmokeAPI configuration
- Update creamlinux config functionality
### Changed
- Adjusted styling for CreamLinux settings dialog
## [1.3.0] - 22-12-2025
### Added
- New icons
- Unlockers are now cached in `~/.cache/creamlinux/` with automatic version management
- Check for new SmokeAPI/CreamLinux versions on every app startup
- Each game gets a `creamlinux.json` manifest tracking installed versions
- Outdated installations automatically sync with latest cached versions
### Changed
- Polished toast notifications alot
- Complete modular rewrite with clear separation of concerns
### Fixed
- Fixed toast message where uninstall actions incorrectly showed success notifications
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Tickbase
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+118 -32
View File
@@ -1,44 +1,130 @@
# CreamLinux
# Steam DLC Fetcher and installer for Linux
- A user-friendly tool for managing DLC for Steam games on Linux systems.
CreamLinux is a GUI application for Linux that simplifies the management of DLC IDs in Steam games. It provides a user-friendly interface to install and configure CreamAPI (for native Linux games) and SmokeAPI (for Windows games running through Proton).
[Demo/Tutorial](https://www.youtube.com/watch?v=Y1E15rUsdDw) - [OUTDATED]
## Watch the demo here:
### Features
- Automatic Steam library detection
- Support for Linux and Proton
- Automatic updates (Soon)
- DLC detection and installation
[![Watch the demo](./src/assets/screenshot.png)](https://www.youtube.com/watch?v=ZunhZnKFLlg)
### Prerequisites
- Python 3.7 or higher
- requests
- rich
- argparse
- json
## Beta Status
### Installation
⚠️ **IMPORTANT**: CreamLinux is currently in BETA. This means:
- Some features may be incomplete or subject to change
- You might encounter bugs or unexpected behavior
- The application is under active development
- Your feedback and bug reports are invaluable
While the core functionality is working, please be aware that this is an early release. Im continuously working to improve stability, add features, and enhance the user experience. Please report any issues you encounter on [GitHub Issues page](https://github.com/Novattz/creamlinux-installer/issues).
## Features
- **Auto-discovery**: Automatically finds Steam games installed on your system
- **Native support**: Installs CreamLinux for native Linux games
- **Proton support**: Installs SmokeAPI for Windows games running through Proton
- **DLC management**: Easily select which DLCs to enable
- **Modern UI**: Clean, responsive interface that's easy to use
## Installation
### AppImage (Recommended)
1. Download the latest `creamlinux.AppImage` from the [Releases](https://github.com/Novattz/creamlinux-installer/releases) page
2. Make it executable:
```bash
chmod +x creamlinux.AppImage
```
3. Run it:
```bash
./creamlinux.AppImage
```
For Nvidia users use this command:
```
WEBKIT_DISABLE_DMABUF_RENDERER=1 ./creamlinux.AppImage
```
### Building from Source
#### Prerequisites
- Rust 1.77.2 or later
- Node.js 18 or later
- webkit2gtk-4.1 (libwebkit2gtk-4.1 for debian)
- npm or yarn
#### Steps
1. Clone the repository:
```bash
git clone https://github.com/Novattz/creamlinux-installer.git
cd creamlinux-installer
```
2. Install dependencies:
```bash
npm install # or yarn
```
3. Build the application:
```bash
NO_STRIP=true npm run tauri build
```
4. The compiled binary will be available in `src-tauri/target/release/creamlinux`
### Desktop Integration
If you're using the AppImage version, you can integrate it into your desktop environment:
1. Create a desktop entry file:
```bash
mkdir -p ~/.local/share/applications
```
2. Create `~/.local/share/applications/creamlinux.desktop` with the following content (adjust the path to your AppImage):
```
[Desktop Entry]
Name=Creamlinux
Exec=/absolute/path/to/CreamLinux.AppImage
Icon=/absolute/path/to/creamlinux-icon.png
Type=Application
Categories=Game;Utility;
Comment=DLC Manager for Steam games on Linux
```
3. Update your desktop database so creamlinux appears in your app launcher:
- Clone the repo or download the script.
- Navigate to the directory containing the script.
- Run the script using python:
```bash
python main.py
update-desktop-database ~/.local/share/applications
```
### Basic Usage
- `--manual <path>`: Specify steam library path manually
```bash
python main.py --manual "/path/to/steamapps"
```
- `--debug`: Enable debug logging
```bash
python main.py --debug
```
## Troubleshooting
### Issues?
- Open a issue and attach all relevant errors/logs.
### Common Issues
- **Game doesn't load**: Make sure the launch options are correctly set in Steam
- **DLCs not showing up**: Try refreshing the game list and reinstalling
- **Cannot find Steam**: Ensure Steam is installed and you've launched it at least once
### Debug Logs
Logs are stored at: `~/.cache/creamlinux/creamlinux.log`
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) file for details.
## Credits
- [Creamlinux](https://github.com/anticitizn/creamlinux) by anticitizn
- [SmokeAPI](https://github.com/acidicoala/SmokeAPI) by acidicoala
- [Creamlinux](https://github.com/anticitizn/creamlinux) - Native support
- [SmokeAPI](https://github.com/acidicoala/SmokeAPI) - Proton support
- [Tauri](https://tauri.app/) - Framework for building the desktop application
- [React](https://reactjs.org/) - UI library
+128
View File
@@ -0,0 +1,128 @@
# Adding New Icons to Creamlinux
This guide explains how to add new icons to the Creamlinux project.
## Prerequisites
- Basic knowledge of SVG files
- Node.js and npm installed
- Creamlinux project set up
## Step 1: Find or Create SVG Icons
You can:
- Create your own SVG icons using tools like Figma, Sketch, or Illustrator
- Download icons from libraries like Heroicons, Material Icons, or Feather Icons
- Use existing SVG files
Ideally, icons should:
- Be 24x24px or have a viewBox of "0 0 24 24"
- Have a consistent style with existing icons
- Use stroke-width of 2 for outline variants
- Use solid fills for bold variants
## Step 2: Optimize SVG Files
We have a script to optimize SVG files for the icon system:
```bash
# Install dependencies
npm install
# Optimize a single SVG
npm run optimize-svg path/to/icon.svg
# Optimize all SVGs in a directory
npm run optimize-svg src/components/icons/ui/outline
```
The optimizer will:
- Remove unnecessary attributes
- Set the viewBox to "0 0 24 24"
- Add currentColor for fills/strokes for proper color inheritance
- Remove width and height attributes for flexible sizing
## Step 3: Add SVG Files to the Project
1. Decide if your icon is a "bold" (filled) or "outline" (stroked) variant
2. Place the file in the appropriate directory:
- For outline variants: `src/components/icons/ui/outline/`
- For bold variants: `src/components/icons/ui/bold/`
3. Use a descriptive name like `download.svg` or `settings.svg`
## Step 4: Export the Icons
1. Open the index.ts file in the respective directory:
- `src/components/icons/ui/outline/index.ts` for outline variants
- `src/components/icons/ui/bold/index.ts` for bold variants
2. Add an export statement for your new icon:
```typescript
// For outline variant
export { ReactComponent as NewIconOutlineIcon } from './new-icon.svg'
// For bold variant
export { ReactComponent as NewIconBoldIcon } from './new-icon.svg'
```
Use a consistent naming pattern:
- CamelCase
- Descriptive name
- Suffix with BoldIcon or OutlineIcon based on variant
## Step 5: Use the Icon in Your Components
Now you can use your new icon in any component:
```tsx
import { Icon } from '@/components/icons'
import { NewIconOutlineIcon, NewIconBoldIcon } from '@/components/icons'
// In your component:
<Icon icon={NewIconOutlineIcon} size="md" />
<Icon icon={NewIconBoldIcon} size="lg" fillColor="var(--primary-color)" />
```
## Best Practices
1. **Create both variants**: When possible, create both bold and outline variants for consistency.
2. **Use semantic names**: Name icons based on their meaning, not appearance (e.g., "success" instead of "checkmark").
3. **Be consistent**: Follow the existing icon style for visual harmony.
4. **Test different sizes**: Ensure icons look good at all standard sizes: xs, sm, md, lg, xl.
5. **Optimize manually if needed**: Sometimes automatic optimization may not work perfectly. You might need to manually edit SVG files.
6. **Add accessibility**: When using icons, provide proper accessibility:
```tsx
<Icon icon={InfoOutlineIcon} title="Additional information" size="md" />
```
## Troubleshooting
**Problem**: Icon doesn't change color with CSS
**Solution**: Make sure your SVG uses `currentColor` for fill or stroke
**Problem**: Icon looks pixelated
**Solution**: Ensure your SVG has a proper viewBox attribute
**Problem**: Icon sizing is inconsistent
**Solution**: Use the standard size props (xs, sm, md, lg, xl) instead of custom sizes
**Problem**: SVG has complex gradients or effects that don't render correctly
**Solution**: Simplify the SVG design; complex effects aren't ideal for UI icons
## Additional Resources
- [SVGR documentation](https://react-svgr.com/docs/what-is-svgr/)
- [SVGO documentation](https://github.com/svg/svgo)
- [SVG MDN documentation](https://developer.mozilla.org/en-US/docs/Web/SVG)
+160
View File
@@ -0,0 +1,160 @@
# Icon Usage Methods
There are two ways to use icons in Creamlinux, both fully supported and completely interchangeable.
## Method 1: Using Icon component with name prop
This approach uses the `Icon` component with a `name` prop:
```tsx
import { Icon, refresh, check, info, steam } from '@/components/icons'
<Icon name={refresh} />
<Icon name={check} variant="bold" />
<Icon name={info} size="lg" fillColor="var(--info)" />
<Icon name={steam} /> {/* Brand icons auto-detect the variant */}
```
## Method 2: Using direct icon components
This approach imports pre-configured icon components directly:
```tsx
import { RefreshIcon, CheckBoldIcon, InfoIcon, SteamIcon } from '@/components/icons'
<RefreshIcon /> {/* Outline variant */}
<CheckBoldIcon /> {/* Bold variant */}
<InfoIcon size="lg" fillColor="var(--info)" />
<SteamIcon /> {/* Brand icon */}
```
## When to use each method
### Use Method 1 (Icon + name) when:
- You have dynamic icon selection based on data or state
- You want to keep your imports list shorter
- You're working with icons in loops or maps
- You want to change variants dynamically
Example of dynamic icon selection:
```tsx
import { Icon } from '@/components/icons'
function StatusIndicator({ status }) {
const iconName =
status === 'success'
? 'Check'
: status === 'warning'
? 'Warning'
: status === 'error'
? 'Close'
: 'Info'
return <Icon name={iconName} variant="bold" />
}
```
### Use Method 2 (direct components) when:
- You want the most concise syntax
- You're using a fixed set of icons that won't change
- You want specific variants (like InfoBoldIcon vs InfoIcon)
- You prefer more explicit component names in your JSX
Example of fixed icon usage:
```tsx
import { InfoIcon, CloseIcon } from '@/components/icons'
function ModalHeader({ title, onClose }) {
return (
<div className="modal-header">
<div className="title">
<InfoIcon size="sm" />
<h3>{title}</h3>
</div>
<button onClick={onClose}>
<CloseIcon size="md" />
</button>
</div>
)
}
```
## Available Icon Component Exports
### UI Icons (Outline variant by default)
```tsx
import {
ArrowUpIcon,
CheckIcon,
CloseIcon,
ControllerIcon,
CopyIcon,
DownloadIcon,
EditIcon,
InfoIcon,
LayersIcon,
RefreshIcon,
SearchIcon,
TrashIcon,
WarningIcon,
WineIcon,
} from '@/components/icons'
```
### Bold Variants
```tsx
import { CheckBoldIcon, InfoBoldIcon, WarningBoldIcon } from '@/components/icons'
```
### Brand Icons
```tsx
import { DiscordIcon, GitHubIcon, LinuxIcon, SteamIcon, WindowsIcon } from '@/components/icons'
```
## Combining Methods
Both methods work perfectly together and can be mixed in the same component:
```tsx
import {
Icon,
refresh, // Method 1
CheckBoldIcon, // Method 2
} from '@/components/icons'
function MyComponent() {
return (
<div>
<Icon name={refresh} />
<CheckBoldIcon />
</div>
)
}
```
## Props are Identical
Both methods accept the same props:
```tsx
// These are equivalent:
<InfoIcon size="lg" fillColor="blue" className="my-icon" />
<Icon name={info} size="lg" fillColor="blue" className="my-icon" />
```
Available props in both cases:
- `size`: "xs" | "sm" | "md" | "lg" | "xl" | number
- `variant`: "outline" | "bold" | "brand" (only for Icon + name method)
- `fillColor`: CSS color string
- `strokeColor`: CSS color string
- `className`: CSS class string
- `title`: Accessibility title
- ...plus all standard SVG attributes
+25
View File
@@ -0,0 +1,25 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist', 'node_modules', 'src-tauri/target'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
}
)
-566
View File
@@ -1,566 +0,0 @@
import os
import re
import requests
import zipfile
import time
import shutil
import stat
import subprocess
import logging
import json
from datetime import datetime
class SteamHelper:
def __init__(self, debug=False):
self.debug = debug
self.logger = None
self.config = None
# Only setup logging if debug is enabled - errors will setup logging on-demand
if debug:
self._setup_logging()
self.load_config()
def load_config(self):
"""Load configuration from config.json"""
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, 'config.json')
# Create default config if it doesn't exist
if not os.path.exists(config_path):
default_config = {
"version": "v1.0.8",
"github_repo": "Novattz/creamlinux-installer",
"github_api": "https://api.github.com/repos/",
"creamlinux_release": "https://github.com/anticitizn/creamlinux/releases/latest/download/creamlinux.zip",
"smokeapi_release": "acidicoala/SmokeAPI",
}
with open(config_path, 'w') as f:
json.dump(default_config, f, indent=4)
self.config = default_config
if self.debug:
self._log_debug("Created default config.json")
return
try:
with open(config_path, 'r') as f:
self.config = json.load(f)
if self.debug:
self._log_debug(f"Loaded config: {self.config}")
except Exception as e:
self._log_error(f"Failed to load config: {str(e)}")
raise
def _cleanup_old_logs(self, log_dir, keep_logs=5):
"""Clean up old log files, keeping only the most recent ones"""
try:
log_files = [os.path.join(log_dir, f) for f in os.listdir(log_dir)
if f.endswith('.log')]
if len(log_files) > keep_logs:
log_files.sort(key=lambda x: os.path.getmtime(x))
for f in log_files[:-keep_logs]:
os.remove(f)
except Exception:
pass # Silently fail cleanup since this is not critical
def _setup_logging(self):
"""Setup logging to file with detailed formatting"""
script_dir = os.path.dirname(os.path.abspath(__file__))
log_dir = os.path.join(script_dir, 'logs')
os.makedirs(log_dir, exist_ok=True)
self._cleanup_old_logs(log_dir)
log_file = os.path.join(log_dir, f'cream_installer_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log')
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(logging.DEBUG if self.debug else logging.ERROR)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s'
))
logger = logging.getLogger('cream_installer')
logger.handlers = []
logger.propagate = False
logger.setLevel(logging.DEBUG if self.debug else logging.ERROR)
logger.addHandler(file_handler)
self.logger = logger
if self.debug:
self.logger.debug("=== Session Started ===")
self.logger.debug(f"Debug mode enabled - Log file: {log_file}")
self.logger.debug(f"System: {os.uname().sysname if hasattr(os, 'uname') else os.name}")
self.logger.debug(f"Python version: {subprocess.check_output(['python', '--version']).decode().strip()}")
self.logger.debug("Checking for Steam installation...")
def _log_debug(self, message):
"""Log debug message if debug mode is enabled"""
if self.debug and not self.logger:
self._setup_logging()
if self.logger:
self.logger.debug(message)
def _log_error(self, message):
"""Log error message, setting up logging if needed"""
if not self.logger:
self._setup_logging()
self.logger.error(message)
def check_requirements(self):
"""Check if all required commands and packages are available"""
missing_commands = []
missing_packages = []
# Check commands
required_commands = ['which', 'steam']
for cmd in required_commands:
if not subprocess.run(['which', cmd], capture_output=True).returncode == 0:
missing_commands.append(cmd)
self._log_error(f"Required command not found: {cmd}")
# Check packages
required_packages = ['requests', 'argparse', 'rich', 'json']
for package in required_packages:
try:
__import__(package)
except ImportError:
missing_packages.append(package)
self._log_error(f"Required Python package not found: {package}")
if missing_commands or missing_packages:
error_details = []
if missing_commands:
cmd_list = ', '.join(missing_commands)
error_details.append(f"Missing commands: {cmd_list}")
if missing_packages:
pkg_list = ', '.join(missing_packages)
error_details.append(f"Missing Python packages: {pkg_list}")
error_details.append("Install them using: pip install " + ' '.join(missing_packages))
raise RequirementsError("\n".join(error_details))
return True
def _is_excluded_app(self, app_id, name):
"""Check if the app should be excluded from the game list"""
excluded_ids = {
'228980', # Steamworks Common Redistributables
'1070560', # Steam Linux Runtime
'1391110', # Steam Linux Runtime - Soldier
'1628350', # Steam Linux Runtime - Sniper
'1493710', # Proton Experimental
'1826330' # Steam Linux Runtime - Scout
}
excluded_patterns = [
r'Proton \d+\.\d+',
r'Steam Linux Runtime',
r'Steamworks Common'
]
if app_id in excluded_ids:
return True
for pattern in excluded_patterns:
if re.match(pattern, name, re.IGNORECASE):
return True
return False
def _read_steam_registry(self):
"""Read Steam registry file"""
registry_path = os.path.expanduser('~/.steam/registry.vdf')
if os.path.exists(registry_path):
self._log_debug(f"Found Steam registry file: {registry_path}")
with open(registry_path, 'r') as f:
content = f.read()
install_path = re.search(r'"InstallPath"\s*"([^"]+)"', content)
if install_path:
return install_path.group(1)
return None
def _parse_vdf(self, file_path):
"""Parse Steam library folders VDF file"""
library_paths = []
try:
with open(file_path, 'r', encoding='utf-8') as file:
content = file.read()
paths = re.findall(r'"path"\s*"(.*?)"', content, re.IGNORECASE)
library_paths.extend([os.path.normpath(path) for path in paths])
except Exception as e:
self._log_error(f"Failed to read {file_path}: {str(e)}")
return library_paths
def _find_steam_binary(self):
"""Find Steam binary location"""
try:
result = subprocess.run(['which', 'steam'], stdout=subprocess.PIPE)
steam_path = result.stdout.decode('utf-8').strip()
if steam_path:
return os.path.dirname(steam_path)
except Exception as e:
self._log_error(f"Failed to locate steam binary: {str(e)}")
return None
def find_steam_library_folders(self, manual_path=""):
"""Find all Steam library folders"""
self._log_debug("Starting Steam library folder search")
search_list = [
os.path.expanduser('~/.steam/steam'),
os.path.expanduser('~/.local/share/Steam'),
os.path.expanduser('/home/deck/.steam/steam'),
os.path.expanduser('/home/deck/.local/share/Steam'),
'/mnt/Jogos/Steam',
'/run/media/mmcblk0p1',
os.path.expanduser('~/.var/app/com.valvesoftware.Steam/.local/share/Steam'),
os.path.expanduser('~/.var/app/com.valvesoftware.Steam/data/Steam/steamapps/common')
]
library_folders = []
try:
if manual_path:
self._log_debug(f"Manual game path provided: {manual_path}")
if os.path.exists(manual_path):
self._log_debug("Manual path exists, adding to library folders")
library_folders.append(manual_path)
else:
self._log_debug(f"Manual path does not exist: {manual_path}")
return library_folders
steam_binary_path = self._find_steam_binary()
if steam_binary_path:
self._log_debug(f"Found Steam binary at: {steam_binary_path}")
if steam_binary_path not in search_list:
search_list.append(steam_binary_path)
steam_install_path = self._read_steam_registry()
if steam_install_path:
self._log_debug(f"Found Steam installation path in registry: {steam_install_path}")
if steam_install_path not in search_list:
search_list.append(steam_install_path)
self._log_debug("Searching for Steam library folders in all potential locations")
for search_path in search_list:
self._log_debug(f"Checking path: {search_path}")
if os.path.exists(search_path):
steamapps_path = str(os.path.normpath(f"{search_path}/steamapps"))
if os.path.exists(steamapps_path):
self._log_debug(f"Found valid steamapps folder: {steamapps_path}")
library_folders.append(steamapps_path)
vdf_path = os.path.join(steamapps_path, 'libraryfolders.vdf')
if os.path.exists(vdf_path):
self._log_debug(f"Found libraryfolders.vdf at: {vdf_path}")
additional_paths = self._parse_vdf(vdf_path)
for path in additional_paths:
new_steamapps_path = os.path.join(path, 'steamapps')
if os.path.exists(new_steamapps_path):
self._log_debug(f"Found additional library folder: {new_steamapps_path}")
library_folders.append(new_steamapps_path)
self._log_debug(f"Found {len(library_folders)} total library folders")
for folder in library_folders:
self._log_debug(f"Library folder: {folder}")
except Exception as e:
self._log_error(f"Error finding Steam library folders: {e}")
self._log_debug(f"Stack trace:", exc_info=True)
return library_folders
def _parse_acf(self, file_path):
"""Parse Steam ACF file"""
try:
with open(file_path, 'r', encoding='utf-8') as file:
data = file.read()
app_id = re.search(r'"appid"\s+"(\d+)"', data)
name = re.search(r'"name"\s+"([^"]+)"', data)
install_dir = re.search(r'"installdir"\s+"([^"]+)"', data)
return app_id.group(1), name.group(1), install_dir.group(1)
except Exception as e:
self._log_error(f"Error reading ACF file {file_path}: {e}")
return None, None, None
def _check_proton_status(self, install_path):
"""
Check if a game requires Proton by looking for .exe files and Steam API DLLs
Returns: (needs_proton, steam_api_files)
"""
try:
has_exe = False
steam_api_files = []
steam_api_patterns = ['steam_api.dll', 'steam_api64.dll']
for root, _, files in os.walk(install_path):
# Check for .exe files
if not has_exe and any(file.lower().endswith('.exe') for file in files):
has_exe = True
# Check for Steam API files
for file in files:
if file.lower() in steam_api_patterns:
steam_api_files.append(os.path.relpath(os.path.join(root, file), install_path))
# If we found both, we can stop searching
if has_exe and steam_api_files:
break
return has_exe, steam_api_files
except Exception as e:
self._log_error(f"Error checking Proton status: {e}")
return False, []
def find_steam_apps(self, library_folders):
"""Find all Steam apps in library folders"""
self._log_debug("Starting Steam apps search")
acf_pattern = re.compile(r'^appmanifest_(\d+)\.acf$')
games = {}
for folder in library_folders:
self._log_debug(f"Searching for games in: {folder}")
if os.path.exists(folder):
for item in os.listdir(folder):
if acf_pattern.match(item):
app_id, game_name, install_dir = self._parse_acf(os.path.join(folder, item))
if app_id and game_name and not self._is_excluded_app(app_id, game_name):
install_path = os.path.join(folder, 'common', install_dir)
if os.path.exists(install_path):
cream_installed = os.path.exists(os.path.join(install_path, 'cream.sh'))
needs_proton, steam_api_files = self._check_proton_status(install_path)
smoke_installed = self.check_smokeapi_status(install_path, steam_api_files) if needs_proton else False
games[app_id] = (
game_name, # [0] Name
cream_installed, # [1] CreamLinux status
install_path, # [2] Install path
needs_proton, # [3] Proton status
steam_api_files, # [4] Steam API files
smoke_installed # [5] SmokeAPI status
)
self._log_debug(f"Found game: {game_name} (App ID: {app_id})")
self._log_debug(f" Path: {install_path}")
self._log_debug(f" Status: Cream={cream_installed}, Proton={needs_proton}, Smoke={smoke_installed}")
if steam_api_files:
self._log_debug(f" Steam API files: {', '.join(steam_api_files)}")
self._log_debug(f"Found {len(games)} total games")
return games
def fetch_dlc_details(self, app_id, progress_callback=None):
"""Fetch DLC details for a game"""
base_url = f"https://store.steampowered.com/api/appdetails?appids={app_id}"
try:
response = requests.get(base_url)
data = response.json()
if str(app_id) not in data:
return []
app_data = data[str(app_id)]
if not app_data.get('success') or 'data' not in app_data:
return []
game_data = app_data['data']
dlcs = game_data.get("dlc", [])
dlc_details = []
total_dlcs = len(dlcs)
for index, dlc_id in enumerate(dlcs):
try:
time.sleep(0.3)
dlc_url = f"https://store.steampowered.com/api/appdetails?appids={dlc_id}"
dlc_response = requests.get(dlc_url)
if dlc_response.status_code == 200:
dlc_data = dlc_response.json()
if str(dlc_id) in dlc_data and "data" in dlc_data[str(dlc_id)]:
dlc_name = dlc_data[str(dlc_id)]["data"].get("name", "Unknown DLC")
dlc_details.append({"appid": dlc_id, "name": dlc_name})
elif dlc_response.status_code == 429:
time.sleep(10)
if progress_callback:
progress_callback(index + 1, total_dlcs)
except Exception as e:
self._log_error(f"Error fetching DLC {dlc_id}: {str(e)}")
return dlc_details
except requests.exceptions.RequestException as e:
self._log_error(f"Failed to fetch DLC details: {str(e)}")
return []
def install_creamlinux(self, app_id, game_install_dir, dlcs):
"""Install CreamLinux for a game"""
try:
zip_url = self.config['creamlinux_release']
zip_path = os.path.join(game_install_dir, 'creamlinux.zip')
self._log_debug(f"Downloading CreamLinux from {zip_url}")
response = requests.get(zip_url)
if response.status_code != 200:
raise InstallationError(f"Failed to download CreamLinux (HTTP {response.status_code})")
self._log_debug(f"Writing zip file to {zip_path}")
with open(zip_path, 'wb') as f:
f.write(response.content)
self._log_debug("Extracting CreamLinux files")
try:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(game_install_dir)
except zipfile.BadZipFile:
raise InstallationError("Downloaded file is corrupted. Please try again.")
os.remove(zip_path)
cream_sh_path = os.path.join(game_install_dir, 'cream.sh')
self._log_debug(f"Setting permissions for {cream_sh_path}")
try:
os.chmod(cream_sh_path, os.stat(cream_sh_path).st_mode | stat.S_IEXEC)
except OSError as e:
raise InstallationError(f"Failed to set execute permissions: {str(e)}")
cream_api_path = os.path.join(game_install_dir, 'cream_api.ini')
self._log_debug(f"Creating config at {cream_api_path}")
try:
dlc_list = "\n".join([f"{dlc['appid']} = {dlc['name']}" for dlc in dlcs])
with open(cream_api_path, 'w') as f:
f.write(f"APPID = {app_id}\n[config]\nissubscribedapp_on_false_use_real = true\n[methods]\ndisable_steamapps_issubscribedapp = false\n[dlc]\n{dlc_list}")
except IOError as e:
raise InstallationError(f"Failed to create config file: {str(e)}")
return True
except Exception as e:
self._log_error(f"Installation failed: {str(e)}")
if isinstance(e, InstallationError):
raise
raise InstallationError(f"Installation failed: {str(e)}")
def uninstall_creamlinux(self, install_path):
"""Uninstall CreamLinux from a game"""
try:
files_to_remove = ['cream.sh', 'cream_api.ini', 'cream_api.so', 'lib32Creamlinux.so', 'lib64Creamlinux.so']
for file in files_to_remove:
file_path = os.path.join(install_path, file)
if os.path.exists(file_path):
os.remove(file_path)
return True
except Exception as e:
self._log_error(f"Uninstallation failed: {str(e)}")
return False
def install_smokeapi(self, install_path, steam_api_files):
"""Install SmokeAPI for a Proton game"""
try:
# Construct the correct URL using latest version
response = requests.get(
f"{self.config['github_api']}{self.config['smokeapi_release']}/releases/latest"
)
if response.status_code != 200:
raise InstallationError("Failed to fetch latest SmokeAPI version")
latest_release = response.json()
latest_version = latest_release['tag_name']
zip_url = (
f"https://github.com/{self.config['smokeapi_release']}/releases/download/"
f"{latest_version}/SmokeAPI-{latest_version}.zip"
)
zip_path = os.path.join(install_path, 'smokeapi.zip')
self._log_debug(f"Downloading SmokeAPI from {zip_url}")
response = requests.get(zip_url)
if response.status_code != 200:
raise InstallationError(f"Failed to download SmokeAPI (HTTP {response.status_code})")
self._log_debug(f"Writing zip file to {zip_path}")
with open(zip_path, 'wb') as f:
f.write(response.content)
self._log_debug("Extracting SmokeAPI files")
try:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
for api_file in steam_api_files:
api_dir = os.path.dirname(os.path.join(install_path, api_file))
api_name = os.path.basename(api_file)
# Backup original file
original_path = os.path.join(api_dir, api_name)
backup_path = os.path.join(api_dir, api_name.replace('.dll', '_o.dll'))
self._log_debug(f"Processing {api_file}:")
self._log_debug(f" Original: {original_path}")
self._log_debug(f" Backup: {backup_path}")
# Only backup if not already backed up
if not os.path.exists(backup_path):
shutil.move(original_path, backup_path)
# Extract the appropriate DLL directly to the game directory
zip_ref.extract(api_name, api_dir)
self._log_debug(f" Installed SmokeAPI as: {original_path}")
except zipfile.BadZipFile:
raise InstallationError("Downloaded file is corrupted. Please try again.")
os.remove(zip_path)
return True
except Exception as e:
self._log_error(f"SmokeAPI installation failed: {str(e)}")
raise InstallationError(f"Failed to install SmokeAPI: {str(e)}")
def uninstall_smokeapi(self, install_path, steam_api_files):
"""Uninstall SmokeAPI and restore original files"""
try:
for api_file in steam_api_files:
api_dir = os.path.dirname(os.path.join(install_path, api_file))
api_name = os.path.basename(api_file)
original_path = os.path.join(api_dir, api_name)
backup_path = os.path.join(api_dir, api_name.replace('.dll', '_o.dll'))
if os.path.exists(backup_path):
if os.path.exists(original_path):
os.remove(original_path)
shutil.move(backup_path, original_path)
self._log_debug(f"Restored original file: {original_path}")
return True
except Exception as e:
self._log_error(f"SmokeAPI uninstallation failed: {str(e)}")
return False
def check_smokeapi_status(self, install_path, steam_api_files):
"""Check if SmokeAPI is installed"""
try:
for api_file in steam_api_files:
backup_path = os.path.join(
install_path,
os.path.dirname(api_file),
os.path.basename(api_file).replace('.dll', '_o.dll')
)
if os.path.exists(backup_path):
return True
return False
except Exception as e:
self._log_error(f"Error checking SmokeAPI status: {str(e)}")
return False
class RequirementsError(Exception):
"""Raised when system requirements are not met"""
pass
class NetworkError(Exception):
"""Raised when network-related operations fail"""
pass
class InstallationError(Exception):
"""Raised when installation operations fail"""
pass
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Creamlinux</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-201
View File
@@ -1,201 +0,0 @@
import argparse
import os
from helper import SteamHelper, RequirementsError, NetworkError, InstallationError
from ui_handler import UIHandler
from updater import check_for_updates, UpdateError
def handle_dlc_operation(ui, helper, app_id, game_name, install_dir):
"""Handle DLC fetching and installation"""
ui.show_info(f"\nSelected: {game_name} (App ID: {app_id})")
with ui.create_progress_context() as progress:
progress_task = progress.add_task("🔍 Fetching DLC details...", total=None)
def update_progress(current, total):
progress.update(progress_task, completed=current, total=total)
dlcs = helper.fetch_dlc_details(app_id, update_progress)
if dlcs:
ui.show_dlc_table(dlcs)
if ui.get_user_confirmation("\nProceed with installation?"):
with ui.create_status_context("Installing CreamLinux..."):
success = helper.install_creamlinux(app_id, install_dir, dlcs)
if success:
ui.show_success("Installation complete!")
ui.show_launch_options(game_name)
else:
ui.show_warning("No DLCs found for this game.")
def handle_smokeapi_operation(ui, helper, install_path, steam_api_files, game_name, is_install=True):
"""Handle SmokeAPI installation/uninstallation"""
operation = "installation" if is_install else "uninstallation"
ui.show_info(f"\nProceeding with SmokeAPI {operation} for {game_name}")
try:
with ui.create_status_context(f"{'Installing' if is_install else 'Uninstalling'} SmokeAPI..."):
if is_install:
success = helper.install_smokeapi(install_path, steam_api_files)
else:
success = helper.uninstall_smokeapi(install_path, steam_api_files)
if success:
ui.show_success(f"Successfully {'installed' if is_install else 'uninstalled'} SmokeAPI!")
else:
ui.show_error(f"Failed to {'install' if is_install else 'uninstall'} SmokeAPI")
except Exception as e:
ui.show_error(str(e))
def main():
parser = argparse.ArgumentParser(description="Steam DLC Fetcher")
parser.add_argument("--manual", metavar='steamapps_path', help="Sets the steamapps path for faster operation", required=False)
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
parser.add_argument("--no-update", action="store_true", help="Skip update check")
args = parser.parse_args()
ui = UIHandler(debug=args.debug)
helper = SteamHelper(debug=args.debug)
try:
if not args.no_update:
try:
if check_for_updates(ui, helper):
return # Exit if update was performed
except UpdateError as e:
ui.show_error(f"Update failed: {str(e)}")
if not ui.get_user_confirmation("Would you like to continue anyway?"):
return
# Use version from config instead of fetching
app_version = helper.config['version']
ui.show_header(app_version, args.debug)
helper.check_requirements()
except RequirementsError as e:
ui.show_error("Missing dependencies:", show_details=str(e))
return
try:
with ui.create_status_context("Finding Steam library folders..."):
library_folders = helper.find_steam_library_folders(args.manual)
if not library_folders:
if args.manual:
ui.show_error(f"Could not find Steam library at specified path: {args.manual}")
else:
ui.show_warning("No Steam library folders found. Please enter the path manually.")
steamapps_path = ui.get_user_input("Enter Steamapps Path")
if len(steamapps_path) > 3 and os.path.exists(steamapps_path):
library_folders = [steamapps_path]
else:
ui.show_error("Invalid path or path does not exist!")
return
while True:
# Refresh games list at the start of each loop
with ui.create_status_context("Scanning for games..."):
games = helper.find_steam_apps(library_folders)
if not games:
ui.show_error("No Steam games found.")
return
games_list = list(games.items())
ui.show_games_table(games_list)
try:
ui.console.print("\n[dim]Enter game number or 'q' to quit[/dim]")
user_input = ui.get_user_input("Select game number")
if user_input.lower() == 'q':
return
choice = int(user_input) - 1
if not (0 <= choice < len(games_list)):
ui.show_error("Invalid selection.")
continue
# Show the selected game and options
ui.clear_screen()
ui.show_header(app_version, args.debug)
ui.show_games_table(games_list, choice)
# Get game's status
is_installed = games_list[choice][1][1] # cream_status
needs_proton = games_list[choice][1][3] # needs_proton
steam_api_files = games_list[choice][1][4] # steam_api_files
smoke_status = games_list[choice][1][5] # smoke_status
game_info = (games_list[choice][0], games_list[choice][1])
# Different choices based on installation status and game type
if needs_proton and steam_api_files:
if smoke_status:
max_options = 2 # Uninstall and Go Back
action = ui.get_user_input("\nChoose action", choices=["1", "2"])
if action == "2": # Go back
ui.clear_screen()
ui.show_header(app_version, args.debug)
continue
if action == "1": # Uninstall SmokeAPI
handle_smokeapi_operation(ui, helper, game_info[1][2], steam_api_files, game_info[1][0], False)
else:
max_options = 2 # Install and Go Back
action = ui.get_user_input("\nChoose action", choices=["1", "2"])
if action == "2": # Go back
ui.clear_screen()
ui.show_header(app_version, args.debug)
continue
if action == "1": # Install SmokeAPI
handle_smokeapi_operation(ui, helper, game_info[1][2], steam_api_files, game_info[1][0], True)
else:
# Handle non-Proton games (original logic)
if is_installed:
action = ui.get_user_input("\nChoose action", choices=["1", "2", "3"])
if action == "3": # Go back
ui.clear_screen()
ui.show_header(app_version, args.debug)
continue
if action == "1": # Fetch DLCs
handle_dlc_operation(ui, helper, game_info[0], game_info[1][0], game_info[1][2])
else: # Uninstall
if ui.get_user_confirmation("\nAre you sure you want to uninstall CreamLinux?"):
with ui.create_status_context("Uninstalling CreamLinux..."):
success = helper.uninstall_creamlinux(game_info[1][2])
if success:
ui.show_success(f"Successfully uninstalled CreamLinux from {game_info[1][0]}")
ui.show_uninstall_reminder()
else:
action = ui.get_user_input("\nChoose action", choices=["1", "2"])
if action == "2": # Go back
ui.clear_screen()
ui.show_header(app_version, args.debug)
continue
# Proceed with DLC operation
handle_dlc_operation(ui, helper, game_info[0], game_info[1][0], game_info[1][2])
# After any operation, ask if user wants to continue
if ui.get_user_confirmation("\nWould you like to perform another operation?"):
ui.clear_screen()
ui.show_header(app_version, args.debug)
continue
else:
break
except ValueError:
ui.show_error("Invalid input. Please enter a number.")
continue
except Exception as e:
if isinstance(e, (RequirementsError, NetworkError, InstallationError)):
ui.show_error(str(e))
else:
helper._log_error(f"Unexpected error: {str(e)}")
ui.show_error(f"An unexpected error occurred: {str(e)}", show_exception=args.debug)
if __name__ == "__main__":
main()
+13450
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
{
"name": "creamlinux",
"private": true,
"version": "1.4.2",
"type": "module",
"author": "Tickbase",
"repository": "https://github.com/Novattz/creamlinux-installer",
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"tauri": "tauri",
"optimize-svg": "node scripts/optimize-svg.js",
"set-version": "node scripts/set-version.js"
},
"dependencies": {
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sass": "^1.89.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^11.0.2",
"@svgr/core": "^8.1.0",
"@svgr/webpack": "^8.1.0",
"@tauri-apps/cli": "^2.5.0",
"@types/node": "^20.10.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"dotenv": "^16.5.0",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"glob": "^11.1.0",
"globals": "^16.0.0",
"node-fetch": "^3.3.2",
"sass-embedded": "^1.86.3",
"semantic-release": "^25.0.2",
"typescript": "~5.7.2",
"typescript-eslint": "^8.26.1",
"vite": "^6.4.1",
"vite-plugin-svgr": "^4.3.0"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
/**
* SVG Optimizer for Creamlinux
*
* This script optimizes SVG files for use in the icon system.
* Run it with `node optimize-svg.js path/to/svg`
*/
import fs from 'fs'
import path from 'path'
import optimize from 'svgo'
// Check if a file path is provided
if (process.argv.length < 3) {
console.error('Please provide a path to an SVG file or directory')
process.exit(1)
}
const inputPath = process.argv[2]
// SVGO configuration
const svgoConfig = {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
// Keep viewBox attribute
removeViewBox: false,
// Don't remove IDs
cleanupIDs: false,
// Don't minify colors
convertColors: false,
},
},
},
// Add currentColor for path fill if not specified
{
name: 'addAttributesToSVGElement',
params: {
attributes: [
{
fill: 'currentColor',
},
],
},
},
// Remove width and height
{
name: 'removeAttrs',
params: {
attrs: ['width', 'height'],
},
},
// Make sure viewBox is 0 0 24 24 for consistent sizing
{
name: 'addAttributesToSVGElement',
params: {
attributes: [
{
viewBox: '0 0 24 24',
},
],
},
},
],
}
// Function to optimize a single SVG file
function optimizeSVG(filePath) {
try {
const svg = fs.readFileSync(filePath, 'utf8')
const result = optimize(svg, svgoConfig)
// Write the optimized SVG back to the file
fs.writeFileSync(filePath, result.data)
console.log(`✅ Optimized: ${filePath}`)
return true
} catch (error) {
console.error(`❌ Error optimizing ${filePath}:`, error)
return false
}
}
// Function to process a directory of SVG files
function processDirectory(dirPath) {
try {
const files = fs.readdirSync(dirPath)
let optimizedCount = 0
for (const file of files) {
const filePath = path.join(dirPath, file)
const stat = fs.statSync(filePath)
if (stat.isDirectory()) {
// Recursively process subdirectories
optimizedCount += processDirectory(filePath)
} else if (path.extname(file).toLowerCase() === '.svg') {
// Process SVG files
if (optimizeSVG(filePath)) {
optimizedCount++
}
}
}
return optimizedCount
} catch (error) {
console.error(`Error processing directory ${dirPath}:`, error)
return 0
}
}
// Main execution
try {
const stat = fs.statSync(inputPath)
if (stat.isDirectory()) {
const count = processDirectory(inputPath)
console.log(`\nOptimized ${count} SVG files in ${inputPath}`)
} else if (path.extname(inputPath).toLowerCase() === '.svg') {
optimizeSVG(inputPath)
} else {
console.error('The provided path is not an SVG file or directory')
process.exit(1)
}
} catch (error) {
console.error('Error:', error)
process.exit(1)
}
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
// Get version from command line argument
const newVersion = process.argv[2]
if (!newVersion) {
console.error('Error: No version specified')
console.log('Usage: npm run set-version <version>')
console.log('Example: npm run set-version 1.2.3')
process.exit(1)
}
// Validate version format (basic semver check)
if (!/^\d+\.\d+\.\d+$/.test(newVersion)) {
console.error('Error: Invalid version format. Use semver format: X.Y.Z')
console.log('Example: 1.2.3')
process.exit(1)
}
console.log(`Setting version to ${newVersion}...\n`)
let errors = 0
// 1. Update package.json
try {
const packageJsonPath = path.join(process.cwd(), 'package.json')
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
packageJson.version = newVersion
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n')
console.log('Updated package.json')
} catch (err) {
console.error('Failed to update package.json:', err.message)
errors++
}
// 2. Update package-lock.json
try {
const packageLockPath = path.join(process.cwd(), 'package-lock.json')
if (fs.existsSync(packageLockPath)) {
const packageLock = JSON.parse(fs.readFileSync(packageLockPath, 'utf8'))
packageLock.version = newVersion
if (packageLock.packages && packageLock.packages['']) {
packageLock.packages[''].version = newVersion
}
fs.writeFileSync(packageLockPath, JSON.stringify(packageLock, null, 2) + '\n')
console.log('Updated package-lock.json')
} else {
console.log('package-lock.json not found (skipping)')
}
} catch (err) {
console.error('Failed to update package-lock.json:', err.message)
errors++
}
// 3. Update Cargo.toml
try {
const cargoTomlPath = path.join(process.cwd(), 'src-tauri', 'Cargo.toml')
let cargoToml = fs.readFileSync(cargoTomlPath, 'utf8')
// Replace version in [package] section
cargoToml = cargoToml.replace(/^version\s*=\s*"[^"]*"/m, `version = "${newVersion}"`)
fs.writeFileSync(cargoTomlPath, cargoToml)
console.log('Updated Cargo.toml')
} catch (err) {
console.error('Failed to update Cargo.toml:', err.message)
errors++
}
// 4. Update tauri.conf.json
try {
const tauriConfPath = path.join(process.cwd(), 'src-tauri', 'tauri.conf.json')
const tauriConf = JSON.parse(fs.readFileSync(tauriConfPath, 'utf8'))
tauriConf.version = newVersion
fs.writeFileSync(tauriConfPath, JSON.stringify(tauriConf, null, 2) + '\n')
console.log('Updated tauri.conf.json')
} catch (err) {
console.error('Failed to update tauri.conf.json:', err.message)
errors++
}
// Summary
console.log('\n' + '='.repeat(50))
if (errors === 0) {
console.log(`Successfully set version to ${newVersion} in all files!`)
} else {
console.log(`Completed with ${errors} error(s)`)
process.exit(1)
}
+5
View File
@@ -0,0 +1,5 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
/resources/
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "creamlinux-installer"
version = "1.4.2"
description = "DLC Manager for Steam games on Linux"
authors = ["tickbase"]
license = "MIT"
repository = "https://github.com/Novattz/creamlinux-installer"
edition = "2021"
rust-version = "1.77.2"
[build-dependencies]
tauri-build = { version = "2.2.0", features = [] }
[dependencies]
serde_json = { version = "1.0", features = ["raw_value"] }
serde = { version = "1.0", features = ["derive"] }
regex = "1"
xdg = "2"
log = "0.4"
log4rs = "1.2"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
zip = "0.6"
tempfile = "3.8"
walkdir = "2.3"
parking_lot = "0.12"
tauri = { version = "2.5.0", features = [] }
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.0.0-rc"
tauri-plugin-dialog = "2.0.0-rc"
tauri-plugin-fs = "2.0.0-rc"
num_cpus = "1.16.0"
tauri-plugin-process = "2"
async-trait = "0.1.89"
[features]
custom-protocol = ["tauri/custom-protocol"]
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main"],
"permissions": ["core:default", "updater:default", "process:default"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"identifier": "desktop-capability",
"platforms": [
"macOS",
"windows",
"linux"
],
"windows": [
"main"
],
"permissions": [
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

+295
View File
@@ -0,0 +1,295 @@
mod storage;
mod version;
pub use storage::{
get_creamlinux_version_dir, get_smokeapi_version_dir,
list_creamlinux_files, list_smokeapi_files, read_versions,
update_creamlinux_version, update_smokeapi_version, validate_smokeapi_cache,
validate_creamlinux_cache,
};
pub use version::{
read_manifest, remove_creamlinux_version, remove_smokeapi_version,
update_creamlinux_version as update_game_creamlinux_version,
update_smokeapi_version as update_game_smokeapi_version,
};
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
use log::{error, info, warn};
use std::collections::HashMap;
// Initialize the cache on app startup
// Downloads both unlockers if they don't exist
pub async fn initialize_cache() -> Result<(), String> {
info!("Initializing cache...");
let versions = read_versions()?;
let mut needs_smokeapi = false;
let mut needs_creamlinux = false;
// Check if SmokeAPI is properly cached
if versions.smokeapi.latest.is_empty() {
info!("No SmokeAPI version in manifest");
needs_smokeapi = true
} else {
// Validate that all files exist
match validate_smokeapi_cache(&versions.smokeapi.latest) {
Ok(true) => {
info!("SmokeAPI cache validated successfully");
}
Ok(false) => {
info!("SmokeAPI cache incomplete, re-downloading");
needs_smokeapi = true;
}
Err(e) => {
warn!("Failed to validate SmokeAPI cache: {}, re-downloading", e);
needs_smokeapi = true;
}
}
}
// Check if CreamLinux is properly cached
if versions.creamlinux.latest.is_empty() {
info!("No CreamLinux version in manifest");
needs_creamlinux = true;
} else {
match validate_creamlinux_cache(&versions.creamlinux.latest) {
Ok(true) => {
info!("CreamLinux cache validated successfully");
}
Ok(false) => {
info!("CreamLinux cache incomplete, re-downloading");
needs_creamlinux = true;
}
Err(e) => {
warn!("Failed to validate CreamLinux cache: {}, re-downloading", e);
needs_creamlinux = true;
}
}
}
// Download SmokeAPI
if needs_smokeapi {
info!("Downloading SmokeAPI...");
match SmokeAPI::download_to_cache().await {
Ok(version) => {
info!("Downloaded SmokeAPI version: {}", version);
update_smokeapi_version(&version)?;
}
Err(e) => {
error!("Failed to download SmokeAPI: {}", e);
return Err(format!("Failed to download SmokeAPI: {}", e));
}
}
}
// Download CreamLinux
if needs_creamlinux {
info!("Downloading CreamLinux...");
match CreamLinux::download_to_cache().await {
Ok(version) => {
info!("Downloaded CreamLinux version: {}", version);
update_creamlinux_version(&version)?;
}
Err(e) => {
error!("Failed to download CreamLinux: {}", e);
return Err(format!("Failed to download CreamLinux: {}", e));
}
}
}
if !needs_smokeapi && !needs_creamlinux {
info!("Cache already initialized and validated");
} else {
info!("Cache initialization complete");
}
Ok(())
}
// Check for updates and download new versions if available
pub async fn check_and_update_cache() -> Result<UpdateResult, String> {
info!("Checking for unlocker updates...");
let mut result = UpdateResult::default();
// Check SmokeAPI
let current_smokeapi = read_versions()?.smokeapi.latest;
match SmokeAPI::get_latest_version().await {
Ok(latest_version) => {
if current_smokeapi != latest_version {
info!(
"SmokeAPI update available: {} -> {}",
current_smokeapi, latest_version
);
match SmokeAPI::download_to_cache().await {
Ok(version) => {
update_smokeapi_version(&version)?;
result.smokeapi_updated = true;
result.new_smokeapi_version = Some(version);
info!("SmokeAPI updated successfully");
}
Err(e) => {
error!("Failed to download SmokeAPI update: {}", e);
return Err(format!("Failed to download SmokeAPI update: {}", e));
}
}
} else {
info!("SmokeAPI is up to date: {}", current_smokeapi);
}
}
Err(e) => {
warn!("Failed to check SmokeAPI version: {}", e);
}
}
// Check CreamLinux
let current_creamlinux = read_versions()?.creamlinux.latest;
match CreamLinux::get_latest_version().await {
Ok(latest_version) => {
if current_creamlinux != latest_version {
info!(
"CreamLinux update available: {} -> {}",
current_creamlinux, latest_version
);
match CreamLinux::download_to_cache().await {
Ok(version) => {
update_creamlinux_version(&version)?;
result.creamlinux_updated = true;
result.new_creamlinux_version = Some(version);
info!("CreamLinux updated successfully");
}
Err(e) => {
error!("Failed to download CreamLinux update: {}", e);
return Err(format!("Failed to download CreamLinux update: {}", e));
}
}
} else {
info!("CreamLinux is up to date: {}", current_creamlinux);
}
}
Err(e) => {
warn!("Failed to check CreamLinux version: {}", e);
}
}
Ok(result)
}
// Update all games that have outdated unlocker versions
pub async fn update_outdated_games(
games: &HashMap<String, crate::installer::Game>,
) -> Result<GameUpdateStats, String> {
info!("Checking for outdated game installations...");
let cached_versions = read_versions()?;
let mut stats = GameUpdateStats::default();
for (game_id, game) in games {
// Read the game's manifest
let manifest = match read_manifest(&game.path) {
Ok(m) => m,
Err(e) => {
warn!("Failed to read manifest for {}: {}", game.title, e);
continue;
}
};
// Check if SmokeAPI needs updating
if manifest.has_smokeapi()
&& manifest.is_smokeapi_outdated(&cached_versions.smokeapi.latest)
{
info!(
"Game '{}' has outdated SmokeAPI, updating...",
game.title
);
// Convert api_files Vec to comma-separated string
let api_files_str = game.api_files.join(",");
match SmokeAPI::install_to_game(&game.path, &api_files_str).await {
Ok(_) => {
update_game_smokeapi_version(&game.path, cached_versions.smokeapi.latest.clone())?;
stats.smokeapi_updated += 1;
info!("Updated SmokeAPI for '{}'", game.title);
}
Err(e) => {
error!("Failed to update SmokeAPI for '{}': {}", game.title, e);
stats.smokeapi_failed += 1;
}
}
}
// Check if CreamLinux needs updating
if manifest.has_creamlinux()
&& manifest.is_creamlinux_outdated(&cached_versions.creamlinux.latest)
{
info!(
"Game '{}' has outdated CreamLinux, updating...",
game.title
);
// For CreamLinux, we need to preserve the DLC configuration
match CreamLinux::install_to_game(&game.path, game_id).await {
Ok(_) => {
update_game_creamlinux_version(&game.path, cached_versions.creamlinux.latest.clone())?;
stats.creamlinux_updated += 1;
info!("Updated CreamLinux for '{}'", game.title);
}
Err(e) => {
error!("Failed to update CreamLinux for '{}': {}", game.title, e);
stats.creamlinux_failed += 1;
}
}
}
}
info!(
"Game update complete - SmokeAPI: {} updated, {} failed | CreamLinux: {} updated, {} failed",
stats.smokeapi_updated,
stats.smokeapi_failed,
stats.creamlinux_updated,
stats.creamlinux_failed
);
Ok(stats)
}
// Result of checking for cache updates
#[derive(Debug, Default, Clone)]
pub struct UpdateResult {
pub smokeapi_updated: bool,
pub creamlinux_updated: bool,
pub new_smokeapi_version: Option<String>,
pub new_creamlinux_version: Option<String>,
}
impl UpdateResult {
pub fn any_updated(&self) -> bool {
self.smokeapi_updated || self.creamlinux_updated
}
}
// Statistics about game updates
#[derive(Debug, Default, Clone)]
pub struct GameUpdateStats {
pub smokeapi_updated: u32,
pub smokeapi_failed: u32,
pub creamlinux_updated: u32,
pub creamlinux_failed: u32,
}
impl GameUpdateStats {
pub fn total_updated(&self) -> u32 {
self.smokeapi_updated + self.creamlinux_updated
}
pub fn total_failed(&self) -> u32 {
self.smokeapi_failed + self.creamlinux_failed
}
pub fn has_failures(&self) -> bool {
self.total_failed() > 0
}
}
+355
View File
@@ -0,0 +1,355 @@
use log::{info, warn};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
// Represents the versions.json file in the cache root
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CacheVersions {
pub smokeapi: VersionInfo,
pub creamlinux: VersionInfo,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct VersionInfo {
pub latest: String,
}
impl Default for CacheVersions {
fn default() -> Self {
Self {
smokeapi: VersionInfo {
latest: String::new(),
},
creamlinux: VersionInfo {
latest: String::new(),
},
}
}
}
// Get the cache directory path (~/.cache/creamlinux)
pub fn get_cache_dir() -> Result<PathBuf, String> {
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")
.map_err(|e| format!("Failed to get XDG directories: {}", e))?;
let cache_dir = xdg_dirs
.get_cache_home()
.parent()
.ok_or_else(|| "Failed to get cache parent directory".to_string())?
.join("creamlinux");
// Create the directory if it doesn't exist
if !cache_dir.exists() {
fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
info!("Created cache directory: {}", cache_dir.display());
}
Ok(cache_dir)
}
// Get the SmokeAPI cache directory path
pub fn get_smokeapi_dir() -> Result<PathBuf, String> {
let cache_dir = get_cache_dir()?;
let smokeapi_dir = cache_dir.join("smokeapi");
if !smokeapi_dir.exists() {
fs::create_dir_all(&smokeapi_dir)
.map_err(|e| format!("Failed to create SmokeAPI directory: {}", e))?;
info!("Created SmokeAPI directory: {}", smokeapi_dir.display());
}
Ok(smokeapi_dir)
}
// Get the CreamLinux cache directory path
pub fn get_creamlinux_dir() -> Result<PathBuf, String> {
let cache_dir = get_cache_dir()?;
let creamlinux_dir = cache_dir.join("creamlinux");
if !creamlinux_dir.exists() {
fs::create_dir_all(&creamlinux_dir)
.map_err(|e| format!("Failed to create CreamLinux directory: {}", e))?;
info!("Created CreamLinux directory: {}", creamlinux_dir.display());
}
Ok(creamlinux_dir)
}
// Get the path to a versioned SmokeAPI directory
pub fn get_smokeapi_version_dir(version: &str) -> Result<PathBuf, String> {
let smokeapi_dir = get_smokeapi_dir()?;
let version_dir = smokeapi_dir.join(version);
if !version_dir.exists() {
fs::create_dir_all(&version_dir)
.map_err(|e| format!("Failed to create SmokeAPI version directory: {}", e))?;
info!(
"Created SmokeAPI version directory: {}",
version_dir.display()
);
}
Ok(version_dir)
}
// Get the path to a versioned CreamLinux directory
pub fn get_creamlinux_version_dir(version: &str) -> Result<PathBuf, String> {
let creamlinux_dir = get_creamlinux_dir()?;
let version_dir = creamlinux_dir.join(version);
if !version_dir.exists() {
fs::create_dir_all(&version_dir)
.map_err(|e| format!("Failed to create CreamLinux version directory: {}", e))?;
info!(
"Created CreamLinux version directory: {}",
version_dir.display()
);
}
Ok(version_dir)
}
// Read the versions.json file from cache
pub fn read_versions() -> Result<CacheVersions, String> {
let cache_dir = get_cache_dir()?;
let versions_path = cache_dir.join("versions.json");
if !versions_path.exists() {
info!("versions.json doesn't exist, creating default");
return Ok(CacheVersions::default());
}
let content = fs::read_to_string(&versions_path)
.map_err(|e| format!("Failed to read versions.json: {}", e))?;
let versions: CacheVersions = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse versions.json: {}", e))?;
info!(
"Read cached versions - SmokeAPI: {}, CreamLinux: {}",
versions.smokeapi.latest, versions.creamlinux.latest
);
Ok(versions)
}
// Write the versions.json file to cache
pub fn write_versions(versions: &CacheVersions) -> Result<(), String> {
let cache_dir = get_cache_dir()?;
let versions_path = cache_dir.join("versions.json");
let content = serde_json::to_string_pretty(versions)
.map_err(|e| format!("Failed to serialize versions: {}", e))?;
fs::write(&versions_path, content)
.map_err(|e| format!("Failed to write versions.json: {}", e))?;
info!(
"Wrote versions.json - SmokeAPI: {}, CreamLinux: {}",
versions.smokeapi.latest, versions.creamlinux.latest
);
Ok(())
}
// Update the SmokeAPI version in versions.json and clean old version directories
pub fn update_smokeapi_version(new_version: &str) -> Result<(), String> {
let mut versions = read_versions()?;
let old_version = versions.smokeapi.latest.clone();
versions.smokeapi.latest = new_version.to_string();
write_versions(&versions)?;
// Delete old version directory if it exists and is different
if !old_version.is_empty() && old_version != new_version {
let old_dir = get_smokeapi_dir()?.join(&old_version);
if old_dir.exists() {
match fs::remove_dir_all(&old_dir) {
Ok(_) => info!("Deleted old SmokeAPI version directory: {}", old_version),
Err(e) => warn!(
"Failed to delete old SmokeAPI version directory: {}",
e
),
}
}
}
Ok(())
}
// Update the CreamLinux version in versions.json and clean old version directories
pub fn update_creamlinux_version(new_version: &str) -> Result<(), String> {
let mut versions = read_versions()?;
let old_version = versions.creamlinux.latest.clone();
versions.creamlinux.latest = new_version.to_string();
write_versions(&versions)?;
// Delete old version directory if it exists and is different
if !old_version.is_empty() && old_version != new_version {
let old_dir = get_creamlinux_dir()?.join(&old_version);
if old_dir.exists() {
match fs::remove_dir_all(&old_dir) {
Ok(_) => info!("Deleted old CreamLinux version directory: {}", old_version),
Err(e) => warn!(
"Failed to delete old CreamLinux version directory: {}",
e
),
}
}
}
Ok(())
}
// Get the SmokeAPI DLL path for the latest cached version
#[allow(dead_code)]
pub fn get_smokeapi_dll_path() -> Result<PathBuf, String> {
let versions = read_versions()?;
if versions.smokeapi.latest.is_empty() {
return Err("SmokeAPI is not cached".to_string());
}
let version_dir = get_smokeapi_version_dir(&versions.smokeapi.latest)?;
Ok(version_dir.join("SmokeAPI.dll"))
}
// Get the CreamLinux files directory path for the latest cached version
#[allow(dead_code)]
pub fn get_creamlinux_files_dir() -> Result<PathBuf, String> {
let versions = read_versions()?;
if versions.creamlinux.latest.is_empty() {
return Err("CreamLinux is not cached".to_string());
}
get_creamlinux_version_dir(&versions.creamlinux.latest)
}
/// List all SmokeAPI files in the cached version directory
pub fn list_smokeapi_files() -> Result<Vec<PathBuf>, String> {
let versions = read_versions()?;
if versions.smokeapi.latest.is_empty() {
return Ok(Vec::new());
}
let version_dir = get_smokeapi_version_dir(&versions.smokeapi.latest)?;
if !version_dir.exists() {
return Ok(Vec::new());
}
let entries = fs::read_dir(&version_dir)
.map_err(|e| format!("Failed to read SmokeAPI directory: {}", e))?;
let mut files = Vec::new();
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
// Get both .dll and .so files
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
if ext == "dll" || ext == "so" {
files.push(path);
}
}
}
}
Ok(files)
}
// List all CreamLinux files in the cached version directory
pub fn list_creamlinux_files() -> Result<Vec<PathBuf>, String> {
let versions = read_versions()?;
if versions.creamlinux.latest.is_empty() {
return Ok(Vec::new());
}
let version_dir = get_creamlinux_version_dir(&versions.creamlinux.latest)?;
if !version_dir.exists() {
return Ok(Vec::new());
}
let entries = fs::read_dir(&version_dir)
.map_err(|e| format!("Failed to read CreamLinux directory: {}", e))?;
let mut files = Vec::new();
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
if path.is_file() {
files.push(path);
}
}
}
Ok(files)
}
/// Validate that all required files exist for SmokeAPI
pub fn validate_smokeapi_cache(version: &str) -> Result<bool, String> {
let version_dir = get_smokeapi_version_dir(version)?;
if !version_dir.exists() {
return Ok(false);
}
// Required files for SmokeAPI
let required_files = vec![
"smoke_api32.dll",
"smoke_api64.dll",
"libsmoke_api32.so",
"libsmoke_api64.so",
];
let mut missing_files = Vec::new();
for file in &required_files {
let file_path = version_dir.join(file);
if !file_path.exists() {
missing_files.push(file.to_string());
}
}
if !missing_files.is_empty() {
info!("Missing required files in cache: {:?}", missing_files);
return Ok(false);
}
Ok(true)
}
/// Validate that all required files exist for CreamLinux
pub fn validate_creamlinux_cache(version: &str) -> Result<bool, String> {
let version_dir = get_creamlinux_version_dir(version)?;
if !version_dir.exists() {
return Ok(false);
}
// Required files for CreamLinux
let required_files = vec![
"cream.sh",
"cream_api.ini",
"lib32Creamlinux.so",
"lib64Creamlinux.so",
];
let mut missing_files = Vec::new();
for file in &required_files {
let file_path = version_dir.join(file);
if !file_path.exists() {
missing_files.push(file.to_string());
}
}
if !missing_files.is_empty() {
info!("Missing required files in cache: {:?}", missing_files);
return Ok(false);
}
Ok(true)
}
+177
View File
@@ -0,0 +1,177 @@
use log::{info};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
// Represents the version manifest stored in each game directory
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct GameManifest {
pub smokeapi_version: Option<String>,
pub creamlinux_version: Option<String>,
}
#[allow(dead_code)]
impl GameManifest {
// Create a new manifest with SmokeAPI version
pub fn with_smokeapi(version: String) -> Self {
Self {
smokeapi_version: Some(version),
creamlinux_version: None,
}
}
// Create a new manifest with CreamLinux version
pub fn with_creamlinux(version: String) -> Self {
Self {
smokeapi_version: None,
creamlinux_version: Some(version),
}
}
// Check if SmokeAPI is installed
pub fn has_smokeapi(&self) -> bool {
self.smokeapi_version.is_some()
}
// Check if CreamLinux is installed
pub fn has_creamlinux(&self) -> bool {
self.creamlinux_version.is_some()
}
// Check if SmokeAPI version is outdated
pub fn is_smokeapi_outdated(&self, latest_version: &str) -> bool {
match &self.smokeapi_version {
Some(version) => version != latest_version,
None => false,
}
}
// Check if CreamLinux version is outdated
pub fn is_creamlinux_outdated(&self, latest_version: &str) -> bool {
match &self.creamlinux_version {
Some(version) => version != latest_version,
None => false,
}
}
}
// Read the creamlinux.json manifest from a game directory
pub fn read_manifest(game_path: &str) -> Result<GameManifest, String> {
let manifest_path = Path::new(game_path).join("creamlinux.json");
if !manifest_path.exists() {
return Ok(GameManifest::default());
}
let content = fs::read_to_string(&manifest_path)
.map_err(|e| format!("Failed to read manifest: {}", e))?;
let manifest: GameManifest = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse manifest: {}", e))?;
info!(
"Read manifest from {}: SmokeAPI: {:?}, CreamLinux: {:?}",
game_path, manifest.smokeapi_version, manifest.creamlinux_version
);
Ok(manifest)
}
// Write the creamlinux.json manifest to a game directory
pub fn write_manifest(game_path: &str, manifest: &GameManifest) -> Result<(), String> {
let manifest_path = Path::new(game_path).join("creamlinux.json");
let content = serde_json::to_string_pretty(manifest)
.map_err(|e| format!("Failed to serialize manifest: {}", e))?;
fs::write(&manifest_path, content)
.map_err(|e| format!("Failed to write manifest: {}", e))?;
info!(
"Wrote manifest to {}: SmokeAPI: {:?}, CreamLinux: {:?}",
game_path, manifest.smokeapi_version, manifest.creamlinux_version
);
Ok(())
}
// Update the SmokeAPI version in the manifest
pub fn update_smokeapi_version(game_path: &str, version: String) -> Result<(), String> {
let mut manifest = read_manifest(game_path)?;
manifest.smokeapi_version = Some(version);
write_manifest(game_path, &manifest)
}
// Update the CreamLinux version in the manifest
pub fn update_creamlinux_version(game_path: &str, version: String) -> Result<(), String> {
let mut manifest = read_manifest(game_path)?;
manifest.creamlinux_version = Some(version);
write_manifest(game_path, &manifest)
}
// Remove SmokeAPI version from the manifest
pub fn remove_smokeapi_version(game_path: &str) -> Result<(), String> {
let mut manifest = read_manifest(game_path)?;
manifest.smokeapi_version = None;
// If both versions are None, delete the manifest file
if manifest.smokeapi_version.is_none() && manifest.creamlinux_version.is_none() {
let manifest_path = Path::new(game_path).join("creamlinux.json");
if manifest_path.exists() {
fs::remove_file(&manifest_path)
.map_err(|e| format!("Failed to delete manifest: {}", e))?;
info!("Deleted empty manifest from {}", game_path);
}
} else {
write_manifest(game_path, &manifest)?;
}
Ok(())
}
// Remove CreamLinux version from the manifest
pub fn remove_creamlinux_version(game_path: &str) -> Result<(), String> {
let mut manifest = read_manifest(game_path)?;
manifest.creamlinux_version = None;
// If both versions are None, delete the manifest file
if manifest.smokeapi_version.is_none() && manifest.creamlinux_version.is_none() {
let manifest_path = Path::new(game_path).join("creamlinux.json");
if manifest_path.exists() {
fs::remove_file(&manifest_path)
.map_err(|e| format!("Failed to delete manifest: {}", e))?;
info!("Deleted empty manifest from {}", game_path);
}
} else {
write_manifest(game_path, &manifest)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manifest_creation() {
let manifest = GameManifest::with_smokeapi("v1.0.0".to_string());
assert_eq!(manifest.smokeapi_version, Some("v1.0.0".to_string()));
assert_eq!(manifest.creamlinux_version, None);
let manifest = GameManifest::with_creamlinux("v2.0.0".to_string());
assert_eq!(manifest.smokeapi_version, None);
assert_eq!(manifest.creamlinux_version, Some("v2.0.0".to_string()));
}
#[test]
fn test_outdated_check() {
let mut manifest = GameManifest::with_smokeapi("v1.0.0".to_string());
assert!(manifest.is_smokeapi_outdated("v2.0.0"));
assert!(!manifest.is_smokeapi_outdated("v1.0.0"));
manifest.creamlinux_version = Some("v1.5.0".to_string());
assert!(manifest.is_creamlinux_outdated("v2.0.0"));
assert!(!manifest.is_creamlinux_outdated("v1.5.0"));
}
}
+118
View File
@@ -0,0 +1,118 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use log::info;
// User configuration structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
// Whether to show the disclaimer on startup
pub show_disclaimer: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
show_disclaimer: true,
}
}
}
// Get the config directory path (~/.config/creamlinux)
fn get_config_dir() -> Result<PathBuf, String> {
let home = std::env::var("HOME")
.map_err(|_| "Failed to get HOME directory".to_string())?;
let config_dir = PathBuf::from(home).join(".config").join("creamlinux");
Ok(config_dir)
}
// Get the config file path
fn get_config_path() -> Result<PathBuf, String> {
let config_dir = get_config_dir()?;
Ok(config_dir.join("config.json"))
}
// Ensure the config directory exists
fn ensure_config_dir() -> Result<(), String> {
let config_dir = get_config_dir()?;
if !config_dir.exists() {
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
info!("Created config directory at {:?}", config_dir);
}
Ok(())
}
// Load configuration from disk
pub fn load_config() -> Result<Config, String> {
ensure_config_dir()?;
let config_path = get_config_path()?;
// If config file doesn't exist, create default config
if !config_path.exists() {
let default_config = Config::default();
save_config(&default_config)?;
info!("Created default config file at {:?}", config_path);
return Ok(default_config);
}
// Read and parse config file
let config_str = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config file: {}", e))?;
let config: Config = serde_json::from_str(&config_str)
.map_err(|e| format!("Failed to parse config file: {}", e))?;
info!("Loaded config from {:?}", config_path);
Ok(config)
}
// Save configuration to disk
pub fn save_config(config: &Config) -> Result<(), String> {
ensure_config_dir()?;
let config_path = get_config_path()?;
let config_str = serde_json::to_string_pretty(config)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
fs::write(&config_path, config_str)
.map_err(|e| format!("Failed to write config file: {}", e))?;
info!("Saved config to {:?}", config_path);
Ok(())
}
// Update a specific config value
pub fn update_config<F>(updater: F) -> Result<Config, String>
where
F: FnOnce(&mut Config),
{
let mut config = load_config()?;
updater(&mut config);
save_config(&config)?;
Ok(config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.show_disclaimer);
}
#[test]
fn test_config_serialization() {
let config = Config::default();
let json = serde_json::to_string(&config).unwrap();
let parsed: Config = serde_json::from_str(&json).unwrap();
assert_eq!(config.show_disclaimer, parsed.show_disclaimer);
}
}
+353
View File
@@ -0,0 +1,353 @@
use log::{error, info};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use tauri::Manager;
// More detailed DLC information with enabled state
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DlcInfoWithState {
pub appid: String,
pub name: String,
pub enabled: bool,
}
// Parse the cream_api.ini file to extract both enabled and disabled DLCs
pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
info!("Reading enabled DLCs from {}", game_path);
let cream_api_path = Path::new(game_path).join("cream_api.ini");
if !cream_api_path.exists() {
return Err(format!(
"cream_api.ini not found at {}",
cream_api_path.display()
));
}
let contents = match fs::read_to_string(&cream_api_path) {
Ok(c) => c,
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
};
// Extract DLCs
let mut in_dlc_section = false;
let mut enabled_dlcs = Vec::new();
for line in contents.lines() {
let trimmed = line.trim();
// Check if we're in the DLC section
if trimmed == "[dlc]" {
in_dlc_section = true;
continue;
}
// Check if we're leaving the DLC section
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
in_dlc_section = false;
continue;
}
// Skip empty lines and non-DLC comments
if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') {
// Extract the DLC app ID
if let Some(appid) = trimmed.split('=').next() {
let appid_clean = appid.trim();
// Check if the line is commented out (indicating a disabled DLC)
if !appid_clean.starts_with("#") {
enabled_dlcs.push(appid_clean.to_string());
}
}
}
}
info!("Found {} enabled DLCs", enabled_dlcs.len());
Ok(enabled_dlcs)
}
// Get all DLCs (both enabled and disabled) from cream_api.ini
pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
info!("Reading all DLCs from {}", game_path);
let cream_api_path = Path::new(game_path).join("cream_api.ini");
if !cream_api_path.exists() {
return Err(format!(
"cream_api.ini not found at {}",
cream_api_path.display()
));
}
let contents = match fs::read_to_string(&cream_api_path) {
Ok(c) => c,
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
};
// Extract DLCs
let mut in_dlc_section = false;
let mut all_dlcs = Vec::new();
for line in contents.lines() {
let trimmed = line.trim();
// Check if we're in the DLC section
if trimmed == "[dlc]" {
in_dlc_section = true;
continue;
}
// Check if we're leaving the DLC section
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
in_dlc_section = false;
continue;
}
// Process DLC entries (both enabled and commented/disabled)
if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') {
let is_commented = trimmed.starts_with("#");
let actual_line = if is_commented {
trimmed.trim_start_matches('#').trim()
} else {
trimmed
};
let parts: Vec<&str> = actual_line.splitn(2, '=').collect();
if parts.len() == 2 {
let appid = parts[0].trim();
let name = parts[1].trim();
all_dlcs.push(DlcInfoWithState {
appid: appid.to_string(),
name: name.to_string().trim_matches('"').to_string(),
enabled: !is_commented,
});
}
}
}
info!(
"Found {} total DLCs ({} enabled, {} disabled)",
all_dlcs.len(),
all_dlcs.iter().filter(|d| d.enabled).count(),
all_dlcs.iter().filter(|d| !d.enabled).count()
);
Ok(all_dlcs)
}
// Update the cream_api.ini file with the user's DLC selections
pub fn update_dlc_configuration(
game_path: &str,
dlcs: Vec<DlcInfoWithState>,
) -> Result<(), String> {
info!("Updating DLC configuration for {}", game_path);
let cream_api_path = Path::new(game_path).join("cream_api.ini");
if !cream_api_path.exists() {
return Err(format!(
"cream_api.ini not found at {}",
cream_api_path.display()
));
}
// Read the current file contents
let current_contents = match fs::read_to_string(&cream_api_path) {
Ok(c) => c,
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
};
// Create a mapping of DLC appid to its state for easy lookup
let dlc_states: HashMap<String, (bool, String)> = dlcs
.iter()
.map(|dlc| (dlc.appid.clone(), (dlc.enabled, dlc.name.clone())))
.collect();
// Keep track of processed DLCs to avoid duplicates
let mut processed_dlcs = HashSet::new();
// Process the file line by line to retain most of the original structure
let mut new_contents = Vec::new();
let mut in_dlc_section = false;
for line in current_contents.lines() {
let trimmed = line.trim();
// Add section markers directly
if trimmed == "[dlc]" {
in_dlc_section = true;
new_contents.push(line.to_string());
continue;
}
// Check if we're leaving the DLC section
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
in_dlc_section = false;
// Before leaving the DLC section, add any DLCs that weren't processed yet
for (appid, (enabled, name)) in &dlc_states {
if !processed_dlcs.contains(appid) {
if *enabled {
new_contents.push(format!("{} = {}", appid, name));
} else {
new_contents.push(format!("# {} = {}", appid, name));
}
}
}
// Now add the section marker
new_contents.push(line.to_string());
continue;
}
if in_dlc_section && !trimmed.is_empty() {
let is_comment_line = trimmed.starts_with(';');
// If it's a regular comment line (not a DLC), keep it as is
if is_comment_line {
new_contents.push(line.to_string());
continue;
}
// Check if it's a commented-out DLC line or a regular DLC line
let is_commented = trimmed.starts_with("#");
let actual_line = if is_commented {
trimmed.trim_start_matches('#').trim()
} else {
trimmed
};
// Extract appid and name
let parts: Vec<&str> = actual_line.splitn(2, '=').collect();
if parts.len() == 2 {
let appid = parts[0].trim();
let name = parts[1].trim();
// Check if this DLC exists in our updated list
if let Some((enabled, _)) = dlc_states.get(appid) {
// Add the DLC with its updated state
if *enabled {
new_contents.push(format!("{} = {}", appid, name));
} else {
new_contents.push(format!("# {} = {}", appid, name));
}
processed_dlcs.insert(appid.to_string());
} else {
// Not in our list, keep the original line
new_contents.push(line.to_string());
}
} else {
// Invalid format or not a DLC line, keep as is
new_contents.push(line.to_string());
}
} else if !in_dlc_section || trimmed.is_empty() {
// Not a DLC line or empty line, keep as is
new_contents.push(line.to_string());
}
}
// If we never left the DLC section, make sure we add any unprocessed DLCs
if in_dlc_section {
for (appid, (enabled, name)) in &dlc_states {
if !processed_dlcs.contains(appid) {
if *enabled {
new_contents.push(format!("{} = {}", appid, name));
} else {
new_contents.push(format!("# {} = {}", appid, name));
}
}
}
}
// Write the updated file
match fs::write(&cream_api_path, new_contents.join("\n")) {
Ok(_) => {
info!(
"Successfully updated DLC configuration at {}",
cream_api_path.display()
);
Ok(())
}
Err(e) => {
error!("Failed to write updated cream_api.ini: {}", e);
Err(format!("Failed to write updated cream_api.ini: {}", e))
}
}
}
// Create a custom installation with selected DLCs
pub async fn install_cream_with_dlcs(
game_id: String,
app_handle: tauri::AppHandle,
selected_dlcs: Vec<DlcInfoWithState>,
) -> Result<(), String> {
use crate::AppState;
// Count enabled DLCs for logging
let enabled_dlc_count = selected_dlcs.iter().filter(|dlc| dlc.enabled).count();
info!(
"Starting installation of CreamLinux with {} selected DLCs",
enabled_dlc_count
);
// Get the game from state
let game = {
let state = app_handle.state::<AppState>();
let games = state.games.lock();
match games.get(&game_id) {
Some(g) => g.clone(),
None => return Err(format!("Game with ID {} not found", game_id)),
}
};
info!(
"Installing CreamLinux for game: {} ({})",
game.title, game_id
);
// Convert DlcInfoWithState to installer::DlcInfo for those that are enabled
let enabled_dlcs = selected_dlcs
.iter()
.filter(|dlc| dlc.enabled)
.map(|dlc| crate::installer::DlcInfo {
appid: dlc.appid.clone(),
name: dlc.name.clone(),
})
.collect::<Vec<_>>();
// Install CreamLinux binaries from cache
use crate::unlockers::{CreamLinux, Unlocker};
let game_path = game.path.clone();
// Install binaries
CreamLinux::install_to_game(&game.path, &game_id)
.await
.map_err(|e| format!("Failed to install CreamLinux binaries: {}", e))?;
// Write cream_api.ini with DLCs
let cream_api_path = Path::new(&game_path).join("cream_api.ini");
let mut config = String::new();
config.push_str(&format!("APPID = {}\n[config]\n", game_id));
config.push_str("issubscribedapp_on_false_use_real = true\n");
config.push_str("[methods]\n");
config.push_str("disable_steamapps_issubscribedapp = false\n");
config.push_str("[dlc]\n");
for dlc in &enabled_dlcs {
config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name));
}
fs::write(&cream_api_path, config)
.map_err(|e| format!("Failed to write cream_api.ini: {}", e))?;
// Update version manifest
let cached_versions = crate::cache::read_versions()?;
crate::cache::update_game_creamlinux_version(&game_path, cached_versions.creamlinux.latest)?;
info!(
"CreamLinux installation completed successfully for game: {}",
game.title
);
Ok(())
}
+44
View File
@@ -0,0 +1,44 @@
// This module contains helper functions for file operations during installation
use std::fs;
use std::io;
use std::path::Path;
// Copy a file with backup
#[allow(dead_code)]
pub fn copy_with_backup(src: &Path, dest: &Path) -> io::Result<()> {
// If destination exists, create a backup
if dest.exists() {
let backup = dest.with_extension("bak");
fs::copy(dest, &backup)?;
}
fs::copy(src, dest)?;
Ok(())
}
// Safely remove a file (doesn't error if it doesn't exist)
#[allow(dead_code)]
pub fn safe_remove(path: &Path) -> io::Result<()> {
if path.exists() {
fs::remove_file(path)?;
}
Ok(())
}
// Make a file executable (Unix only)
#[cfg(unix)]
#[allow(dead_code)]
pub fn make_executable(path: &Path) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
pub fn make_executable(_path: &Path) -> io::Result<()> {
Ok(())
}
+766
View File
@@ -0,0 +1,766 @@
mod file_ops;
use crate::cache::{
remove_creamlinux_version, remove_smokeapi_version,
update_game_creamlinux_version, update_game_smokeapi_version,
};
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
use crate::AppState;
use log::{error, info, warn};
use reqwest;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fs;
use std::path::Path;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tauri::Manager;
use tauri::{AppHandle, Emitter};
// Type of installer
#[derive(Debug, Clone, Copy)]
pub enum InstallerType {
Cream,
Smoke,
}
// Action to perform
#[derive(Debug, Clone, Copy)]
pub enum InstallerAction {
Install,
Uninstall,
}
// DLC Information structure
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DlcInfo {
pub appid: String,
pub name: String,
}
// Struct to hold installation instructions for the frontend
#[derive(Serialize, Debug, Clone)]
pub struct InstallationInstructions {
#[serde(rename = "type")]
pub type_: String,
pub command: String,
pub game_title: String,
pub dlc_count: Option<usize>,
}
// Game information structure
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Game {
pub id: String,
pub title: String,
pub path: String,
pub native: bool,
pub api_files: Vec<String>,
pub cream_installed: bool,
pub smoke_installed: bool,
pub installing: bool,
}
// Emit a progress update to the frontend
pub fn emit_progress(
app_handle: &AppHandle,
title: &str,
message: &str,
progress: f32,
complete: bool,
show_instructions: bool,
instructions: Option<InstallationInstructions>,
) {
let mut payload = json!({
"title": title,
"message": message,
"progress": progress,
"complete": complete,
"show_instructions": show_instructions
});
if let Some(inst) = instructions {
payload["instructions"] = serde_json::to_value(inst).unwrap_or_default();
}
if let Err(e) = app_handle.emit("installation-progress", payload) {
warn!("Failed to emit progress event: {}", e);
}
}
// Process a single game action (install/uninstall Cream/Smoke)
pub async fn process_action(
game_id: String,
installer_type: InstallerType,
action: InstallerAction,
game: Game,
app_handle: AppHandle,
) -> Result<(), String> {
match (installer_type, action) {
(InstallerType::Cream, InstallerAction::Install) => {
install_creamlinux(game_id, game, app_handle).await
}
(InstallerType::Cream, InstallerAction::Uninstall) => {
uninstall_creamlinux(game, app_handle).await
}
(InstallerType::Smoke, InstallerAction::Install) => {
install_smokeapi(game, app_handle).await
}
(InstallerType::Smoke, InstallerAction::Uninstall) => {
uninstall_smokeapi(game, app_handle).await
}
}
}
// Install CreamLinux to a game
async fn install_creamlinux(
game_id: String,
game: Game,
app_handle: AppHandle,
) -> Result<(), String> {
if !game.native {
return Err("CreamLinux can only be installed on native Linux games".to_string());
}
info!("Installing CreamLinux for game: {}", game.title);
let game_title = game.title.clone();
emit_progress(
&app_handle,
&format!("Installing CreamLinux for {}", game_title),
"Fetching DLC list...",
10.0,
false,
false,
None,
);
// Fetch DLC list
let dlcs = match fetch_dlc_details(&game_id).await {
Ok(dlcs) => dlcs,
Err(e) => {
error!("Failed to fetch DLC details: {}", e);
return Err(format!("Failed to fetch DLC details: {}", e));
}
};
let dlc_count = dlcs.len();
info!("Found {} DLCs for {}", dlc_count, game_title);
emit_progress(
&app_handle,
&format!("Installing CreamLinux for {}", game_title),
"Installing from cache...",
50.0,
false,
false,
None,
);
// Install CreamLinux binaries from cache
CreamLinux::install_to_game(&game.path, &game_id)
.await
.map_err(|e| format!("Failed to install CreamLinux: {}", e))?;
emit_progress(
&app_handle,
&format!("Installing CreamLinux for {}", game_title),
"Writing DLC configuration...",
80.0,
false,
false,
None,
);
// Write cream_api.ini with DLCs
write_cream_api_ini(&game.path, &game_id, &dlcs)?;
// Update version manifest
let cached_versions = crate::cache::read_versions()?;
update_game_creamlinux_version(&game.path, cached_versions.creamlinux.latest)?;
// Emit completion with instructions
let instructions = InstallationInstructions {
type_: "cream_install".to_string(),
command: "sh ./cream.sh %command%".to_string(),
game_title: game_title.clone(),
dlc_count: Some(dlc_count),
};
emit_progress(
&app_handle,
&format!("Installation Completed: {}", game_title),
"CreamLinux has been installed successfully!",
100.0,
true,
true,
Some(instructions),
);
info!("CreamLinux installation completed for: {}", game_title);
Ok(())
}
// Uninstall CreamLinux from a game
async fn uninstall_creamlinux(game: Game, app_handle: AppHandle) -> Result<(), String> {
if !game.native {
return Err("CreamLinux can only be uninstalled from native Linux games".to_string());
}
let game_title = game.title.clone();
info!("Uninstalling CreamLinux from game: {}", game_title);
emit_progress(
&app_handle,
&format!("Uninstalling CreamLinux from {}", game_title),
"Removing CreamLinux files...",
50.0,
false,
false,
None,
);
CreamLinux::uninstall_from_game(&game.path, &game.id)
.await
.map_err(|e| format!("Failed to uninstall CreamLinux: {}", e))?;
// Remove version from manifest
remove_creamlinux_version(&game.path)?;
emit_progress(
&app_handle,
&format!("Uninstallation Completed: {}", game_title),
"CreamLinux has been removed successfully!",
100.0,
true,
false,
None,
);
info!("CreamLinux uninstallation completed for: {}", game_title);
Ok(())
}
async fn install_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), String> {
// Check if native or proton and route accordingly
if game.native {
install_smokeapi_native(game, app_handle).await
} else {
install_smokeapi_proton(game, app_handle).await
}
}
async fn uninstall_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), String> {
// Check if native or proton and route accordingly
if game.native {
uninstall_smokeapi_native(game, app_handle).await
} else {
uninstall_smokeapi_proton(game, app_handle).await
}
}
// Install SmokeAPI to a proton game
async fn install_smokeapi_proton(game: Game, app_handle: AppHandle) -> Result<(), String> {
if game.native {
return Err("SmokeAPI can only be installed on Proton/Windows games".to_string());
}
info!("Installing SmokeAPI for game: {}", game.title);
let game_title = game.title.clone();
emit_progress(
&app_handle,
&format!("Installing SmokeAPI for {}", game_title),
"Installing from cache...",
50.0,
false,
false,
None,
);
// Join api_files into a comma-separated string for the context
let api_files_str = game.api_files.join(",");
// Install SmokeAPI from cache
SmokeAPI::install_to_game(&game.path, &api_files_str)
.await
.map_err(|e| format!("Failed to install SmokeAPI: {}", e))?;
// Update version manifest
let cached_versions = crate::cache::read_versions()?;
update_game_smokeapi_version(&game.path, cached_versions.smokeapi.latest)?;
emit_progress(
&app_handle,
&format!("Installation Completed: {}", game_title),
"SmokeAPI has been installed successfully!",
100.0,
true,
false,
None,
);
info!("SmokeAPI installation completed for: {}", game_title);
Ok(())
}
// Uninstall SmokeAPI from a proton game
async fn uninstall_smokeapi_proton(game: Game, app_handle: AppHandle) -> Result<(), String> {
if game.native {
return Err("SmokeAPI can only be uninstalled from Proton/Windows games".to_string());
}
let game_title = game.title.clone();
info!("Uninstalling SmokeAPI from game: {}", game_title);
emit_progress(
&app_handle,
&format!("Uninstalling SmokeAPI from {}", game_title),
"Removing SmokeAPI files...",
50.0,
false,
false,
None,
);
// Join api_files into a comma-separated string for the context
let api_files_str = game.api_files.join(",");
SmokeAPI::uninstall_from_game(&game.path, &api_files_str)
.await
.map_err(|e| format!("Failed to uninstall SmokeAPI: {}", e))?;
// Remove version from manifest
remove_smokeapi_version(&game.path)?;
emit_progress(
&app_handle,
&format!("Uninstallation Completed: {}", game_title),
"SmokeAPI has been removed successfully!",
100.0,
true,
false,
None,
);
info!("SmokeAPI uninstallation completed for: {}", game_title);
Ok(())
}
// Install SmokeAPI to a native Linux game
async fn install_smokeapi_native(
game: Game,
app_handle: AppHandle,
) -> Result<(), String> {
info!("Installing SmokeAPI (native) for game: {}", game.title);
let game_title = game.title.clone();
emit_progress(
&app_handle,
&format!("Installing SmokeAPI for {}", game_title),
"Detecting game architecture...",
20.0,
false,
false,
None,
);
emit_progress(
&app_handle,
&format!("Installing SmokeAPI for {}", game_title),
"Installing from cache...",
50.0,
false,
false,
None,
);
// Install SmokeAPI for native Linux (empty string for api_files_str)
SmokeAPI::install_to_game(&game.path, "")
.await
.map_err(|e| format!("Failed to install SmokeAPI: {}", e))?;
// Update version manifest
let cached_versions = crate::cache::read_versions()?;
update_game_smokeapi_version(&game.path, cached_versions.smokeapi.latest)?;
emit_progress(
&app_handle,
&format!("Installation Completed: {}", game_title),
"SmokeAPI has been installed successfully!",
100.0,
true,
false,
None,
);
info!("SmokeAPI (native) installation completed for: {}", game_title);
Ok(())
}
// Uninstall SmokeAPI from a native Linux game
async fn uninstall_smokeapi_native(game: Game, app_handle: AppHandle) -> Result<(), String> {
if !game.native {
return Err("This function is only for native Linux games".to_string());
}
let game_title = game.title.clone();
info!("Uninstalling SmokeAPI (native) from game: {}", game_title);
emit_progress(
&app_handle,
&format!("Uninstalling SmokeAPI from {}", game_title),
"Removing SmokeAPI files...",
50.0,
false,
false,
None,
);
// Uninstall SmokeAPI (empty string for api_files_str)
SmokeAPI::uninstall_from_game(&game.path, "")
.await
.map_err(|e| format!("Failed to uninstall SmokeAPI: {}", e))?;
// Remove version from manifest
remove_smokeapi_version(&game.path)?;
emit_progress(
&app_handle,
&format!("Uninstallation Completed: {}", game_title),
"SmokeAPI has been removed successfully!",
100.0,
true,
false,
None,
);
info!("SmokeAPI (native) uninstallation completed for: {}", game_title);
Ok(())
}
// Fetch DLC details from Steam API (simple version without progress)
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, String> {
let client = reqwest::Client::new();
let base_url = format!(
"https://store.steampowered.com/api/appdetails?appids={}",
app_id
);
let response = client
.get(&base_url)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Failed to fetch game details: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Failed to fetch game details: HTTP {}",
response.status()
));
}
let data: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let dlc_ids = match data
.get(app_id)
.and_then(|app| app.get("data"))
.and_then(|data| data.get("dlc"))
{
Some(dlc_array) => match dlc_array.as_array() {
Some(array) => array
.iter()
.filter_map(|id| id.as_u64().map(|n| n.to_string()))
.collect::<Vec<String>>(),
_ => Vec::new(),
},
_ => Vec::new(),
};
info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id);
let mut dlc_details = Vec::new();
for dlc_id in dlc_ids {
let dlc_url = format!(
"https://store.steampowered.com/api/appdetails?appids={}",
dlc_id
);
// Add a small delay to avoid rate limiting
tokio::time::sleep(Duration::from_millis(300)).await;
let dlc_response = client
.get(&dlc_url)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Failed to fetch DLC details: {}", e))?;
if dlc_response.status().is_success() {
let dlc_data: serde_json::Value = dlc_response
.json()
.await
.map_err(|e| format!("Failed to parse DLC response: {}", e))?;
let dlc_name = match dlc_data
.get(&dlc_id)
.and_then(|app| app.get("data"))
.and_then(|data| data.get("name"))
{
Some(name) => match name.as_str() {
Some(s) => s.to_string(),
_ => "Unknown DLC".to_string(),
},
_ => "Unknown DLC".to_string(),
};
info!("Found DLC: {} ({})", dlc_name, dlc_id);
dlc_details.push(DlcInfo {
appid: dlc_id,
name: dlc_name,
});
} else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
// If rate limited, wait longer
error!("Rate limited by Steam API, waiting 10 seconds");
tokio::time::sleep(Duration::from_secs(10)).await;
}
}
info!(
"Successfully retrieved details for {} DLCs",
dlc_details.len()
);
Ok(dlc_details)
}
// Fetch DLC details from Steam API with progress updates
pub async fn fetch_dlc_details_with_progress(
app_id: &str,
app_handle: &tauri::AppHandle,
) -> Result<Vec<DlcInfo>, String> {
info!(
"Starting DLC details fetch with progress for game ID: {}",
app_id
);
// Get a reference to a cancellation flag from app state
let state = app_handle.state::<AppState>();
let should_cancel = state.fetch_cancellation.clone();
let client = reqwest::Client::new();
let base_url = format!(
"https://store.steampowered.com/api/appdetails?appids={}",
app_id
);
// Emit initial progress
emit_dlc_progress(app_handle, "Looking up game details...", 5, None);
info!("Emitted initial DLC progress: 5%");
let response = client
.get(&base_url)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Failed to fetch game details: {}", e))?;
if !response.status().is_success() {
let error_msg = format!("Failed to fetch game details: HTTP {}", response.status());
error!("{}", error_msg);
return Err(error_msg);
}
let data: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let dlc_ids = match data
.get(app_id)
.and_then(|app| app.get("data"))
.and_then(|data| data.get("dlc"))
{
Some(dlc_array) => match dlc_array.as_array() {
Some(array) => array
.iter()
.filter_map(|id| id.as_u64().map(|n| n.to_string()))
.collect::<Vec<String>>(),
_ => Vec::new(),
},
_ => Vec::new(),
};
info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id);
emit_dlc_progress(
app_handle,
&format!("Found {} DLCs. Fetching details...", dlc_ids.len()),
10,
None,
);
info!("Emitted DLC progress: 10%, found {} DLCs", dlc_ids.len());
let mut dlc_details = Vec::new();
let total_dlcs = dlc_ids.len();
for (index, dlc_id) in dlc_ids.iter().enumerate() {
// Check if cancellation was requested
if should_cancel.load(Ordering::SeqCst) {
info!("DLC fetch cancelled for game {}", app_id);
return Err("Operation cancelled by user".to_string());
}
let progress_percent = 10.0 + (index as f32 / total_dlcs as f32) * 90.0;
let progress_rounded = progress_percent as u32;
let remaining_dlcs = total_dlcs - index;
// Estimate time remaining (rough calculation - 300ms per DLC)
let est_time_left = if remaining_dlcs > 0 {
let seconds = (remaining_dlcs as f32 * 0.3).ceil() as u32;
if seconds < 60 {
format!("~{} seconds", seconds)
} else {
format!("~{} minute(s)", (seconds as f32 / 60.0).ceil() as u32)
}
} else {
"almost done".to_string()
};
info!(
"Processing DLC {}/{} - Progress: {}%",
index + 1,
total_dlcs,
progress_rounded
);
emit_dlc_progress(
app_handle,
&format!("Processing DLC {}/{}", index + 1, total_dlcs),
progress_rounded,
Some(&est_time_left),
);
let dlc_url = format!(
"https://store.steampowered.com/api/appdetails?appids={}",
dlc_id
);
// Add a small delay to avoid rate limiting
tokio::time::sleep(Duration::from_millis(300)).await;
let dlc_response = client
.get(&dlc_url)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Failed to fetch DLC details: {}", e))?;
if dlc_response.status().is_success() {
let dlc_data: serde_json::Value = dlc_response
.json()
.await
.map_err(|e| format!("Failed to parse DLC response: {}", e))?;
let dlc_name = match dlc_data
.get(&dlc_id)
.and_then(|app| app.get("data"))
.and_then(|data| data.get("name"))
{
Some(name) => match name.as_str() {
Some(s) => s.to_string(),
_ => "Unknown DLC".to_string(),
},
_ => "Unknown DLC".to_string(),
};
info!("Found DLC: {} ({})", dlc_name, dlc_id);
let dlc_info = DlcInfo {
appid: dlc_id.clone(),
name: dlc_name,
};
// Emit each DLC as we find it
if let Ok(json) = serde_json::to_string(&dlc_info) {
if let Err(e) = app_handle.emit("dlc-found", json) {
warn!("Failed to emit dlc-found event: {}", e);
} else {
info!("Emitted dlc-found event for DLC: {}", dlc_id);
}
}
dlc_details.push(dlc_info);
} else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
// If rate limited, wait longer
error!("Rate limited by Steam API, waiting 10 seconds");
emit_dlc_progress(
app_handle,
"Rate limited by Steam. Waiting...",
progress_rounded,
None,
);
tokio::time::sleep(Duration::from_secs(10)).await;
}
}
// Final progress update
info!(
"Completed DLC fetch. Found {} DLCs in total",
dlc_details.len()
);
emit_dlc_progress(
app_handle,
&format!("Completed! Found {} DLCs", dlc_details.len()),
100,
None,
);
info!("Emitted final DLC progress: 100%");
Ok(dlc_details)
}
// Emit DLC progress updates to the frontend
fn emit_dlc_progress(
app_handle: &tauri::AppHandle,
message: &str,
progress: u32,
time_left: Option<&str>,
) {
let mut payload = json!({
"message": message,
"progress": progress
});
if let Some(time) = time_left {
payload["timeLeft"] = json!(time);
}
if let Err(e) = app_handle.emit("dlc-progress", payload) {
warn!("Failed to emit dlc-progress event: {}", e);
}
}
// Write cream_api.ini configuration file
fn write_cream_api_ini(game_path: &str, app_id: &str, dlcs: &[DlcInfo]) -> Result<(), String> {
let cream_api_path = Path::new(game_path).join("cream_api.ini");
let mut config = String::new();
config.push_str(&format!("APPID = {}\n[config]\n", app_id));
config.push_str("issubscribedapp_on_false_use_real = true\n");
config.push_str("[methods]\n");
config.push_str("disable_steamapps_issubscribedapp = false\n");
config.push_str("[dlc]\n");
for dlc in dlcs {
config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name));
}
fs::write(&cream_api_path, config)
.map_err(|e| format!("Failed to write cream_api.ini: {}", e))?;
info!("Wrote cream_api.ini to {}", cream_api_path.display());
Ok(())
}
+758
View File
@@ -0,0 +1,758 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
mod cache;
mod utils;
mod dlc_manager;
mod installer;
mod searcher;
mod unlockers;
mod smokeapi_config;
mod config;
use crate::config::Config;
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
use dlc_manager::DlcInfoWithState;
use installer::{Game, InstallerAction, InstallerType};
use log::{debug, error, info, warn};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use tauri::State;
use tauri::{Emitter, Manager};
use tauri_plugin_updater::Builder as UpdaterBuilder;
use tokio::time::Instant;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GameAction {
game_id: String,
action: String,
}
#[derive(Debug, Clone)]
struct DlcCache {
#[allow(dead_code)]
data: Vec<DlcInfoWithState>,
#[allow(dead_code)]
timestamp: Instant,
}
// Structure to hold the state of installed games
pub struct AppState {
games: Mutex<HashMap<String, Game>>,
dlc_cache: Mutex<HashMap<String, DlcCache>>,
fetch_cancellation: Arc<AtomicBool>,
}
// Load the current configuration
#[tauri::command]
fn load_config() -> Result<Config, String> {
config::load_config()
}
// Update configuration
#[tauri::command]
fn update_config(config_data: Config) -> Result<Config, String> {
config::save_config(&config_data)?;
Ok(config_data)
}
#[tauri::command]
fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, String> {
info!("Getting all DLCs (enabled and disabled) for: {}", game_path);
dlc_manager::get_all_dlcs(&game_path)
}
#[tauri::command]
async fn scan_steam_games(
state: State<'_, AppState>,
app_handle: tauri::AppHandle,
) -> Result<Vec<Game>, String> {
info!("Starting Steam games scan");
emit_scan_progress(&app_handle, "Locating Steam libraries...", 10);
let paths = searcher::get_default_steam_paths();
emit_scan_progress(&app_handle, "Finding Steam libraries...", 15);
let libraries = searcher::find_steam_libraries(&paths);
let mut unique_libraries = std::collections::HashSet::new();
for lib in &libraries {
unique_libraries.insert(lib.to_string_lossy().to_string());
}
info!(
"Found {} Steam library directories:",
unique_libraries.len()
);
for (i, lib) in unique_libraries.iter().enumerate() {
info!(" Library {}: {}", i + 1, lib);
}
emit_scan_progress(
&app_handle,
&format!(
"Found {} Steam libraries. Starting game scan...",
unique_libraries.len()
),
20,
);
let games_info = searcher::find_installed_games(&libraries).await;
emit_scan_progress(
&app_handle,
&format!("Found {} games. Processing...", games_info.len()),
90,
);
info!("Games scan complete - Found {} games", games_info.len());
info!(
"Native games: {}",
games_info.iter().filter(|g| g.native).count()
);
info!(
"Proton games: {}",
games_info.iter().filter(|g| !g.native).count()
);
info!(
"Games with CreamLinux: {}",
games_info.iter().filter(|g| g.cream_installed).count()
);
info!(
"Games with SmokeAPI: {}",
games_info.iter().filter(|g| g.smoke_installed).count()
);
let mut result = Vec::new();
info!("Processing games into application state...");
for game_info in games_info {
debug!(
"Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}",
game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed
);
let game = Game {
id: game_info.id,
title: game_info.title,
path: game_info.path.to_string_lossy().to_string(),
native: game_info.native,
api_files: game_info.api_files,
cream_installed: game_info.cream_installed,
smoke_installed: game_info.smoke_installed,
installing: false,
};
result.push(game.clone());
state.games.lock().insert(game.id.clone(), game);
}
emit_scan_progress(
&app_handle,
&format!("Scan complete. Found {} games.", result.len()),
100,
);
info!("Game scan completed successfully");
Ok(result)
}
fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u32) {
info!("Scan progress: {}% - {}", progress, message);
let payload = serde_json::json!({
"message": message,
"progress": progress
});
if let Err(e) = app_handle.emit("scan-progress", payload) {
warn!("Failed to emit scan-progress event: {}", e);
}
}
#[tauri::command]
fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String> {
let games = state.games.lock();
games
.get(&game_id)
.cloned()
.ok_or_else(|| format!("Game with ID {} not found", game_id))
}
#[tauri::command]
async fn process_game_action(
game_action: GameAction,
state: State<'_, AppState>,
app_handle: tauri::AppHandle,
) -> Result<Game, String> {
let game = {
let games = state.games.lock();
games
.get(&game_action.game_id)
.cloned()
.ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))?
};
let (installer_type, action) = match game_action.action.as_str() {
"install_cream" => (InstallerType::Cream, InstallerAction::Install),
"uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall),
"install_smoke" => (InstallerType::Smoke, InstallerAction::Install),
"uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall),
_ => return Err(format!("Invalid action: {}", game_action.action)),
};
installer::process_action(
game_action.game_id.clone(),
installer_type,
action,
game.clone(),
app_handle.clone(),
)
.await?;
let updated_game = {
let mut games_map = state.games.lock();
let game = games_map.get_mut(&game_action.game_id).ok_or_else(|| {
format!(
"Game with ID {} not found after action",
game_action.game_id
)
})?;
match (installer_type, action) {
(InstallerType::Cream, InstallerAction::Install) => {
game.cream_installed = true;
}
(InstallerType::Cream, InstallerAction::Uninstall) => {
game.cream_installed = false;
}
(InstallerType::Smoke, InstallerAction::Install) => {
game.smoke_installed = true;
}
(InstallerType::Smoke, InstallerAction::Uninstall) => {
game.smoke_installed = false;
}
}
game.installing = false;
game.clone()
};
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
warn!("Failed to emit game-updated event: {}", e);
}
Ok(updated_game)
}
#[tauri::command]
async fn fetch_game_dlcs(
game_id: String,
state: State<'_, AppState>,
) -> Result<Vec<DlcInfoWithState>, String> {
info!("Fetching DLC list for game ID: {}", game_id);
// Fetch DLC data from API
match installer::fetch_dlc_details(&game_id).await {
Ok(dlcs) => {
info!("Successfully fetched {} DLCs for game {}", dlcs.len(), game_id);
// Convert to DLCInfoWithState for in-memory caching
let dlcs_with_state = dlcs
.into_iter()
.map(|dlc| DlcInfoWithState {
appid: dlc.appid,
name: dlc.name,
enabled: true,
})
.collect::<Vec<_>>();
// Update in-memory cache
let mut dlc_cache = state.dlc_cache.lock();
dlc_cache.insert(
game_id.clone(),
DlcCache {
data: dlcs_with_state.clone(),
timestamp: tokio::time::Instant::now(),
},
);
Ok(dlcs_with_state)
}
Err(e) => {
error!("Failed to fetch DLC details: {}", e);
Err(format!("Failed to fetch DLC details: {}", e))
}
}
}
#[tauri::command]
fn abort_dlc_fetch(state: State<'_, AppState>, app_handle: tauri::AppHandle) -> Result<(), String> {
info!("Aborting DLC fetch request received");
state.fetch_cancellation.store(true, Ordering::SeqCst);
// Reset cancellation flag after a short delay
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(500));
let state = app_handle.state::<AppState>();
state.fetch_cancellation.store(false, Ordering::SeqCst);
});
Ok(())
}
#[tauri::command]
async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
info!("Streaming DLCs for game ID: {}", game_id);
// Fetch DLC data from API
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
Ok(dlcs) => {
info!(
"Successfully streamed {} DLCs for game {}",
dlcs.len(),
game_id
);
// Convert to DLCInfoWithState for in-memory caching
let dlcs_with_state = dlcs
.into_iter()
.map(|dlc| DlcInfoWithState {
appid: dlc.appid,
name: dlc.name,
enabled: true,
})
.collect::<Vec<_>>();
// Update in-memory cache
let state = app_handle.state::<AppState>();
let mut dlc_cache = state.dlc_cache.lock();
dlc_cache.insert(
game_id.clone(),
DlcCache {
data: dlcs_with_state,
timestamp: tokio::time::Instant::now(),
},
);
Ok(())
}
Err(e) => {
error!("Failed to stream DLC details: {}", e);
// Emit error event
let error_payload = serde_json::json!({
"error": format!("Failed to fetch DLC details: {}", e)
});
if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) {
warn!("Failed to emit dlc-error event: {}", emit_err);
}
Err(format!("Failed to fetch DLC details: {}", e))
}
}
}
#[tauri::command]
fn clear_caches() -> Result<(), String> {
info!("Data flush requested - cleaning in-memory state only");
Ok(())
}
#[tauri::command]
fn get_enabled_dlcs_command(game_path: String) -> Result<Vec<String>, String> {
info!("Getting enabled DLCs for: {}", game_path);
dlc_manager::get_enabled_dlcs(&game_path)
}
#[tauri::command]
fn update_dlc_configuration_command(
game_path: String,
dlcs: Vec<DlcInfoWithState>,
) -> Result<(), String> {
info!("Updating DLC configuration for: {}", game_path);
dlc_manager::update_dlc_configuration(&game_path, dlcs)
}
#[tauri::command]
async fn install_cream_with_dlcs_command(
game_id: String,
selected_dlcs: Vec<DlcInfoWithState>,
app_handle: tauri::AppHandle,
) -> Result<Game, String> {
info!(
"Installing CreamLinux with selected DLCs for game: {}",
game_id
);
// Clone selected_dlcs for later use
let selected_dlcs_clone = selected_dlcs.clone();
// Install CreamLinux with the selected DLCs
match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs)
.await
{
Ok(_) => {
// Return updated game info
let state = app_handle.state::<AppState>();
// Get a mutable reference and update the game
let game = {
let mut games_map = state.games.lock();
let game = games_map.get_mut(&game_id).ok_or_else(|| {
format!("Game with ID {} not found after installation", game_id)
})?;
// Update installation status
game.cream_installed = true;
game.installing = false;
// Clone the game for returning later
game.clone()
};
// Emit an event to update the UI
if let Err(e) = app_handle.emit("game-updated", &game) {
warn!("Failed to emit game-updated event: {}", e);
}
// Show installation complete dialog with instructions
let instructions = installer::InstallationInstructions {
type_: "cream_install".to_string(),
command: "sh ./cream.sh %command%".to_string(),
game_title: game.title.clone(),
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()),
};
installer::emit_progress(
&app_handle,
&format!("Installation Completed: {}", game.title),
"CreamLinux has been installed successfully!",
100.0,
true,
true,
Some(instructions),
);
Ok(game)
}
Err(e) => {
error!("Failed to install CreamLinux with selected DLCs: {}", e);
Err(format!(
"Failed to install CreamLinux with selected DLCs: {}",
e
))
}
}
}
#[tauri::command]
fn read_smokeapi_config(game_path: String) -> Result<Option<smokeapi_config::SmokeAPIConfig>, String> {
info!("Reading SmokeAPI config for: {}", game_path);
smokeapi_config::read_config(&game_path)
}
#[tauri::command]
fn write_smokeapi_config(
game_path: String,
config: smokeapi_config::SmokeAPIConfig,
) -> Result<(), String> {
info!("Writing SmokeAPI config for: {}", game_path);
smokeapi_config::write_config(&game_path, &config)
}
#[tauri::command]
fn delete_smokeapi_config(game_path: String) -> Result<(), String> {
info!("Deleting SmokeAPI config for: {}", game_path);
smokeapi_config::delete_config(&game_path)
}
#[tauri::command]
async fn resolve_platform_conflict(
game_id: String,
conflict_type: String, // "cream-to-proton" or "smoke-to-native"
state: State<'_, AppState>,
app_handle: tauri::AppHandle,
) -> Result<Game, String> {
info!(
"Resolving platform conflict for game {}: {}",
game_id, conflict_type
);
let game = {
let games = state.games.lock();
games
.get(&game_id)
.cloned()
.ok_or_else(|| format!("Game with ID {} not found", game_id))?
};
let game_title = game.title.clone();
// Emit progress
installer::emit_progress(
&app_handle,
&format!("Resolving Conflict: {}", game_title),
"Removing conflicting files...",
50.0,
false,
false,
None,
);
// Perform the appropriate removal based on conflict type
match conflict_type.as_str() {
"cream-to-proton" => {
// Remove CreamLinux files (bypassing native check)
info!("Removing CreamLinux files from Proton game: {}", game_title);
CreamLinux::uninstall_from_game(&game.path, &game.id)
.await
.map_err(|e| format!("Failed to remove CreamLinux files: {}", e))?;
// Remove version from manifest
crate::cache::remove_creamlinux_version(&game.path)?;
}
"smoke-to-native" => {
// Remove SmokeAPI files (bypassing proton check)
info!("Removing SmokeAPI files from native game: {}", game_title);
// For native games, we need to manually remove backup files since
// the main DLL might already be gone
// Look for and remove *_o.dll backup files
use walkdir::WalkDir;
let mut removed_files = false;
for entry in WalkDir::new(&game.path)
.max_depth(5)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
if !path.is_file() {
continue;
}
let filename = path.file_name().unwrap_or_default().to_string_lossy();
// Remove steam_api*_o.dll backup files
if filename.starts_with("steam_api") && filename.ends_with("_o.dll") {
match std::fs::remove_file(path) {
Ok(_) => {
info!("Removed SmokeAPI backup file: {}", path.display());
removed_files = true;
}
Err(e) => {
warn!("Failed to remove backup file {}: {}", path.display(), e);
}
}
}
}
// Also try the normal uninstall if api_files are present
if !game.api_files.is_empty() {
let api_files_str = game.api_files.join(",");
if let Err(e) = SmokeAPI::uninstall_from_game(&game.path, &api_files_str).await {
// Don't fail if this errors - we might have already cleaned up manually above
warn!("SmokeAPI uninstall warning: {}", e);
}
}
if !removed_files {
warn!("No SmokeAPI files found to remove for: {}", game_title);
}
// Remove version from manifest
crate::cache::remove_smokeapi_version(&game.path)?;
}
_ => return Err(format!("Invalid conflict type: {}", conflict_type)),
}
installer::emit_progress(
&app_handle,
&format!("Conflict Resolved: {}", game_title),
"Conflicting files have been removed successfully!",
100.0,
true,
false,
None,
);
// Update game state
let updated_game = {
let mut games_map = state.games.lock();
let game = games_map
.get_mut(&game_id)
.ok_or_else(|| format!("Game with ID {} not found after conflict resolution", game_id))?;
match conflict_type.as_str() {
"cream-to-proton" => {
game.cream_installed = false;
}
"smoke-to-native" => {
game.smoke_installed = false;
}
_ => {}
}
game.installing = false;
game.clone()
};
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
warn!("Failed to emit game-updated event: {}", e);
}
info!("Platform conflict resolved successfully for: {}", game_title);
Ok(updated_game)
}
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
use log::LevelFilter;
use log4rs::append::file::FileAppender;
use log4rs::config::{Appender, Config, Root};
use log4rs::encode::pattern::PatternEncoder;
use std::fs;
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?;
let log_path = xdg_dirs.place_cache_file("creamlinux.log")?;
if log_path.exists() {
if let Err(e) = fs::write(&log_path, "") {
eprintln!("Warning: Failed to clear log file: {}", e);
}
}
let file = FileAppender::builder()
.encoder(Box::new(PatternEncoder::new(
"[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n",
)))
.build(log_path)?;
let config = Config::builder()
.appender(Appender::builder().build("file", Box::new(file)))
.build(Root::builder().appender("file").build(LevelFilter::Info))?;
log4rs::init_config(config)?;
info!("CreamLinux started with a clean log file");
Ok(())
}
fn main() {
if let Err(e) = setup_logging() {
eprintln!("Warning: Failed to initialize logging: {}", e);
}
info!("Initializing CreamLinux application");
tauri::Builder::default()
.plugin(UpdaterBuilder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.invoke_handler(tauri::generate_handler![
scan_steam_games,
get_game_info,
process_game_action,
fetch_game_dlcs,
stream_game_dlcs,
get_enabled_dlcs_command,
update_dlc_configuration_command,
install_cream_with_dlcs_command,
get_all_dlcs_command,
clear_caches,
abort_dlc_fetch,
read_smokeapi_config,
write_smokeapi_config,
delete_smokeapi_config,
resolve_platform_conflict,
load_config,
update_config,
])
.setup(|app| {
info!("Tauri application setup");
#[cfg(debug_assertions)]
{
if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
if let Some(window) = app.get_webview_window("main") {
window.open_devtools();
}
}
}
let app_handle = app.handle().clone();
let state = AppState {
games: Mutex::new(HashMap::new()),
dlc_cache: Mutex::new(HashMap::new()),
fetch_cancellation: Arc::new(AtomicBool::new(false)),
};
app.manage(state);
// Initialize cache on startup in a background task
tauri::async_runtime::spawn(async move {
info!("Starting cache initialization...");
// Step 1: Initialize cache if needed (downloads unlockers)
if let Err(e) = cache::initialize_cache().await {
error!("Failed to initialize cache: {}", e);
return;
}
info!("Cache initialized successfully");
// Step 2: Check for updates
match cache::check_and_update_cache().await {
Ok(result) => {
if result.any_updated() {
info!(
"Updates found - SmokeAPI: {:?}, CreamLinux: {:?}",
result.new_smokeapi_version, result.new_creamlinux_version
);
// Step 3: Update outdated games
let state_for_update = app_handle.state::<AppState>();
let games = state_for_update.games.lock().clone();
match cache::update_outdated_games(&games).await {
Ok(stats) => {
info!(
"Game updates complete - {} games updated, {} failed",
stats.total_updated(),
stats.total_failed()
);
if stats.has_failures() {
warn!(
"Some game updates failed: SmokeAPI failed: {}, CreamLinux failed: {}",
stats.smokeapi_failed, stats.creamlinux_failed
);
}
}
Err(e) => {
error!("Failed to update games: {}", e);
}
}
} else {
info!("All unlockers are up to date");
}
}
Err(e) => {
error!("Failed to check for updates: {}", e);
}
}
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+745
View File
@@ -0,0 +1,745 @@
use log::{debug, error, info, warn};
use regex::Regex;
use std::collections::HashSet;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::mpsc;
use walkdir::WalkDir;
// Game information structure
#[derive(Debug, Clone)]
pub struct GameInfo {
pub id: String,
pub title: String,
pub path: PathBuf,
pub native: bool,
pub api_files: Vec<String>,
pub cream_installed: bool,
pub smoke_installed: bool,
}
// Find potential Steam installation directories
pub fn get_default_steam_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
// Get user's home directory
if let Ok(home) = std::env::var("HOME") {
info!("Searching for Steam in home directory: {}", home);
// Common Steam installation locations on Linux
let common_paths = [
".steam/steam", // Steam symlink directory
".steam/root", // Alternative symlink
".local/share/Steam", // Flatpak Steam installation
".var/app/com.valvesoftware.Steam/.local/share/Steam", // Flatpak container path
".var/app/com.valvesoftware.Steam/data/Steam", // Alternative Flatpak path
"/run/media/mmcblk0p1", // Removable Storage path
];
for path in &common_paths {
let full_path = PathBuf::from(&home).join(path);
if full_path.exists() {
debug!("Found Steam directory: {}", full_path.display());
paths.push(full_path);
}
}
}
// Add Steam Deck paths if they exist
let deck_paths = ["/home/deck/.steam/steam", "/home/deck/.local/share/Steam"];
for path in &deck_paths {
let p = PathBuf::from(path);
if p.exists() && !paths.contains(&p) {
debug!("Found Steam Deck path: {}", p.display());
paths.push(p);
}
}
// Try to extract paths from Steam registry file
if let Some(registry_paths) = read_steam_registry() {
for path in registry_paths {
if !paths.contains(&path) && path.exists() {
debug!("Adding Steam path from registry: {}", path.display());
paths.push(path);
}
}
}
info!("Found {} potential Steam directories", paths.len());
paths
}
// Try to read the Steam registry file to find installation paths
fn read_steam_registry() -> Option<Vec<PathBuf>> {
let home = match std::env::var("HOME") {
Ok(h) => h,
Err(_) => return None,
};
let registry_paths = [
format!("{}/.steam/registry.vdf", home),
format!("{}/.steam/steam/registry.vdf", home),
format!("{}/.local/share/Steam/registry.vdf", home),
];
for registry_path in registry_paths {
let path = Path::new(&registry_path);
if path.exists() {
debug!("Found Steam registry at: {}", path.display());
if let Ok(content) = fs::read_to_string(path) {
let mut paths = Vec::new();
// Extract Steam installation paths
let re_steam_path = Regex::new(r#""SteamPath"\s+"([^"]+)""#).unwrap();
if let Some(cap) = re_steam_path.captures(&content) {
let steam_path = PathBuf::from(&cap[1]);
paths.push(steam_path);
}
// Look for install path
let re_install_path = Regex::new(r#""InstallPath"\s+"([^"]+)""#).unwrap();
if let Some(cap) = re_install_path.captures(&content) {
let install_path = PathBuf::from(&cap[1]);
if !paths.contains(&install_path) {
paths.push(install_path);
}
}
if !paths.is_empty() {
return Some(paths);
}
}
}
}
None
}
// Find all Steam library folders from base Steam installation paths
pub fn find_steam_libraries(base_paths: &[PathBuf]) -> Vec<PathBuf> {
let mut libraries = HashSet::new();
for base_path in base_paths {
debug!("Looking for Steam libraries in: {}", base_path.display());
// Check if this path contains a steamapps directory
let steamapps_path = base_path.join("steamapps");
if steamapps_path.exists() && steamapps_path.is_dir() {
debug!("Found steamapps directory: {}", steamapps_path.display());
libraries.insert(steamapps_path.clone());
// Check for additional libraries in libraryfolders.vdf
parse_library_folders_vdf(&steamapps_path, &mut libraries);
}
// Also check for steamapps in common locations relative to this path
let possible_steamapps = [
base_path.join("steam/steamapps"),
base_path.join("Steam/steamapps"),
];
for path in &possible_steamapps {
if path.exists() && path.is_dir() && !libraries.contains(path) {
debug!("Found steamapps directory: {}", path.display());
libraries.insert(path.clone());
// Check for additional libraries in libraryfolders.vdf
parse_library_folders_vdf(path, &mut libraries);
}
}
}
let result: Vec<PathBuf> = libraries.into_iter().collect();
info!("Found {} Steam library directories", result.len());
for (i, lib) in result.iter().enumerate() {
info!(" Library {}: {}", i + 1, lib.display());
}
result
}
// Parse libraryfolders.vdf to extract additional library paths
fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet<PathBuf>) {
// Check both possible locations of the VDF file
let vdf_paths = [
steamapps_path.join("libraryfolders.vdf"),
steamapps_path.join("config/libraryfolders.vdf"),
];
for vdf_path in &vdf_paths {
if vdf_path.exists() {
debug!("Found library folders VDF: {}", vdf_path.display());
if let Ok(content) = fs::read_to_string(vdf_path) {
// Extract library paths using regex for both new and old format VDFs
let re_path = Regex::new(r#""path"\s+"([^"]+)""#).unwrap();
for cap in re_path.captures_iter(&content) {
let path_str = &cap[1];
let lib_path = PathBuf::from(path_str).join("steamapps");
if lib_path.exists() && lib_path.is_dir() && !libraries.contains(&lib_path) {
debug!("Found library from VDF: {}", lib_path.display());
// Clone lib_path before inserting to avoid ownership issues
let lib_path_clone = lib_path.clone();
libraries.insert(lib_path_clone);
// Recursively check this library for more libraries
parse_library_folders_vdf(&lib_path, libraries);
}
}
}
}
}
}
// Parse an appmanifest ACF file to extract game information
fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
match fs::read_to_string(path) {
Ok(content) => {
// Use regex to extract the app ID, name, and install directory
let re_appid = Regex::new(r#""appid"\s+"(\d+)""#).unwrap();
let re_name = Regex::new(r#""name"\s+"([^"]+)""#).unwrap();
let re_installdir = Regex::new(r#""installdir"\s+"([^"]+)""#).unwrap();
if let (Some(app_id_cap), Some(name_cap), Some(dir_cap)) = (
re_appid.captures(&content),
re_name.captures(&content),
re_installdir.captures(&content),
) {
let app_id = app_id_cap[1].to_string();
let name = name_cap[1].to_string();
let install_dir = dir_cap[1].to_string();
return Some((app_id, name, install_dir));
}
}
Err(e) => {
error!("Failed to read ACF file {}: {}", path.display(), e);
}
}
None
}
// Check if a file is a Linux ELF binary
fn is_elf_binary(path: &Path) -> bool {
if let Ok(mut file) = fs::File::open(path) {
let mut buffer = [0; 4];
if file.read_exact(&mut buffer).is_ok() {
// Check for ELF magic number (0x7F 'E' 'L' 'F')
return buffer[0] == 0x7F
&& buffer[1] == b'E'
&& buffer[2] == b'L'
&& buffer[3] == b'F';
}
}
false
}
// Check if a game has CreamLinux installed
fn check_creamlinux_installed(game_path: &Path) -> bool {
let cream_files = ["cream.sh", "cream_api.ini", "cream_api.so"];
for file in &cream_files {
if game_path.join(file).exists() {
debug!("CreamLinux installation detected: {}", file);
return true;
}
}
false
}
// Check if a game has SmokeAPI installed
fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
// For Proton games: check for backup DLL files
if !api_files.is_empty() {
for api_file in api_files {
let api_path = game_path.join(api_file);
let api_dir = api_path.parent().unwrap_or(game_path);
let api_filename = api_path.file_name().unwrap_or_default();
// Check for backup file (original file renamed with _o.dll suffix)
let backup_name = api_filename.to_string_lossy().replace(".dll", "_o.dll");
let backup_path = api_dir.join(backup_name);
if backup_path.exists() {
debug!("SmokeAPI backup file found: {}", backup_path.display());
return true;
}
}
}
// For Native games: check for lib_steam_api_o.so backup
for entry in WalkDir::new(game_path)
.max_depth(3)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
if !path.is_file() {
continue;
}
let filename = path.file_name().unwrap_or_default().to_string_lossy();
// Check for native SmokeAPI backup
if filename == "libsteam_api_o.so" {
debug!("Found native SmokeAPI backup: {}", path.display());
return true;
}
}
// Also scan for orphaned backup files (in case the main DLL was removed)
// This handles the Proton->Native switch case where steam_api*.dll is gone
// but steam_api*_o.dll backup remains
for entry in WalkDir::new(game_path)
.max_depth(5)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
if !path.is_file() {
continue;
}
let filename = path.file_name().unwrap_or_default().to_string_lossy();
// Look for steam_api*_o.dll backup files (SmokeAPI pattern)
if filename.starts_with("steam_api") && filename.ends_with("_o.dll") {
debug!("Found orphaned SmokeAPI backup file: {}", path.display());
return true;
}
}
false
}
// Scan a game directory to determine if it's native or needs Proton
// Also collect any Steam API DLLs for potential SmokeAPI installation
fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
let mut found_exe = false;
let mut found_linux_binary = false;
let mut found_main_executable = false;
let mut steam_api_files = Vec::new();
// Strong indicators for native Linux games
let mut has_libsteam_api = false;
let mut has_linux_steam_libs = false;
let mut linux_binary_count = 0;
let mut windows_exe_count = 0;
// Directories to skip for better performance
let skip_dirs = [
"videos",
"video",
"movies",
"movie",
"sound",
"sounds",
"audio",
"textures",
"music",
"localization",
"shaders",
"logs",
"assets/audio",
"assets/video",
"assets/textures",
];
// Only scan to a reasonable depth (avoid extreme recursion)
const MAX_DEPTH: usize = 8;
// File extensions to check for (executable and Steam API files)
let exe_extensions = ["exe", "bat", "cmd", "msi"];
let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"];
// Files that indicate this is likely a launcher/installer
let installer_patterns = [
"setup", "install", "launcher", "uninstall", "redist", "vcredist", "directx", "_commonredist", "dotnet", "PhysX"
];
// Recursively walk through the game directory
for entry in WalkDir::new(game_path)
.max_depth(MAX_DEPTH) // Limit depth to avoid traversing too deep
.follow_links(false) // Don't follow symlinks to prevent cycles
.into_iter()
.filter_entry(|e| {
// Skip certain directories for performance
if e.file_type().is_dir() {
let file_name = e.file_name().to_string_lossy().to_lowercase();
if skip_dirs.iter().any(|&dir| file_name == dir) {
debug!("Skipping directory: {}", e.path().display());
return false;
}
}
true
})
.filter_map(Result::ok)
{
let path = entry.path();
if !path.is_file() {
continue;
}
let filename = path.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
// Check for strong Linux indicators first
if filename == "libsteam_api.so" {
has_libsteam_api = true;
debug!("Found strong Linux indicator: {}", path.display());
}
// Check for other Linux Steam libraries
if filename.starts_with("lib") && filename.contains("steam") && filename.ends_with(".so") {
has_linux_steam_libs = true;
debug!("Found Linux Steam library: {}", path.display());
}
// Check file extension
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
// Check for Windows executables
if exe_extensions.iter().any(|&e| ext_str == e) {
// Check if this looks like an installer/utility rather than main game
let is_likely_installer = installer_patterns.iter()
.any(|&pattern| filename.contains(pattern));
if !is_likely_installer {
found_exe = true;
windows_exe_count += 1;
// If its in the root directory and not an installer, its likely the main executable
if path.parent() == Some(game_path) {
found_main_executable = true;
}
}
}
// Check for Steam API DLLs
if ext_str == "dll" {
if filename == "steam_api.dll" || filename == "steam_api64.dll" {
if let Ok(rel_path) = path.strip_prefix(game_path) {
let rel_path_str = rel_path.to_string_lossy().to_string();
debug!("Found Steam API DLL: {}", rel_path_str);
steam_api_files.push(rel_path_str);
}
}
}
// Check for Linux binary files
if binary_extensions.iter().any(|&e| ext_str == e) {
found_linux_binary = true;
linux_binary_count += 1;
// Check if it's actually an ELF binary for more certainty
if ext_str == "so" && is_elf_binary(path) {
found_linux_binary = true;
}
}
}
// Check for Linux executables (no extension)
#[cfg(unix)]
if !path.extension().is_some() {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = path.metadata() {
let is_executable = metadata.permissions().mode() & 0o111 != 0;
// Check executable permission and ELF format
if is_executable && is_elf_binary(path) {
found_linux_binary = true;
linux_binary_count += 1;
}
}
}
}
// Detection logic with priority system
let has_steam_api_dll = !steam_api_files.is_empty();
let is_native = determine_platform(
has_libsteam_api,
has_linux_steam_libs,
found_linux_binary,
found_exe,
found_main_executable,
linux_binary_count,
windows_exe_count,
has_steam_api_dll,
);
debug!(
"Game scan results: native={}, libsteam_api={}, linux_libs={}, linux_binaries={}, exe_files={}, api_dlls={}",
is_native,
has_libsteam_api,
has_linux_steam_libs,
linux_binary_count,
windows_exe_count,
steam_api_files.len()
);
(is_native, steam_api_files)
}
// Priority-based platform detection
fn determine_platform(
has_libsteam_api: bool,
has_linux_steam_libs: bool,
found_linux_binary: bool,
found_exe: bool,
found_main_executable: bool,
linux_binary_count: usize,
windows_exe_count: usize,
has_steam_api_dll: bool,
) -> bool {
// Priority 1: Strong Linux indicators
if has_libsteam_api {
debug!("Detected as native: libsteam_api.so found");
return true;
}
if has_linux_steam_libs {
debug!("Detected as native: Linux steam libraries found");
return true;
}
// Priority 2: Strong Windows indicators - DLL files are Windows-only
if has_steam_api_dll {
debug!("Detected as Windows/Proton: steam_api.dll or steam_api64.dll found");
return false;
}
// Priority 3: High confidence Linux indicators
if found_linux_binary && linux_binary_count >= 3 && !found_main_executable {
debug!("Detected as native: Multiple Linux binaries, no main Windows executable");
return true;
}
// Priority 4: Balanced assessment
if found_linux_binary && !found_main_executable && windows_exe_count <= 2 {
debug!("Detected as native: Linux binaries present, only installer/utility Windows files");
return true;
}
// Priority 5: Windows indicators
if found_main_executable || (found_exe && !found_linux_binary) {
debug!("Detected as Windows/Proton: Main executable or only Windows files found");
return false;
}
// Priority 6: Default fallback
if found_linux_binary {
debug!("Detected as native: Linux binaries found (default fallback)");
return true;
}
debug!("Detected as Windows/Proton: No strong indicators found");
false
}
// Find all installed Steam games from library folders
pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo> {
let mut games = Vec::new();
let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new()));
// IDs to skip (tools, redistributables, etc.)
let skip_ids = Arc::new(
[
"228980", // Steamworks Common Redistributables
"1070560", // Steam Linux Runtime
"1391110", // Steam Linux Runtime - Soldier
"1628350", // Steam Linux Runtime - Sniper
"1493710", // Proton Experimental
"2180100", // Steam Linux Runtime - Scout
]
.iter()
.copied()
.collect::<HashSet<&str>>(),
);
// Name patterns to skip (case insensitive)
let skip_patterns = Arc::new(
[
r"(?i)steam linux runtime",
r"(?i)proton",
r"(?i)steamworks common",
r"(?i)redistributable",
r"(?i)dotnet",
r"(?i)vc redist",
]
.iter()
.map(|pat| Regex::new(pat).unwrap())
.collect::<Vec<_>>(),
);
info!("Scanning for installed games in parallel...");
// Create a channel to collect results
let (tx, mut rx) = mpsc::channel(32);
// First collect all appmanifest files to process
let mut app_manifests = Vec::new();
for steamapps_dir in steamapps_paths {
if let Ok(entries) = fs::read_dir(steamapps_dir) {
for entry in entries.flatten() {
let path = entry.path();
let filename = path.file_name().unwrap_or_default().to_string_lossy();
// Check for appmanifest files
if filename.starts_with("appmanifest_") && filename.ends_with(".acf") {
app_manifests.push((path, steamapps_dir.clone()));
}
}
}
}
info!("Found {} appmanifest files to process", app_manifests.len());
// Process appmanifest files
let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores
info!("Using {} concurrent scanners", max_concurrent);
// Use a semaphore to limit concurrency
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
// Create a Vec to store all our task handles
let mut handles = Vec::new();
// Process each manifest file
for (manifest_idx, (path, steamapps_dir)) in app_manifests.iter().enumerate() {
// Clone what we need for the task
let path = path.clone();
let steamapps_dir = steamapps_dir.clone();
let skip_patterns = Arc::clone(&skip_patterns);
let tx = tx.clone();
let seen_ids = Arc::clone(&seen_ids);
let semaphore = Arc::clone(&semaphore);
let skip_ids = Arc::clone(&skip_ids);
// Create a new task
let handle = tokio::spawn(async move {
// Acquire a permit from the semaphore
let _permit = semaphore.acquire().await.unwrap();
// Parse the appmanifest file
if let Some((id, name, install_dir)) = parse_appmanifest(&path) {
// Skip if in exclusion list
if skip_ids.contains(id.as_str()) {
return;
}
// Add a guard against duplicates
{
let mut seen = seen_ids.lock().await;
if seen.contains(&id) {
return;
}
seen.insert(id.clone());
}
// Skip if the name matches any exclusion patterns
if skip_patterns.iter().any(|re| re.is_match(&name)) {
debug!("Skipping runtime/tool: {} ({})", name, id);
return;
}
// Full path to the game directory
let game_path = steamapps_dir.join("common").join(&install_dir);
// Skip if game directory doesn't exist
if !game_path.exists() {
warn!("Game directory not found: {}", game_path.display());
return;
}
// Scan the game directory to determine platform and find Steam API DLLs
info!("Scanning game: {} at {}", name, game_path.display());
// Scanning is I/O heavy but not CPU heavy, so we can just do it directly
let (is_native, api_files) = scan_game_directory(&game_path);
// Check for CreamLinux installation
let cream_installed = check_creamlinux_installed(&game_path);
// Check for SmokeAPI installation
// For Proton games: check if api_files exist
// For Native games: ALSO check for orphaned backup files (proton->native switch)
let smoke_installed = check_smokeapi_installed(&game_path, &api_files);
// Create the game info
let game_info = GameInfo {
id,
title: name,
path: game_path,
native: is_native,
api_files,
cream_installed,
smoke_installed,
};
// Send the game info through the channel
if tx.send(game_info).await.is_err() {
error!("Failed to send game info through channel");
}
}
});
handles.push(handle);
// Every 10 files, yield to allow progress updates
if manifest_idx % 10 == 0 {
tokio::task::yield_now().await;
}
}
// Drop the original sender so the receiver knows when we're done
drop(tx);
// Spawn a task to collect all the results
let receiver_task = tokio::spawn(async move {
let mut results = Vec::new();
while let Some(game) = rx.recv().await {
info!("Found game: {} ({})", game.title, game.id);
info!(" Path: {}", game.path.display());
info!(
" Status: Native={}, Cream={}, Smoke={}",
game.native, game.cream_installed, game.smoke_installed
);
// Log Steam API DLLs if any
if !game.api_files.is_empty() {
info!(" Steam API files:");
for api_file in &game.api_files {
info!(" - {}", api_file);
}
}
results.push(game);
}
results
});
// Wait for all scan tasks to complete but don't wait for the results yet
for handle in handles {
// Ignore errors the receiver task will just get fewer results
let _ = handle.await;
}
// Now wait for all results to be collected
if let Ok(results) = receiver_task.await {
games = results;
}
info!("Found {} installed games", games.len());
games
}
+128
View File
@@ -0,0 +1,128 @@
use log::{info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SmokeAPIConfig {
#[serde(rename = "$schema")]
pub schema: String,
#[serde(rename = "$version")]
pub version: u32,
pub logging: bool,
pub log_steam_http: bool,
pub default_app_status: String,
pub override_app_status: HashMap<String, String>,
pub override_dlc_status: HashMap<String, String>,
pub auto_inject_inventory: bool,
pub extra_inventory_items: Vec<u32>,
pub extra_dlcs: HashMap<String, serde_json::Value>,
}
impl Default for SmokeAPIConfig {
fn default() -> Self {
Self {
schema: "https://raw.githubusercontent.com/acidicoala/SmokeAPI/refs/tags/v4.0.0/res/SmokeAPI.schema.json".to_string(),
version: 4,
logging: false,
log_steam_http: false,
default_app_status: "unlocked".to_string(),
override_app_status: HashMap::new(),
override_dlc_status: HashMap::new(),
auto_inject_inventory: true,
extra_inventory_items: Vec::new(),
extra_dlcs: HashMap::new(),
}
}
}
// Read SmokeAPI config from a game directory
// Returns None if the config doesn't exist
pub fn read_config(game_path: &str) -> Result<Option<SmokeAPIConfig>, String> {
info!("Reading SmokeAPI config from: {}", game_path);
// Find the SmokeAPI DLL location in the game directory
let config_path = find_smokeapi_config_path(game_path)?;
if !config_path.exists() {
info!("No SmokeAPI config found at: {}", config_path.display());
return Ok(None);
}
let content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read SmokeAPI config: {}", e))?;
let config: SmokeAPIConfig = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse SmokeAPI config: {}", e))?;
info!("Successfully read SmokeAPI config");
Ok(Some(config))
}
// Write SmokeAPI config to a game directory
pub fn write_config(game_path: &str, config: &SmokeAPIConfig) -> Result<(), String> {
info!("Writing SmokeAPI config to: {}", game_path);
let config_path = find_smokeapi_config_path(game_path)?;
let content = serde_json::to_string_pretty(config)
.map_err(|e| format!("Failed to serialize SmokeAPI config: {}", e))?;
fs::write(&config_path, content)
.map_err(|e| format!("Failed to write SmokeAPI config: {}", e))?;
info!("Successfully wrote SmokeAPI config to: {}", config_path.display());
Ok(())
}
// Delete SmokeAPI config from a game directory
pub fn delete_config(game_path: &str) -> Result<(), String> {
info!("Deleting SmokeAPI config from: {}", game_path);
let config_path = find_smokeapi_config_path(game_path)?;
if config_path.exists() {
fs::remove_file(&config_path)
.map_err(|e| format!("Failed to delete SmokeAPI config: {}", e))?;
info!("Successfully deleted SmokeAPI config");
} else {
info!("No SmokeAPI config to delete");
}
Ok(())
}
// Find the path where SmokeAPI.config.json should be located
// This is in the same directory as the SmokeAPI DLL files
fn find_smokeapi_config_path(game_path: &str) -> Result<std::path::PathBuf, String> {
let game_path_obj = Path::new(game_path);
// Search for steam_api*.dll files with _o.dll backups (indicating SmokeAPI installation)
let mut smokeapi_dir: Option<std::path::PathBuf> = None;
// Use walkdir to search recursively
for entry in walkdir::WalkDir::new(game_path_obj)
.max_depth(5)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
let filename = path.file_name().unwrap_or_default().to_string_lossy();
// Look for steam_api*_o.dll (backup files created by SmokeAPI)
if filename.starts_with("steam_api") && filename.ends_with("_o.dll") {
smokeapi_dir = path.parent().map(|p| p.to_path_buf());
break;
}
}
// If we found a SmokeAPI directory, return the config path
if let Some(dir) = smokeapi_dir {
Ok(dir.join("SmokeAPI.config.json"))
} else {
// Fallback to game root directory
warn!("Could not find SmokeAPI DLL directory, using game root");
Ok(game_path_obj.join("SmokeAPI.config.json"))
}
}
+225
View File
@@ -0,0 +1,225 @@
use super::Unlocker;
use async_trait::async_trait;
use log::{info, warn};
use reqwest;
use std::fs;
use std::io;
use std::path::Path;
use std::time::Duration;
use tempfile::tempdir;
use zip::ZipArchive;
pub struct CreamLinux;
#[async_trait]
impl Unlocker for CreamLinux {
async fn get_latest_version() -> Result<String, String> {
info!("Fetching latest CreamLinux version...");
let client = reqwest::Client::new();
// Fetch the latest release from GitHub API
let api_url = "https://api.github.com/repos/anticitizn/creamlinux/releases/latest";
let response = client
.get(api_url)
.header("User-Agent", "CreamLinux-Installer")
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Failed to fetch CreamLinux releases: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Failed to fetch CreamLinux releases: HTTP {}",
response.status()
));
}
let release_info: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse release info: {}", e))?;
let version = release_info
.get("tag_name")
.and_then(|v| v.as_str())
.ok_or_else(|| "Failed to extract version from release info".to_string())?
.to_string();
info!("Latest CreamLinux version: {}", version);
Ok(version)
}
async fn download_to_cache() -> Result<String, String> {
let version = Self::get_latest_version().await?;
info!("Downloading CreamLinux version {}...", version);
let client = reqwest::Client::new();
// Construct the download URL using the version
let download_url = format!(
"https://github.com/anticitizn/creamlinux/releases/download/{}/creamlinux.zip",
version
);
// Download the zip
let response = client
.get(&download_url)
.timeout(Duration::from_secs(30))
.send()
.await
.map_err(|e| format!("Failed to download CreamLinux: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Failed to download CreamLinux: HTTP {}",
response.status()
));
}
// Save to temporary file
let temp_dir = tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?;
let zip_path = temp_dir.path().join("creamlinux.zip");
let content = response
.bytes()
.await
.map_err(|e| format!("Failed to read response bytes: {}", e))?;
fs::write(&zip_path, &content).map_err(|e| format!("Failed to write zip file: {}", e))?;
// Extract to cache directory
let version_dir = crate::cache::get_creamlinux_version_dir(&version)?;
let file = fs::File::open(&zip_path).map_err(|e| format!("Failed to open zip: {}", e))?;
let mut archive =
ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
// Extract all files
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Failed to access zip entry: {}", e))?;
let file_name = file.name().to_string(); // Clone the name early
// Skip directories
if file_name.ends_with('/') {
continue;
}
let output_path = version_dir.join(
Path::new(&file_name)
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new(&file_name)),
);
let mut outfile = fs::File::create(&output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
io::copy(&mut file, &mut outfile)
.map_err(|e| format!("Failed to extract file: {}", e))?;
// Make .sh files executable
if file_name.ends_with(".sh") {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&output_path)
.map_err(|e| format!("Failed to get file metadata: {}", e))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&output_path, perms)
.map_err(|e| format!("Failed to set permissions: {}", e))?;
}
}
info!("Extracted: {}", output_path.display());
}
info!(
"CreamLinux version {} downloaded to cache successfully",
version
);
Ok(version)
}
async fn install_to_game(game_path: &str, _game_id: &str) -> Result<(), String> {
info!("Installing CreamLinux to {}", game_path);
// Get the cached CreamLinux files
let cached_files = crate::cache::list_creamlinux_files()?;
if cached_files.is_empty() {
return Err("No CreamLinux files found in cache".to_string());
}
let game_path_obj = Path::new(game_path);
// Copy all files to the game directory
for file in &cached_files {
let file_name = file.file_name().ok_or_else(|| {
format!("Failed to get filename from: {}", file.display())
})?;
let dest_path = game_path_obj.join(file_name);
fs::copy(file, &dest_path)
.map_err(|e| format!("Failed to copy {} to game directory: {}", file_name.to_string_lossy(), e))?;
// Make .sh files executable
if file_name.to_string_lossy().ends_with(".sh") {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&dest_path)
.map_err(|e| format!("Failed to get file metadata: {}", e))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&dest_path, perms)
.map_err(|e| format!("Failed to set permissions: {}", e))?;
}
}
info!("Installed: {}", dest_path.display());
}
// Note: cream_api.ini is managed separately by dlc_manager
// This function only installs the binaries
info!("CreamLinux installation completed for: {}", game_path);
Ok(())
}
async fn uninstall_from_game(game_path: &str, _game_id: &str) -> Result<(), String> {
info!("Uninstalling CreamLinux from: {}", game_path);
let game_path_obj = Path::new(game_path);
// List of CreamLinux files to remove
let files_to_remove = vec![
"cream.sh",
"lib32Creamlinux.so",
"lib64Creamlinux.so",
"cream_api.ini",
];
for file_name in files_to_remove {
let file_path = game_path_obj.join(file_name);
if file_path.exists() {
match fs::remove_file(&file_path) {
Ok(_) => info!("Removed: {}", file_path.display()),
Err(e) => warn!(
"Failed to remove {}: {}",
file_path.display(),
e
),
}
}
}
info!("CreamLinux uninstallation completed for: {}", game_path);
Ok(())
}
fn name() -> &'static str {
"CreamLinux"
}
}
+27
View File
@@ -0,0 +1,27 @@
mod creamlinux;
mod smokeapi;
pub use creamlinux::CreamLinux;
pub use smokeapi::SmokeAPI;
use async_trait::async_trait;
// Common trait for all unlockers (CreamLinux, SmokeAPI)
#[async_trait]
pub trait Unlocker {
// Get the latest version from the remote source
async fn get_latest_version() -> Result<String, String>;
// Download the unlocker to the cache directory
async fn download_to_cache() -> Result<String, String>;
// Install the unlocker from cache to a game directory
async fn install_to_game(game_path: &str, context: &str) -> Result<(), String>;
// Uninstall the unlocker from a game directory
async fn uninstall_from_game(game_path: &str, context: &str) -> Result<(), String>;
// Get the name of the unlocker
#[allow(dead_code)]
fn name() -> &'static str;
}
+432
View File
@@ -0,0 +1,432 @@
use super::Unlocker;
use async_trait::async_trait;
use log::{error, info, warn};
use reqwest;
use std::fs;
use std::io;
use std::path::Path;
use std::time::Duration;
use tempfile::tempdir;
use zip::ZipArchive;
const SMOKEAPI_REPO: &str = "acidicoala/SmokeAPI";
pub struct SmokeAPI;
#[async_trait]
impl Unlocker for SmokeAPI {
async fn get_latest_version() -> Result<String, String> {
info!("Fetching latest SmokeAPI version...");
let client = reqwest::Client::new();
let releases_url = format!(
"https://api.github.com/repos/{}/releases/latest",
SMOKEAPI_REPO
);
let response = client
.get(&releases_url)
.header("User-Agent", "CreamLinux")
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Failed to fetch SmokeAPI releases: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Failed to fetch SmokeAPI releases: HTTP {}",
response.status()
));
}
let release_info: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse release info: {}", e))?;
let version = release_info
.get("tag_name")
.and_then(|v| v.as_str())
.ok_or_else(|| "Failed to extract version from release info".to_string())?
.to_string();
info!("Latest SmokeAPI version: {}", version);
Ok(version)
}
async fn download_to_cache() -> Result<String, String> {
let version = Self::get_latest_version().await?;
info!("Downloading SmokeAPI version {}...", version);
let client = reqwest::Client::new();
let zip_url = format!(
"https://github.com/{}/releases/download/{}/SmokeAPI-{}.zip",
SMOKEAPI_REPO, version, version
);
// Download the zip
let response = client
.get(&zip_url)
.timeout(Duration::from_secs(30))
.send()
.await
.map_err(|e| format!("Failed to download SmokeAPI: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Failed to download SmokeAPI: HTTP {}",
response.status()
));
}
// Save to temporary file
let temp_dir = tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?;
let zip_path = temp_dir.path().join("smokeapi.zip");
let content = response
.bytes()
.await
.map_err(|e| format!("Failed to read response bytes: {}", e))?;
fs::write(&zip_path, &content).map_err(|e| format!("Failed to write zip file: {}", e))?;
// Extract to cache directory
let version_dir = crate::cache::get_smokeapi_version_dir(&version)?;
let file = fs::File::open(&zip_path).map_err(|e| format!("Failed to open zip: {}", e))?;
let mut archive =
ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
// Extract both DLL files (for Proton) and .so files (for native Linux)
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Failed to access zip entry: {}", e))?;
let file_name = file.name();
// Extract DLL files for Proton and .so files for native Linux
let should_extract = file_name.to_lowercase().ends_with(".dll")
|| file_name.to_lowercase().ends_with(".so");
if should_extract {
let output_path = version_dir.join(
Path::new(file_name)
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new(file_name)),
);
let mut outfile = fs::File::create(&output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
io::copy(&mut file, &mut outfile)
.map_err(|e| format!("Failed to extract file: {}", e))?;
info!("Extracted: {}", output_path.display());
}
}
info!(
"SmokeAPI version {} downloaded to cache successfully",
version
);
Ok(version)
}
async fn install_to_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
// Check if this is a native Linux game or Proton game
// Native games have empty api_files_str, Proton games have DLL paths
let is_native = api_files_str.is_empty();
if is_native {
Self::install_to_native_game(game_path).await
} else {
Self::install_to_proton_game(game_path, api_files_str).await
}
}
async fn uninstall_from_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
// Check if this is a native Linux game or Proton game
let is_native = api_files_str.is_empty();
if is_native {
Self::uninstall_from_native_game(game_path).await
} else {
Self::uninstall_from_proton_game(game_path, api_files_str).await
}
}
fn name() -> &'static str {
"SmokeAPI"
}
}
impl SmokeAPI {
/// Install SmokeAPI to a Proton/Windows game
async fn install_to_proton_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
// Parse api_files from the context string (comma-separated)
let api_files: Vec<String> = api_files_str.split(',').map(|s| s.to_string()).collect();
info!(
"Installing SmokeAPI (Proton) to {} for {} API files",
game_path,
api_files.len()
);
// Get the cached SmokeAPI DLLs
let cached_files = crate::cache::list_smokeapi_files()?;
if cached_files.is_empty() {
return Err("No SmokeAPI files found in cache".to_string());
}
let cached_dlls: Vec<_> = cached_files
.iter()
.filter(|f| f.extension().and_then(|e| e.to_str()) == Some("dll"))
.collect();
if cached_dlls.is_empty() {
return Err("No SmokeAPI DLLs found in cache".to_string());
}
for api_file in &api_files {
let api_dir = Path::new(game_path).join(
Path::new(api_file)
.parent()
.unwrap_or_else(|| Path::new("")),
);
let api_name = Path::new(api_file).file_name().unwrap_or_default();
// Backup original file
let original_path = api_dir.join(api_name);
let backup_path = api_dir.join(api_name.to_string_lossy().replace(".dll", "_o.dll"));
info!("Processing: {}", original_path.display());
// Only backup if not already backed up
if !backup_path.exists() && original_path.exists() {
fs::copy(&original_path, &backup_path)
.map_err(|e| format!("Failed to backup original file: {}", e))?;
info!("Created backup: {}", backup_path.display());
}
// Determine if we need 32-bit or 64-bit SmokeAPI DLL
let is_64bit = api_name.to_string_lossy().contains("64");
let target_arch = if is_64bit { "64" } else { "32" };
// Find the matching DLL
let matching_dll = cached_dlls
.iter()
.find(|dll| {
let dll_name = dll.file_name().unwrap_or_default().to_string_lossy();
dll_name.to_lowercase().contains("smoke")
&& dll_name
.to_lowercase()
.contains(&format!("{}.dll", target_arch))
})
.ok_or_else(|| {
format!(
"No matching {}-bit SmokeAPI DLL found in cache",
target_arch
)
})?;
// Copy the DLL to the game directory
fs::copy(matching_dll, &original_path)
.map_err(|e| format!("Failed to install SmokeAPI DLL: {}", e))?;
info!(
"Installed {} as: {}",
matching_dll.display(),
original_path.display()
);
}
info!("SmokeAPI (Proton) installation completed for: {}", game_path);
Ok(())
}
/// Install SmokeAPI to a native Linux game
async fn install_to_native_game(game_path: &str) -> Result<(), String> {
info!("Installing SmokeAPI (native) to {}", game_path);
// Detect game bitness
let bitness = crate::utils::bitness::detect_game_bitness(game_path)?;
info!("Detected game bitness: {:?}", bitness);
// Get the cached SmokeAPI files
let cached_files = crate::cache::list_smokeapi_files()?;
if cached_files.is_empty() {
return Err("No SmokeAPI files found in cache".to_string());
}
// Determine which .so file to use based on bitness
let target_so = match bitness {
crate::utils::bitness::Bitness::Bit32 => "libsmoke_api32.so",
crate::utils::bitness::Bitness::Bit64 => "libsmoke_api64.so",
};
// Find the matching .so file in cache
let matching_so = cached_files
.iter()
.find(|file| {
file.file_name()
.unwrap_or_default()
.to_string_lossy()
== target_so
})
.ok_or_else(|| format!("No matching {} found in cache", target_so))?;
let game_path_obj = Path::new(game_path);
// Look for libsteam_api.so in the game directory (scan up to depth 3)
let libsteam_path = Self::find_libsteam_api(game_path_obj)?;
info!("Found libsteam_api.so at: {}", libsteam_path.display());
// Create backup of original libsteam_api.so
let backup_path = libsteam_path.with_file_name("libsteam_api_o.so");
// Only backup if not already backed up
if !backup_path.exists() && libsteam_path.exists() {
fs::copy(&libsteam_path, &backup_path)
.map_err(|e| format!("Failed to backup libsteam_api.so: {}", e))?;
info!("Created backup: {}", backup_path.display());
}
// Replace libsteam_api.so with SmokeAPI's libsmoke_api.so
fs::copy(matching_so, &libsteam_path)
.map_err(|e| format!("Failed to replace libsteam_api.so: {}", e))?;
info!(
"Replaced libsteam_api.so with {}",
target_so
);
info!("SmokeAPI (native) installation completed for: {}", game_path);
Ok(())
}
/// Uninstall SmokeAPI from a Proton/Windows game
async fn uninstall_from_proton_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
// Parse api_files from the context string (comma-separated)
let api_files: Vec<String> = api_files_str.split(',').map(|s| s.to_string()).collect();
info!("Uninstalling SmokeAPI (Proton) from: {}", game_path);
for api_file in &api_files {
let api_path = Path::new(game_path).join(api_file);
let api_dir = api_path.parent().unwrap_or_else(|| Path::new(game_path));
let api_name = api_path.file_name().unwrap_or_default();
let original_path = api_dir.join(api_name);
let backup_path = api_dir.join(api_name.to_string_lossy().replace(".dll", "_o.dll"));
info!("Processing: {}", original_path.display());
if backup_path.exists() {
// Remove the SmokeAPI version
if original_path.exists() {
match fs::remove_file(&original_path) {
Ok(_) => info!("Removed SmokeAPI file: {}", original_path.display()),
Err(e) => warn!(
"Failed to remove SmokeAPI file: {}, error: {}",
original_path.display(),
e
),
}
}
// Restore the original file
match fs::rename(&backup_path, &original_path) {
Ok(_) => info!("Restored original file: {}", original_path.display()),
Err(e) => {
warn!(
"Failed to restore original file: {}, error: {}",
original_path.display(),
e
);
// Try to copy instead if rename fails
if let Err(copy_err) = fs::copy(&backup_path, &original_path)
.and_then(|_| fs::remove_file(&backup_path))
{
error!("Failed to copy backup file: {}", copy_err);
}
}
}
} else {
info!("No backup found for: {}", api_file);
}
}
info!("SmokeAPI (Proton) uninstallation completed for: {}", game_path);
Ok(())
}
/// Uninstall SmokeAPI from a native Linux game
async fn uninstall_from_native_game(game_path: &str) -> Result<(), String> {
info!("Uninstalling SmokeAPI (native) from: {}", game_path);
let game_path_obj = Path::new(game_path);
// Look for libsteam_api.so (which is actually our SmokeAPI now)
let libsteam_path = match Self::find_libsteam_api(game_path_obj) {
Ok(path) => path,
Err(_) => {
warn!("libsteam_api.so not found, nothing to uninstall");
return Ok(());
}
};
// Look for backup
let backup_path = libsteam_path.with_file_name("libsteam_api_o.so");
if backup_path.exists() {
// Remove the SmokeAPI version
if libsteam_path.exists() {
match fs::remove_file(&libsteam_path) {
Ok(_) => info!("Removed SmokeAPI version: {}", libsteam_path.display()),
Err(e) => warn!("Failed to remove SmokeAPI file: {}", e),
}
}
// Restore the original file
match fs::rename(&backup_path, &libsteam_path) {
Ok(_) => info!("Restored original libsteam_api.so"),
Err(e) => {
warn!("Failed to restore original file: {}", e);
// Try to copy instead if rename fails
if let Err(copy_err) = fs::copy(&backup_path, &libsteam_path)
.and_then(|_| fs::remove_file(&backup_path))
{
error!("Failed to copy backup file: {}", copy_err);
}
}
}
} else {
warn!("No backup found (libsteam_api_o.so), cannot restore original");
}
info!("SmokeAPI (native) uninstallation completed for: {}", game_path);
Ok(())
}
/// Find libsteam_api.so in the game directory
fn find_libsteam_api(game_path: &Path) -> Result<std::path::PathBuf, String> {
use walkdir::WalkDir;
// Scan for libsteam_api.so (some games place it several subdirectories deep)
for entry in WalkDir::new(game_path)
.max_depth(8)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
if !path.is_file() {
continue;
}
let filename = path.file_name().unwrap_or_default().to_string_lossy();
if filename == "libsteam_api.so" {
return Ok(path.to_path_buf());
}
}
Err("libsteam_api.so not found in game directory".to_string())
}
}
+204
View File
@@ -0,0 +1,204 @@
use log::{debug, info, warn};
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
/// Represents the bitness of a binary
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Bitness {
Bit32,
Bit64,
}
/// Detect the bitness of a Linux Binary by reading ELF header
/// ELF format: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
fn detect_binary_bitness(file_path: &Path) -> Option<Bitness> {
use std::io::Read;
// Only read first 5 bytes
let mut file = fs::File::open(file_path).ok()?;
let mut bytes = [0u8; 5];
// Read exactly 5 bytes or fail
if file.read_exact(&mut bytes).is_err() {
return None;
}
// Check for ELF magic number (0x7F 'E' 'L' 'F')
if &bytes[0..4] != b"\x7FELF" {
return None;
}
// Byte 4 (EI_CLASS) indicates 32-bit or 64-bit
// 1 = ELFCLASS32 (32-bit)
// 2 = ELFCLASS64 (64-bit)
match bytes[4] {
1 => Some(Bitness::Bit32),
2 => Some(Bitness::Bit64),
_ => None,
}
}
/// Scan game directory for Linux binaries and determine bitness
/// Returns the detected bitness, prioritizing the main game executable
pub fn detect_game_bitness(game_path: &str) -> Result<Bitness, String> {
info!("Detecting bitness for game at: {}", game_path);
let game_path_obj = Path::new(game_path);
if !game_path_obj.exists() {
return Err("Game path does not exist".to_string());
}
// Directories to skip for performance
let skip_dirs = [
"videos",
"video",
"movies",
"movie",
"sound",
"sounds",
"audio",
"textures",
"music",
"localization",
"shaders",
"logs",
"assets",
"_CommonRedist",
"data",
"Data",
"Docs",
"docs",
"screenshots",
"Screenshots",
"saves",
"Saves",
"mods",
"Mods",
"maps",
"Maps",
];
// Limit scan depth to avoid deep recursion
const MAX_DEPTH: usize = 3;
// Stop after finding reasonable confidence (10 binaries)
const CONFIDENCE_THRESHOLD: usize = 10;
let mut bit64_binaries = Vec::new();
let mut bit32_binaries = Vec::new();
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
// Scan for Linux binaries
for entry in WalkDir::new(game_path_obj)
.max_depth(MAX_DEPTH)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
if e.file_type().is_dir() {
let dir_name = e.file_name().to_string_lossy().to_lowercase();
!skip_dirs.iter().any(|&skip| dir_name.contains(skip))
} else {
true
}
})
.filter_map(Result::ok)
{
// Early termination when we have high confidence
if bit64_binaries.len() >= CONFIDENCE_THRESHOLD || bit32_binaries.len() >= CONFIDENCE_THRESHOLD {
debug!("Reached confidence threshold, stopping scan early");
break;
}
let path = entry.path();
// Only check files
if !path.is_file() {
continue;
}
// Skip non-binary files early for performance
let filename = path.file_name().unwrap_or_default().to_string_lossy();
// Check for common Linux executable extensions or shared libraries
let has_binary_extension = filename.ends_with(".x86")
|| filename.ends_with(".x86_64")
|| filename.ends_with(".bin")
|| filename.ends_with(".so")
|| filename.contains(".so.")
|| filename.starts_with("lib");
// Check if file is executable
let is_executable = {
{
// Get metadata once and check both extension and permissions
if let Ok(metadata) = fs::metadata(path) {
let permissions = metadata.permissions();
let executable = permissions.mode() & 0o111 != 0;
// Skip files that are neither executable nor have binary extensions
executable || has_binary_extension
} else {
// If we can't read metadata, only proceed if it has binary extension
has_binary_extension
}
}
};
if !is_executable {
continue;
}
// Detect bitness
if let Some(bitness) = detect_binary_bitness(path) {
debug!("Found {:?} binary: {}", bitness, path.display());
match bitness {
Bitness::Bit64 => {
bit64_binaries.push(path.to_path_buf());
// If we find libsteam_api.so and it's 64-bit, we can be very confident
if filename == "libsteam_api.so" {
info!("Found 64-bit libsteam_api.so");
return Ok(Bitness::Bit64);
}
},
Bitness::Bit32 => {
bit32_binaries.push(path.to_path_buf());
// If we find libsteam_api.so and it's 32-bit, we can be very confident
if filename == "libsteam_api.so" {
info!("Found 32-bit libsteam_api.so");
return Ok(Bitness::Bit32);
}
},
}
}
}
// Decision logic: prioritize finding the main game executable
// 1. If we found any 64-bit binaries and no 32-bit, it's 64-bit
// 2. If we found any 32-bit binaries and no 64-bit, it's 32-bit
// 3. If we found both, prefer 64-bit (modern games are usually 64-bit)
// 4. If we found neither, return an error
if !bit64_binaries.is_empty() && bit32_binaries.is_empty() {
info!("Detected 64-bit game (Only 64-bit binaries found)");
Ok(Bitness::Bit64)
} else if !bit32_binaries.is_empty() && bit64_binaries.is_empty() {
info!("Detected 32-bit game (Only 32-bit binaries found)");
Ok(Bitness::Bit32)
} else if !bit64_binaries.is_empty() && !bit32_binaries.is_empty() {
warn!(
"Found both 32-bit and 64-bit binaries, defaulting to 64-bit. 32-bit: {}, 64-bit: {}",
bit32_binaries.len(),
bit64_binaries.len()
);
info!("Detected 64-bit game (mixed binaries, defaulting to 64-bit)");
Ok(Bitness::Bit64)
} else {
Err("Could not detect game bitness: no Linux binaries found".to_string())
}
}
+1
View File
@@ -0,0 +1 @@
pub mod bitness;
+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"bundle": {
"active": true,
"targets": "all",
"category": "Utility",
"icon": [
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png"
],
"createUpdaterArtifacts": true
},
"productName": "Creamlinux",
"mainBinaryName": "creamlinux",
"version": "1.4.2",
"identifier": "com.creamlinux.dev",
"app": {
"withGlobalTauri": false,
"windows": [
{
"title": "Creamlinux",
"width": 1000,
"height": 700,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERENzBFNjU0RTBBMUMyNzgKUldSNHdxSGdWT1p3M1liUE0vOGFCRkc2cEQwdWdRR2UyY2VmN3kzckNONCtsaGF0Y1d2WjdOWVEK",
"endpoints": [
"https://github.com/Novattz/creamlinux-installer/releases/latest/download/latest.json"
]
}
}
}
+206
View File
@@ -0,0 +1,206 @@
import { useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { useAppContext } from '@/contexts/useAppContext'
import { useAppLogic, useConflictDetection, useDisclaimer } from '@/hooks'
import './styles/main.scss'
// Layout components
import {
Header,
Sidebar,
InitialLoadingScreen,
ErrorBoundary,
UpdateScreen,
AnimatedBackground,
} from '@/components/layout'
// Dialog components
import {
ProgressDialog,
DlcSelectionDialog,
SettingsDialog,
ConflictDialog,
DisclaimerDialog,
UnlockerSelectionDialog,
} from '@/components/dialogs'
// Game components
import { GameList } from '@/components/games'
/**
* Main application component
*/
function App() {
const [updateComplete, setUpdateComplete] = useState(false)
const { showDisclaimer, handleDisclaimerClose } = useDisclaimer()
// Get application logic from hook
const {
filter,
setFilter,
searchQuery,
handleSearchChange,
isInitialLoad,
scanProgress,
filteredGames,
handleRefresh,
isLoading,
error,
} = useAppLogic({ autoLoad: updateComplete })
// Get action handlers from context
const {
games,
dlcDialog,
handleDlcDialogClose,
handleProgressDialogClose,
progressDialog,
handleGameAction,
handleDlcConfirm,
handleGameEdit,
handleUpdateDlcs,
settingsDialog,
handleSettingsOpen,
handleSettingsClose,
handleSmokeAPISettingsOpen,
showToast,
unlockerSelectionDialog,
handleSelectCreamLinux,
handleSelectSmokeAPI,
closeUnlockerDialog,
} = useAppContext()
// Conflict detection
const { conflicts, showDialog, resolveConflict, closeDialog } =
useConflictDetection(games)
// Handle conflict resolution
const handleConflictResolve = async (
gameId: string,
conflictType: 'cream-to-proton' | 'smoke-to-native'
) => {
try {
// Invoke backend to resolve the conflict
await invoke('resolve_platform_conflict', {
gameId,
conflictType,
})
// Remove from UI
resolveConflict(gameId, conflictType)
showToast('Conflict resolved successfully', 'success')
} catch (error) {
console.error('Error resolving conflict:', error)
showToast('Failed to resolve conflict', 'error')
}
}
// Show update screen first
if (!updateComplete) {
return <UpdateScreen onComplete={() => setUpdateComplete(true)} />
}
// Then show initial loading screen
if (isInitialLoad) {
return <InitialLoadingScreen message={scanProgress.message} progress={scanProgress.progress} />
}
return (
<ErrorBoundary>
<div className="app-container">
{/* Animated background */}
<AnimatedBackground />
{/* Header with search */}
<Header
onRefresh={handleRefresh}
onSearch={handleSearchChange}
searchQuery={searchQuery}
refreshDisabled={isLoading}
/>
<div className="main-content">
{/* Sidebar for filtering */}
<Sidebar
setFilter={setFilter}
currentFilter={filter}
onSettingsClick={handleSettingsOpen}
/>
{/* Show error or game list */}
{error ? (
<div className="error-message">
<h3>Error Loading Games</h3>
<p>{error}</p>
<button onClick={handleRefresh}>Retry</button>
</div>
) : (
<GameList
games={filteredGames}
isLoading={isLoading}
onAction={handleGameAction}
onEdit={handleGameEdit}
onSmokeAPISettings={handleSmokeAPISettingsOpen}
/>
)}
</div>
{/* Progress Dialog */}
<ProgressDialog
visible={progressDialog.visible}
title={progressDialog.title}
message={progressDialog.message}
progress={progressDialog.progress}
showInstructions={progressDialog.showInstructions}
instructions={progressDialog.instructions}
onClose={handleProgressDialogClose}
/>
{/* DLC Selection Dialog */}
<DlcSelectionDialog
visible={dlcDialog.visible}
gameTitle={dlcDialog.gameTitle}
gameId={dlcDialog.gameId}
dlcs={dlcDialog.dlcs}
isLoading={dlcDialog.isLoading}
isEditMode={dlcDialog.isEditMode}
isUpdating={dlcDialog.isUpdating}
updateAttempted={dlcDialog.updateAttempted}
loadingProgress={dlcDialog.progress}
estimatedTimeLeft={dlcDialog.timeLeft}
newDlcsCount={dlcDialog.newDlcsCount}
onClose={handleDlcDialogClose}
onConfirm={handleDlcConfirm}
onUpdate={handleUpdateDlcs}
/>
{/* Settings Dialog */}
<SettingsDialog visible={settingsDialog.visible} onClose={handleSettingsClose} />
{/* Conflict Detection Dialog */}
<ConflictDialog
visible={showDialog}
conflicts={conflicts}
onResolve={handleConflictResolve}
onClose={closeDialog}
/>
{/* Unlocker Selection Dialog */}
<UnlockerSelectionDialog
visible={unlockerSelectionDialog.visible}
gameTitle={unlockerSelectionDialog.gameTitle || ''}
onClose={closeUnlockerDialog}
onSelectCreamLinux={handleSelectCreamLinux}
onSelectSmokeAPI={handleSelectSmokeAPI}
/>
{/* Disclaimer Dialog - Shows AFTER everything is loaded */}
<DisclaimerDialog visible={showDisclaimer} onClose={handleDisclaimerClose} />
</div>
</ErrorBoundary>
)
}
export default App
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+72
View File
@@ -0,0 +1,72 @@
import { FC } from 'react'
import Button, { ButtonVariant } from '../buttons/Button'
import { Icon, trash, download } from '@/components/icons'
// Define available action types
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke' | 'install_unlocker'
interface ActionButtonProps {
action: ActionType
isInstalled: boolean
isWorking: boolean
onClick: () => void
disabled?: boolean
className?: string
}
/**
* Specialized button for game installation actions
*/
const ActionButton: FC<ActionButtonProps> = ({
isInstalled,
isWorking,
onClick,
disabled = false,
className = '',
}) => {
// Determine button text based on state
const getButtonText = () => {
if (isWorking) return 'Working...'
return isInstalled ? 'Uninstall' : 'Install'
}
// Map to button variant
const getButtonVariant = (): ButtonVariant => {
// For uninstall actions, use danger variant
if (isInstalled) return 'danger'
// For install actions, use success variant
return 'success'
}
// Select appropriate icon based on action type and state
const getIconInfo = () => {
if (isInstalled) {
// Uninstall actions
return { name: trash, variant: 'solid' }
} else {
// Install actions
return { name: download, variant: 'solid' }
}
}
const iconInfo = getIconInfo()
return (
<Button
variant={getButtonVariant()}
isLoading={isWorking}
onClick={onClick}
disabled={disabled || isWorking}
fullWidth
className={`action-button ${className}`}
leftIcon={
isWorking ? undefined : <Icon name={iconInfo.name} variant={iconInfo.variant} size="md" />
}
>
{getButtonText()}
</Button>
)
}
export default ActionButton
@@ -0,0 +1,39 @@
import { Icon, check } from '@/components/icons'
interface AnimatedCheckboxProps {
checked: boolean
onChange: () => void
label?: string
sublabel?: string
className?: string
}
/**
* Animated checkbox component with optional label and sublabel
*/
const AnimatedCheckbox = ({
checked,
onChange,
label,
sublabel,
className = '',
}: AnimatedCheckboxProps) => {
return (
<label className={`animated-checkbox ${className}`}>
<input type="checkbox" checked={checked} onChange={onChange} className="checkbox-original" />
<span className={`checkbox-custom ${checked ? 'checked' : ''}`}>
{checked && <Icon name={check} variant="solid" size="sm" className="checkbox-icon" />}
</span>
{(label || sublabel) && (
<div className="checkbox-content">
{label && <span className="checkbox-label">{label}</span>}
{sublabel && <span className="checkbox-sublabel">{sublabel}</span>}
</div>
)}
</label>
)
}
export default AnimatedCheckbox
+72
View File
@@ -0,0 +1,72 @@
import { FC, ButtonHTMLAttributes } from 'react'
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'warning'
export type ButtonSize = 'small' | 'medium' | 'large'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
isLoading?: boolean
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
fullWidth?: boolean
iconOnly?: boolean
}
/**
* Button component with different variants, sizes and states
*/
const Button: FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'medium',
isLoading = false,
leftIcon,
rightIcon,
fullWidth = false,
iconOnly = false,
className = '',
disabled,
...props
}) => {
// Size class mapping
const sizeClass = {
small: 'btn-sm',
medium: 'btn-md',
large: 'btn-lg',
}[size]
// Variant class mapping
const variantClass = {
primary: 'btn-primary',
secondary: 'btn-secondary',
danger: 'btn-danger',
success: 'btn-success',
warning: 'btn-warning',
}[variant]
// Determine if this is an icon-only button
const isIconOnly = iconOnly || (!children && (leftIcon || rightIcon))
return (
<button
className={`btn ${variantClass} ${sizeClass} ${fullWidth ? 'btn-full' : ''} ${
isLoading ? 'btn-loading' : ''
} ${isIconOnly ? 'btn-icon-only' : ''} ${className}`}
disabled={disabled || isLoading}
{...props}
>
{isLoading && (
<span className="btn-spinner">
<span className="spinner"></span>
</span>
)}
{leftIcon && !isLoading && <span className="btn-icon btn-icon-left">{leftIcon}</span>}
{children && <span className="btn-text">{children}</span>}
{rightIcon && !isLoading && <span className="btn-icon btn-icon-right">{rightIcon}</span>}
</button>
)
}
export default Button
+8
View File
@@ -0,0 +1,8 @@
// Export all button components
export { default as Button } from './Button'
export { default as ActionButton } from './ActionButton'
export { default as AnimatedCheckbox } from './AnimatedCheckbox'
// Export types
export type { ButtonVariant, ButtonSize } from './Button'
export type { ActionType } from './ActionButton'
+97
View File
@@ -0,0 +1,97 @@
import { useState, useRef, useEffect } from 'react'
import { Icon, arrowUp } from '@/components/icons'
export interface DropdownOption<T = string> {
value: T
label: string
}
interface DropdownProps<T = string> {
label: string
description?: string
value: T
options: DropdownOption<T>[]
onChange: (value: T) => void
disabled?: boolean
className?: string
}
/**
* Dropdown component for selecting from a list of options
*/
const Dropdown = <T extends string | number | boolean>({
label,
description,
value,
options,
onChange,
disabled = false,
className = '',
}: DropdownProps<T>) => {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
const selectedOption = options.find((opt) => opt.value === value)
const handleSelect = (optionValue: T) => {
onChange(optionValue)
setIsOpen(false)
}
return (
<div className={`dropdown-container ${className}`}>
<div className="dropdown-label-container">
<label className="dropdown-label">{label}</label>
{description && <p className="dropdown-description">{description}</p>}
</div>
<div className={`dropdown ${disabled ? 'disabled' : ''}`} ref={dropdownRef}>
<button
type="button"
className="dropdown-trigger"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
>
<span className="dropdown-value">{selectedOption?.label || 'Select...'}</span>
<Icon
name={arrowUp}
variant="solid"
size="sm"
className={`dropdown-icon ${isOpen ? 'open' : ''}`}
/>
</button>
{isOpen && !disabled && (
<div className="dropdown-menu">
{options.map((option) => (
<button
key={String(option.value)}
type="button"
className={`dropdown-option ${option.value === value ? 'selected' : ''}`}
onClick={() => handleSelect(option.value)}
>
{option.label}
</button>
))}
</div>
)}
</div>
</div>
)
}
export default Dropdown
@@ -0,0 +1,73 @@
import { ReactNode } from 'react'
export type LoadingType = 'spinner' | 'dots' | 'progress'
export type LoadingSize = 'small' | 'medium' | 'large'
interface LoadingIndicatorProps {
size?: LoadingSize
type?: LoadingType
message?: string
progress?: number
className?: string
}
/**
* Versatile loading indicator component
* Supports multiple visual styles and sizes
*/
const LoadingIndicator = ({
size = 'medium',
type = 'spinner',
message,
progress = 0,
className = '',
}: LoadingIndicatorProps) => {
// Size class mapping
const sizeClass = {
small: 'loading-small',
medium: 'loading-medium',
large: 'loading-large',
}[size]
// Render loading indicator based on type
const renderLoadingIndicator = (): ReactNode => {
switch (type) {
case 'spinner':
return <div className="loading-spinner"></div>
case 'dots':
return (
<div className="loading-dots">
<div className="dot dot-1"></div>
<div className="dot dot-2"></div>
<div className="dot dot-3"></div>
</div>
)
case 'progress':
return (
<div className="loading-progress">
<div className="progress-bar-container">
<div
className="progress-bar"
style={{ width: `${Math.min(Math.max(progress, 0), 100)}%` }}
></div>
</div>
{progress > 0 && <div className="progress-percentage">{Math.round(progress)}%</div>}
</div>
)
default:
return <div className="loading-spinner"></div>
}
}
return (
<div className={`loading-indicator ${sizeClass} ${className}`}>
{renderLoadingIndicator()}
{message && <p className="loading-message">{message}</p>}
</div>
)
}
export default LoadingIndicator
+22
View File
@@ -0,0 +1,22 @@
interface ProgressBarProps {
progress: number
}
/**
* Simple progress bar component
*/
const ProgressBar = ({ progress }: ProgressBarProps) => {
return (
<div className="progress-container">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
<span className="progress-text">{Math.round(progress)}%</span>
</div>
)
}
export default ProgressBar
+6
View File
@@ -0,0 +1,6 @@
export { default as LoadingIndicator } from './LoadingIndicator'
export { default as ProgressBar } from './ProgressBar'
export { default as Dropdown } from './Dropdown'
export type { LoadingSize, LoadingType } from './LoadingIndicator'
export type { DropdownOption } from './Dropdown'
+93
View File
@@ -0,0 +1,93 @@
import { useState, useEffect } from 'react'
import Dialog from './Dialog'
import DialogHeader from './DialogHeader'
import DialogBody from './DialogBody'
import DialogFooter from './DialogFooter'
import DialogActions from './DialogActions'
import { Button } from '@/components/buttons'
import { DlcInfo } from '@/types'
export interface AddDlcDialogProps {
visible: boolean
onClose: () => void
onAdd: (dlc: DlcInfo) => void
existingIds: Set<string>
}
/**
* Add DLC Manually dialog
* Allows users to manually enter a DLC ID and name when it is
* missing from the Steam API and cannot be fetched automatically
*/
const AddDlcDialog = ({ visible, onClose, onAdd, existingIds }: AddDlcDialogProps) => {
const [id, setId] = useState('')
const [name, setName] = useState('')
const [error, setError] = useState('')
// Reset form state when dialog closes
useEffect(() => {
if (!visible) {
setId('')
setName('')
setError('')
}
}, [visible])
// Validate inputs and add the DLC to the list
const handleSubmit = () => {
const trimmedId = id.trim()
const trimmedName = name.trim()
if (!trimmedId) return setError('DLC ID is required.')
if (!/^\d+$/.test(trimmedId)) return setError('DLC ID must be a number.')
if (existingIds.has(trimmedId)) return setError('A DLC with this ID already exists.')
if (!trimmedName) return setError('DLC name is required.')
onAdd({ appid: trimmedId, name: trimmedName, enabled: true })
onClose()
}
return (
<Dialog visible={visible} onClose={onClose} size="small">
<DialogHeader onClose={onClose}>
<h3>Add DLC Manually</h3>
</DialogHeader>
<DialogBody>
<div className="add-dlc-form">
<div className="add-dlc-field">
<label className="add-dlc-label">DLC ID</label>
<input
type="text"
className="add-dlc-input"
placeholder="e.g. 1234560"
value={id}
onChange={(e) => { setId(e.target.value); setError('') }}
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
autoFocus
/>
</div>
<div className="add-dlc-field">
<label className="add-dlc-label">DLC Name</label>
<input
type="text"
className="add-dlc-input"
placeholder="e.g. Expansion - My DLC"
value={name}
onChange={(e) => { setName(e.target.value); setError('') }}
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
/>
</div>
{error && <p className="add-dlc-error">{error}</p>}
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSubmit}>Add DLC</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default AddDlcDialog
+106
View File
@@ -0,0 +1,106 @@
import React from 'react'
import {
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
DialogActions,
} from '@/components/dialogs'
import { Button } from '@/components/buttons'
import { Icon, warning, info } from '@/components/icons'
export interface Conflict {
gameId: string
gameTitle: string
type: 'cream-to-proton' | 'smoke-to-native'
}
export interface ConflictDialogProps {
visible: boolean
conflicts: Conflict[]
onResolve: (gameId: string, conflictType: 'cream-to-proton' | 'smoke-to-native') => void
onClose: () => void
}
/**
* Conflict Dialog component
* Shows all conflicts at once with individual resolve buttons
*/
const ConflictDialog: React.FC<ConflictDialogProps> = ({
visible,
conflicts,
onResolve,
onClose,
}) => {
// Check if any CreamLinux conflicts exist
const hasCreamConflicts = conflicts.some((c) => c.type === 'cream-to-proton')
const getConflictDescription = (type: 'cream-to-proton' | 'smoke-to-native') => {
if (type === 'cream-to-proton') {
return 'Will remove existing unlocker files and restore the game to a clean state.'
} else {
return 'Will remove existing unlocker files and restore the game to a clean state.'
}
}
return (
<Dialog visible={visible} size="large" preventBackdropClose={true}>
<DialogHeader hideCloseButton={true}>
<div className="conflict-dialog-header">
<Icon name={warning} variant="solid" size="lg" />
<h3>Unlocker conflicts detected</h3>
</div>
</DialogHeader>
<DialogBody>
<div className="conflict-dialog-body">
<p className="conflict-intro">
Some games have conflicting unlocker states that need attention.
</p>
<div className="conflict-list">
{conflicts.map((conflict) => (
<div key={conflict.gameId} className="conflict-item">
<div className="conflict-info">
<div className="conflict-icon">
<Icon name={warning} variant="solid" size="md" />
</div>
<div className="conflict-details">
<h4>{conflict.gameTitle}</h4>
<p>{getConflictDescription(conflict.type)}</p>
</div>
</div>
<Button
variant="primary"
onClick={() => onResolve(conflict.gameId, conflict.type)}
className="conflict-resolve-btn"
>
Resolve
</Button>
</div>
))}
</div>
</div>
</DialogBody>
<DialogFooter>
{hasCreamConflicts && (
<div className="conflict-reminder">
<Icon name={info} variant="solid" size="md" />
<span>
Remember to remove <code>sh ./cream.sh %command%</code> from Steam launch options
after resolving CreamLinux conflicts.
</span>
</div>
)}
<DialogActions>
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default ConflictDialog
+77
View File
@@ -0,0 +1,77 @@
import { ReactNode, useEffect, useState } from 'react'
export interface DialogProps {
visible: boolean
onClose?: () => void
className?: string
preventBackdropClose?: boolean
children: ReactNode
size?: 'small' | 'medium' | 'large'
showAnimationOnUnmount?: boolean
}
/**
* Base Dialog component that serves as a container for dialog content
* Used with DialogHeader, DialogBody, and DialogFooter components
*/
const Dialog = ({
visible,
onClose,
className = '',
preventBackdropClose = false,
children,
size = 'medium',
showAnimationOnUnmount = true,
}: DialogProps) => {
const [showContent, setShowContent] = useState(false)
const [shouldRender, setShouldRender] = useState(visible)
// Handle visibility changes with animations
useEffect(() => {
if (visible) {
setShouldRender(true)
// Small delay to trigger entrance animation after component is mounted
const timer = setTimeout(() => {
setShowContent(true)
}, 50)
return () => clearTimeout(timer)
} else if (showAnimationOnUnmount) {
// First hide content with animation
setShowContent(false)
// Then unmount after animation completes
const timer = setTimeout(() => {
setShouldRender(false)
}, 300) // Match this with your CSS transition duration
return () => clearTimeout(timer)
} else {
// Immediately unmount without animation
setShowContent(false)
setShouldRender(false)
}
}, [visible, showAnimationOnUnmount])
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget && !preventBackdropClose && onClose) {
onClose()
}
}
// Don't render anything if dialog shouldn't be shown
if (!shouldRender) return null
const sizeClass = {
small: 'dialog-small',
medium: 'dialog-medium',
large: 'dialog-large',
}[size]
return (
<div className={`dialog-overlay ${showContent ? 'visible' : ''}`} onClick={handleBackdropClick}>
<div className={`dialog ${sizeClass} ${className} ${showContent ? 'dialog-visible' : ''}`}>
{children}
</div>
</div>
)
}
export default Dialog
+23
View File
@@ -0,0 +1,23 @@
import { ReactNode } from 'react'
export interface DialogActionsProps {
children: ReactNode
className?: string
align?: 'start' | 'center' | 'end'
}
/**
* Actions container for dialog footers
* Provides consistent spacing and alignment for action buttons
*/
const DialogActions = ({ children, className = '', align = 'end' }: DialogActionsProps) => {
const alignClass = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
}[align]
return <div className={`dialog-actions ${alignClass} ${className}`}>{children}</div>
}
export default DialogActions
+16
View File
@@ -0,0 +1,16 @@
import { ReactNode } from 'react'
export interface DialogBodyProps {
children: ReactNode
className?: string
}
/**
* Body component for dialogs
* Contains the main content with scrolling capability
*/
const DialogBody = ({ children, className = '' }: DialogBodyProps) => {
return <div className={`dialog-body ${className}`}>{children}</div>
}
export default DialogBody
+16
View File
@@ -0,0 +1,16 @@
import { ReactNode } from 'react'
export interface DialogFooterProps {
children: ReactNode
className?: string
}
/**
* Footer component for dialogs
* Contains action buttons and optional status information
*/
const DialogFooter = ({ children, className = '' }: DialogFooterProps) => {
return <div className={`dialog-footer ${className}`}>{children}</div>
}
export default DialogFooter
+27
View File
@@ -0,0 +1,27 @@
import { ReactNode } from 'react'
export interface DialogHeaderProps {
children: ReactNode
className?: string
onClose?: () => void
hideCloseButton?: boolean;
}
/**
* Header component for dialogs
* Contains the title and optional close button
*/
const DialogHeader = ({ children, className = '', onClose, hideCloseButton = false }: DialogHeaderProps) => {
return (
<div className={`dialog-header ${className}`}>
{children}
{onClose && !hideCloseButton && (
<button className="dialog-close-button" onClick={onClose} aria-label="Close dialog">
×
</button>
)}
</div>
)
}
export default DialogHeader
@@ -0,0 +1,69 @@
import {
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
DialogActions,
} from '@/components/dialogs'
import { Button, AnimatedCheckbox } from '@/components/buttons'
import { useState } from 'react'
export interface DisclaimerDialogProps {
visible: boolean
onClose: (dontShowAgain: boolean) => void
}
/**
* Disclaimer dialog that appears on app startup
* Informs users that CreamLinux manages DLC IDs, not actual DLC files
*/
const DisclaimerDialog = ({ visible, onClose }: DisclaimerDialogProps) => {
const [dontShowAgain, setDontShowAgain] = useState(false)
const handleOkClick = () => {
onClose(dontShowAgain)
}
return (
<Dialog visible={visible} onClose={() => onClose(false)} size="medium" preventBackdropClose>
<DialogHeader hideCloseButton={true}>
<div className="disclaimer-header">
<h3>Important Notice</h3>
</div>
</DialogHeader>
<DialogBody>
<div className="disclaimer-content">
<p>
<strong>CreamLinux Installer</strong> does not install any DLC content files.
</p>
<p>
This application manages the <strong>DLC IDs</strong> associated with DLCs you want to
use. You must obtain the actual DLC files separately.
</p>
<p>
This tool only configures which DLC IDs are recognized by the game unlockers
(CreamLinux and SmokeAPI).
</p>
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<div className="disclaimer-footer">
<AnimatedCheckbox
checked={dontShowAgain}
onChange={() => setDontShowAgain(!dontShowAgain)}
label="Don't show this disclaimer again"
/>
<Button variant="primary" onClick={handleOkClick}>
OK
</Button>
</div>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default DisclaimerDialog
@@ -0,0 +1,298 @@
import React, { useState, useEffect, useCallback } from 'react'
import Dialog from './Dialog'
import DialogHeader from './DialogHeader'
import DialogBody from './DialogBody'
import DialogFooter from './DialogFooter'
import DialogActions from './DialogActions'
import AddDlcDialog from './AddDlcDialog'
import { Button, AnimatedCheckbox } from '@/components/buttons'
import { DlcInfo } from '@/types'
import { Icon, check, info } from '@/components/icons'
export interface DlcSelectionDialogProps {
visible: boolean
gameTitle: string
gameId: string
dlcs: DlcInfo[]
onClose: () => void
onConfirm: (selectedDlcs: DlcInfo[]) => void
onUpdate?: (gameId: string) => void
isLoading: boolean
isEditMode?: boolean
isUpdating?: boolean
updateAttempted?: boolean
loadingProgress?: number
estimatedTimeLeft?: string
newDlcsCount?: number
}
/**
* DLC Selection Dialog component
* Allows users to select which DLCs they want to enable
* Works for both initial installation and editing existing configurations
*/
const DlcSelectionDialog = ({
visible,
gameTitle,
gameId,
dlcs,
onClose,
onConfirm,
onUpdate,
isLoading,
isEditMode = false,
isUpdating = false,
updateAttempted = false,
loadingProgress = 0,
estimatedTimeLeft = '',
newDlcsCount = 0,
}: DlcSelectionDialogProps) => {
// State for DLC management
const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [selectAll, setSelectAll] = useState(true)
const [initialized, setInitialized] = useState(false)
const [showAddDlc, setShowAddDlc] = useState(false)
// Reset dialog state when it opens or closes
useEffect(() => {
if (!visible) {
setInitialized(false)
setSelectedDlcs([])
setSearchQuery('')
}
}, [visible])
// Initialize selected DLCs when DLC list changes
useEffect(() => {
if (dlcs.length > 0) {
if (!initialized) {
// Create a new array to ensure we don't share references
setSelectedDlcs([...dlcs])
// Determine initial selectAll state based on if all DLCs are enabled
const allSelected = dlcs.every((dlc) => dlc.enabled)
setSelectAll(allSelected)
// Mark as initialized to avoid resetting selections on subsequent updates
setInitialized(true)
} else {
// Find new DLCs that aren't in our current selection
const currentAppIds = new Set(selectedDlcs.map((dlc) => dlc.appid))
const newDlcs = dlcs.filter((dlc) => !currentAppIds.has(dlc.appid))
// If we found new DLCs, add them to our selection
if (newDlcs.length > 0) {
setSelectedDlcs((prev) => [...prev, ...newDlcs])
}
}
}
}, [dlcs, selectedDlcs, initialized])
// Memoize filtered DLCs to avoid unnecessary recalculations
const filteredDlcs = React.useMemo(() => {
return searchQuery.trim() === ''
? selectedDlcs
: selectedDlcs.filter(
(dlc) =>
dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
dlc.appid.includes(searchQuery)
)
}, [selectedDlcs, searchQuery])
// Update DLC selection status
const handleToggleDlc = useCallback((appid: string) => {
setSelectedDlcs((prev) =>
prev.map((dlc) => (dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc))
)
}, [])
// Update selectAll state when individual DLC selections change
useEffect(() => {
if (selectedDlcs.length > 0) {
const allSelected = selectedDlcs.every((dlc) => dlc.enabled)
setSelectAll(allSelected)
}
}, [selectedDlcs])
// Toggle all DLCs at once
const handleToggleSelectAll = useCallback(() => {
const newSelectAllState = !selectAll
setSelectAll(newSelectAllState)
setSelectedDlcs((prev) =>
prev.map((dlc) => ({
...dlc,
enabled: newSelectAllState,
}))
)
}, [selectAll])
// Add a manually-entered DLC to the list
const handleAddDlc = useCallback((dlc: DlcInfo) => {
setSelectedDlcs((prev) => [...prev, dlc])
}, [])
// Submit selected DLCs to parent component
const handleConfirm = useCallback(() => {
// Create a deep copy to prevent reference issues
const dlcsCopy = JSON.parse(JSON.stringify(selectedDlcs))
onConfirm(dlcsCopy)
}, [onConfirm, selectedDlcs])
// Count selected DLCs
const selectedCount = selectedDlcs.filter((dlc) => dlc.enabled).length
// Format dialog title and messages based on mode
const dialogTitle = isEditMode ? 'Edit DLCs' : 'Select DLCs to Enable'
const actionButtonText = isEditMode ? 'Save Changes' : 'Install with Selected DLCs'
// Format loading message to show total number of DLCs found
const getLoadingInfoText = () => {
if (isLoading && loadingProgress < 100) {
return ` (Loading more DLCs...)`
} else if (dlcs.length > 0) {
return ` (Total DLCs: ${dlcs.length})`
}
return ''
}
return (
<>
<Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}>
<DialogHeader onClose={onClose} hideCloseButton={true}>
<h3>{dialogTitle}</h3>
<div className="dlc-game-info">
<span className="game-title">{gameTitle}</span>
<span className="dlc-count">
{selectedCount} of {selectedDlcs.length} DLCs selected
{getLoadingInfoText()}
</span>
</div>
</DialogHeader>
<div className="dlc-dialog-search">
<input
type="text"
placeholder="Search DLCs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="dlc-search-input"
/>
<div className="select-all-container">
<AnimatedCheckbox
checked={selectAll}
onChange={handleToggleSelectAll}
label="Select All"
/>
</div>
</div>
{(isLoading || isUpdating) && loadingProgress > 0 && (
<div className="dlc-loading-progress">
<div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
</div>
<div className="loading-details">
<span>{isUpdating ? 'Updating DLC list' : 'Loading DLCs'}: {loadingProgress}%</span>
{estimatedTimeLeft && (
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
)}
</div>
</div>
)}
<DialogBody className="dlc-list-container">
{selectedDlcs.length > 0 ? (
<ul className="dlc-list">
{filteredDlcs.map((dlc) => (
<li key={dlc.appid} className="dlc-item">
<AnimatedCheckbox
checked={dlc.enabled}
onChange={() => handleToggleDlc(dlc.appid)}
label={dlc.name}
sublabel={`ID: ${dlc.appid}`}
/>
</li>
))}
{isLoading && (
<li className="dlc-item dlc-item-loading">
<div className="loading-pulse"></div>
</li>
)}
</ul>
) : (
<div className="dlc-loading">
<div className="loading-spinner"></div>
<p>Loading DLC information...</p>
</div>
)}
</DialogBody>
<DialogFooter>
{/* Show update results */}
{!isUpdating && !isLoading && isEditMode && updateAttempted && (
<>
{newDlcsCount > 0 && (
<div className="dlc-update-results dlc-update-success">
<span className="update-message">
<Icon name={check} size="md" variant="solid" className="dlc-update-icon-success"/> Found {newDlcsCount} new DLC{newDlcsCount > 1 ? 's' : ''}!
</span>
</div>
)}
{newDlcsCount === 0 && (
<div className="dlc-update-results dlc-update-info">
<span className="update-message">
<Icon name={info} size="md" variant="solid" className="dlc-update-icon-info"/> No new DLCs found. Your list is up to date!
</span>
</div>
)}
</>
)}
<DialogActions>
<Button
variant="secondary"
onClick={onClose}
disabled={(isLoading || isUpdating) && loadingProgress < 10}
>
Cancel
</Button>
<Button
variant="secondary"
onClick={() => setShowAddDlc(true)}
disabled={isLoading || isUpdating}
>
Add DLC Manually
</Button>
{/* Update button - only show in edit mode */}
{isEditMode && onUpdate && (
<Button
variant="warning"
onClick={() => onUpdate(gameId)}
disabled={isLoading || isUpdating}
>
{isUpdating ? 'Updating...' : 'Update DLC List'}
</Button>
)}
<Button variant="primary" onClick={handleConfirm} disabled={isLoading || isUpdating}>
{actionButtonText}
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
<AddDlcDialog
visible={showAddDlc}
onClose={() => setShowAddDlc(false)}
onAdd={handleAddDlc}
existingIds={new Set(selectedDlcs.map((d) => d.appid))}
/>
</>
)
}
export default DlcSelectionDialog
+173
View File
@@ -0,0 +1,173 @@
import { useState } from 'react'
import Dialog from './Dialog'
import DialogHeader from './DialogHeader'
import DialogBody from './DialogBody'
import DialogFooter from './DialogFooter'
import DialogActions from './DialogActions'
import { Button } from '@/components/buttons'
export interface InstallationInstructions {
type: string
command: string
game_title: string
dlc_count?: number
}
export interface ProgressDialogProps {
visible: boolean
title: string
message: string
progress: number
showInstructions?: boolean
instructions?: InstallationInstructions
onClose?: () => void
}
/**
* ProgressDialog component
* Shows installation progress with a progress bar and optional instructions
*/
const ProgressDialog = ({
visible,
title,
message,
progress,
showInstructions = false,
instructions,
onClose,
}: ProgressDialogProps) => {
const [copySuccess, setCopySuccess] = useState(false)
const handleCopyCommand = () => {
if (instructions?.command) {
navigator.clipboard.writeText(instructions.command)
setCopySuccess(true)
// Reset the success message after 2 seconds
setTimeout(() => {
setCopySuccess(false)
}, 2000)
}
}
// Determine if we should show the copy button (for CreamLinux but not SmokeAPI)
const showCopyButton =
instructions?.type === 'cream_install' || instructions?.type === 'cream_uninstall'
// Format instruction message based on type
const getInstructionText = () => {
if (!instructions) return null
switch (instructions.type) {
case 'cream_install':
return (
<>
<p className="instruction-text">
In Steam, set the following launch options for{' '}
<strong>{instructions.game_title}</strong>:
</p>
{instructions.dlc_count !== undefined && (
<div className="dlc-count">
<strong>{instructions.dlc_count}</strong> DLCs have been enabled!
</div>
)}
</>
)
case 'cream_uninstall':
return (
<p className="instruction-text">
For <strong>{instructions.game_title}</strong>, open Steam properties and remove the
following launch option:
</p>
)
case 'smoke_install':
return (
<>
<p className="instruction-text">
SmokeAPI has been installed for <strong>{instructions.game_title}</strong>
</p>
{instructions.dlc_count !== undefined && (
<div className="dlc-count">
<strong>{instructions.dlc_count}</strong> Steam API files have been patched.
</div>
)}
</>
)
case 'smoke_uninstall':
return (
<p className="instruction-text">
SmokeAPI has been uninstalled from <strong>{instructions.game_title}</strong>
</p>
)
default:
return (
<p className="instruction-text">
Done processing <strong>{instructions.game_title}</strong>
</p>
)
}
}
// Determine the CSS class for the command box based on instruction type
const getCommandBoxClass = () => {
return instructions?.type.includes('smoke') ? 'command-box command-box-smoke' : 'command-box'
}
// Determine if close button should be enabled
const isCloseButtonEnabled = showInstructions || progress >= 100
return (
<Dialog
visible={visible}
onClose={isCloseButtonEnabled ? onClose : undefined}
size="medium"
preventBackdropClose={!isCloseButtonEnabled}
>
<DialogHeader onClose={onClose} hideCloseButton={true}>
<h3>{title}</h3>
</DialogHeader>
<DialogBody>
<p>{message}</p>
<div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${progress}%` }} />
</div>
<div className="progress-percentage">{Math.round(progress)}%</div>
{showInstructions && instructions && (
<div className="instruction-container">
<h4>
{instructions.type.includes('uninstall')
? 'Uninstallation Instructions'
: 'Installation Instructions'}
</h4>
{getInstructionText()}
<div className={getCommandBoxClass()}>
<pre className="selectable-text">{instructions.command}</pre>
</div>
</div>
)}
</DialogBody>
<DialogFooter>
<DialogActions>
{showInstructions && showCopyButton && (
<Button variant="primary" onClick={handleCopyCommand}>
{copySuccess ? 'Copied!' : 'Copy to Clipboard'}
</Button>
)}
{isCloseButtonEnabled && (
<Button variant="secondary" onClick={onClose} disabled={!isCloseButtonEnabled}>
Close
</Button>
)}
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default ProgressDialog
+56
View File
@@ -0,0 +1,56 @@
import React from 'react'
import {
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
DialogActions,
} from '@/components/dialogs'
import { Button } from '@/components/buttons'
import { Icon, info } from '@/components/icons'
export interface ReminderDialogProps {
visible: boolean
onClose: () => void
}
/**
* Reminder Dialog component
* Reminds users to remove Steam launch options after removing CreamLinux
*/
const ReminderDialog: React.FC<ReminderDialogProps> = ({ visible, onClose }) => {
return (
<Dialog visible={visible} onClose={onClose} size="small">
<DialogHeader onClose={onClose} hideCloseButton={true}>
<div className="reminder-dialog-header">
<Icon name={info} variant="solid" size="lg" />
<h3>Reminder</h3>
</div>
</DialogHeader>
<DialogBody>
<div className="reminder-dialog-body">
<p>
If you added a Steam launch option for CreamLinux, remember to remove it in Steam:
</p>
<ol className="reminder-steps">
<li>Right-click the game in Steam</li>
<li>Select "Properties"</li>
<li>Go to "Launch Options"</li>
<li>Remove the CreamLinux command</li>
</ol>
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="primary" onClick={onClose}>
Got it
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default ReminderDialog
+113
View File
@@ -0,0 +1,113 @@
import React, { useEffect, useState } from 'react'
import { getVersion } from '@tauri-apps/api/app'
import {
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
DialogActions,
} from '@/components/dialogs'
import { Button } from '@/components/buttons'
import { Icon, settings } from '@/components/icons'
interface SettingsDialogProps {
visible: boolean
onClose: () => void
}
/**
* Settings Dialog component
* Contains application settings and configuration options
*/
const SettingsDialog: React.FC<SettingsDialogProps> = ({ visible, onClose }) => {
const [appVersion, setAppVersion] = useState<string>('Loading...')
useEffect(() => {
// Fetch app version when component mounts
const fetchVersion = async () => {
try {
const version = await getVersion()
setAppVersion(version)
} catch (error) {
console.error('Failed to fetch app version:', error)
setAppVersion('Unknown')
}
}
fetchVersion()
}, [])
return (
<Dialog visible={visible} onClose={onClose} size="medium">
<DialogHeader onClose={onClose} hideCloseButton={true}>
<div className="settings-header">
{/*<Icon name={settings} variant="solid" size="md" />*/}
<h3>Settings</h3>
</div>
</DialogHeader>
<DialogBody>
<div className="settings-content">
<div className="settings-section">
<h4>General Settings</h4>
<p className="settings-description">
Configure your CreamLinux preferences and application behavior.
</p>
<div className="settings-placeholder">
<div className="placeholder-icon"> <Icon name={settings} variant="solid" size="xl" /> </div>
<div className="placeholder-text">
<h5>Settings Coming Soon</h5>
<p>
Working on adding customizable settings to improve your experience.
Future options may include:
</p>
<ul>
<li>Custom Steam library paths</li>
<li>Automatic update settings</li>
<li>Scan frequency options</li>
<li>DLC catalog</li>
</ul>
</div>
</div>
</div>
<div className="settings-section">
<h4>About CreamLinux</h4>
<div className="app-info">
<div className="info-row">
<span className="info-label">Version:</span>
<span className="info-value">{appVersion}</span>
</div>
<div className="info-row">
<span className="info-label">Build:</span>
<span className="info-value">Beta</span>
</div>
<div className="info-row">
<span className="info-label">Repository:</span>
<a
href="https://github.com/Novattz/creamlinux-installer"
target="_blank"
rel="noopener noreferrer"
className="info-link"
>
GitHub
</a>
</div>
</div>
</div>
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default SettingsDialog
@@ -0,0 +1,228 @@
import { useState, useEffect, useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import {
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
DialogActions,
} from '@/components/dialogs'
import { Button, AnimatedCheckbox } from '@/components/buttons'
import { Dropdown, DropdownOption } from '@/components/common'
//import { Icon, settings } from '@/components/icons'
interface SmokeAPIConfig {
$schema: string
$version: number
logging: boolean
log_steam_http: boolean
default_app_status: 'unlocked' | 'locked' | 'original'
override_app_status: Record<string, string>
override_dlc_status: Record<string, string>
auto_inject_inventory: boolean
extra_inventory_items: number[]
extra_dlcs: Record<string, unknown>
}
interface SmokeAPISettingsDialogProps {
visible: boolean
onClose: () => void
gamePath: string
gameTitle: string
}
const DEFAULT_CONFIG: SmokeAPIConfig = {
$schema:
'https://raw.githubusercontent.com/acidicoala/SmokeAPI/refs/tags/v4.0.0/res/SmokeAPI.schema.json',
$version: 4,
logging: false,
log_steam_http: false,
default_app_status: 'unlocked',
override_app_status: {},
override_dlc_status: {},
auto_inject_inventory: true,
extra_inventory_items: [],
extra_dlcs: {},
}
const APP_STATUS_OPTIONS: DropdownOption<'unlocked' | 'locked' | 'original'>[] = [
{ value: 'unlocked', label: 'Unlocked' },
{ value: 'locked', label: 'Locked' },
{ value: 'original', label: 'Original' },
]
/**
* SmokeAPI Settings Dialog
* Allows configuration of SmokeAPI for a specific game
*/
const SmokeAPISettingsDialog = ({
visible,
onClose,
gamePath,
gameTitle,
}: SmokeAPISettingsDialogProps) => {
const [enabled, setEnabled] = useState(false)
const [config, setConfig] = useState<SmokeAPIConfig>(DEFAULT_CONFIG)
const [isLoading, setIsLoading] = useState(false)
const [hasChanges, setHasChanges] = useState(false)
// Load existing config when dialog opens
const loadConfig = useCallback(async () => {
setIsLoading(true)
try {
const existingConfig = await invoke<SmokeAPIConfig | null>('read_smokeapi_config', {
gamePath,
})
if (existingConfig) {
setConfig(existingConfig)
setEnabled(true)
} else {
setConfig(DEFAULT_CONFIG)
setEnabled(false)
}
setHasChanges(false)
} catch (error) {
console.error('Failed to load SmokeAPI config:', error)
setConfig(DEFAULT_CONFIG)
setEnabled(false)
} finally {
setIsLoading(false)
}
}, [gamePath])
useEffect(() => {
if (visible && gamePath) {
loadConfig()
}
}, [visible, gamePath, loadConfig])
const handleSave = async () => {
setIsLoading(true)
try {
if (enabled) {
// Save the config
await invoke('write_smokeapi_config', {
gamePath,
config,
})
} else {
// Delete the config
await invoke('delete_smokeapi_config', {
gamePath,
})
}
setHasChanges(false)
onClose()
} catch (error) {
console.error('Failed to save SmokeAPI config:', error)
} finally {
setIsLoading(false)
}
}
const handleCancel = () => {
setHasChanges(false)
onClose()
}
const updateConfig = <K extends keyof SmokeAPIConfig>(key: K, value: SmokeAPIConfig[K]) => {
setConfig((prev) => ({ ...prev, [key]: value }))
setHasChanges(true)
}
return (
<Dialog visible={visible} onClose={handleCancel} size="medium">
<DialogHeader onClose={handleCancel} hideCloseButton={true}>
<div className="settings-header">
{/*<Icon name={settings} variant="solid" size="md" />*/}
<h3>SmokeAPI Settings</h3>
</div>
<p className="dialog-subtitle">{gameTitle}</p>
</DialogHeader>
<DialogBody>
<div className="smokeapi-settings-content">
{/* Enable/Disable Section */}
<div className="settings-section">
<AnimatedCheckbox
checked={enabled}
onChange={() => {
setEnabled(!enabled)
setHasChanges(true)
}}
label="Enable SmokeAPI Configuration"
sublabel="Enable this to customize SmokeAPI settings for this game"
/>
</div>
{/* Settings Options */}
<div className={`settings-options ${!enabled ? 'disabled' : ''}`}>
<div className="settings-section">
<h4>General Settings</h4>
<Dropdown
label="Default App Status"
description="Specifies the default DLC status"
value={config.default_app_status}
options={APP_STATUS_OPTIONS}
onChange={(value) => updateConfig('default_app_status', value)}
disabled={!enabled}
/>
</div>
<div className="settings-section">
<h4>Logging</h4>
<div className="checkbox-option">
<AnimatedCheckbox
checked={config.logging}
onChange={() => updateConfig('logging', !config.logging)}
label="Enable Logging"
sublabel="Enables logging to SmokeAPI.log.log file"
/>
</div>
<div className="checkbox-option">
<AnimatedCheckbox
checked={config.log_steam_http}
onChange={() => updateConfig('log_steam_http', !config.log_steam_http)}
label="Log Steam HTTP"
sublabel="Toggles logging of SteamHTTP traffic"
/>
</div>
</div>
<div className="settings-section">
<h4>Inventory</h4>
<div className="checkbox-option">
<AnimatedCheckbox
checked={config.auto_inject_inventory}
onChange={() =>
updateConfig('auto_inject_inventory', !config.auto_inject_inventory)
}
label="Auto Inject Inventory"
sublabel="Automatically inject a list of all registered inventory items when the game queries user inventory"
/>
</div>
</div>
</div>
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="secondary" onClick={handleCancel} disabled={isLoading}>
Cancel
</Button>
<Button variant="primary" onClick={handleSave} disabled={isLoading || !hasChanges}>
{isLoading ? 'Saving...' : 'Save'}
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default SmokeAPISettingsDialog
@@ -0,0 +1,95 @@
import React from 'react'
import {
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
DialogActions,
} from '@/components/dialogs'
import { Button } from '@/components/buttons'
import { Icon, info } from '@/components/icons'
export interface UnlockerSelectionDialogProps {
visible: boolean
gameTitle: string
onClose: () => void
onSelectCreamLinux: () => void
onSelectSmokeAPI: () => void
}
/**
* Unlocker Selection Dialog component
* Allows users to choose between CreamLinux and SmokeAPI for native Linux games
*/
const UnlockerSelectionDialog: React.FC<UnlockerSelectionDialogProps> = ({
visible,
gameTitle,
onClose,
onSelectCreamLinux,
onSelectSmokeAPI,
}) => {
return (
<Dialog visible={visible} onClose={onClose} size="medium">
<DialogHeader onClose={onClose} hideCloseButton={true}>
<div className="unlocker-selection-header">
<h3>Choose Unlocker</h3>
</div>
</DialogHeader>
<DialogBody>
<div className="unlocker-selection-content">
<p className="game-title-info">
Select which unlocker to install for <strong>{gameTitle}</strong>:
</p>
<div className="unlocker-options">
<div className="unlocker-option recommended">
<div className="option-header">
<h4>CreamLinux</h4>
<span className="recommended-badge">Recommended</span>
</div>
<p className="option-description">
Native Linux DLC unlocker. Works best with most native Linux games and provides
better compatibility.
</p>
<Button variant="primary" onClick={onSelectCreamLinux} fullWidth>
Install CreamLinux
</Button>
</div>
<div className="unlocker-option">
<div className="option-header">
<h4>SmokeAPI</h4>
<span className="alternative-badge">Alternative</span>
</div>
<p className="option-description">
Cross-platform DLC unlocker. Try this if CreamLinux doesn't work for your game.
Automatically fetches DLC information.
</p>
<Button variant="secondary" onClick={onSelectSmokeAPI} fullWidth>
Install SmokeAPI
</Button>
</div>
</div>
<div className="selection-info">
<Icon name={info} variant="solid" size="md" />
<span>
You can always uninstall and try the other option if one doesn't work properly.
</span>
</div>
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default UnlockerSelectionDialog
+26
View File
@@ -0,0 +1,26 @@
// Export all dialog components
export { default as Dialog } from './Dialog'
export { default as DialogHeader } from './DialogHeader'
export { default as DialogBody } from './DialogBody'
export { default as DialogFooter } from './DialogFooter'
export { default as DialogActions } from './DialogActions'
export { default as ProgressDialog } from './ProgressDialog'
export { default as DlcSelectionDialog } from './DlcSelectionDialog'
export { default as AddDlcDialog } from './AddDlcDialog'
export { default as SettingsDialog } from './SettingsDialog'
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
export { default as ConflictDialog } from './ConflictDialog'
export { default as DisclaimerDialog } from './DisclaimerDialog'
export { default as UnlockerSelectionDialog} from './UnlockerSelectionDialog'
// Export types
export type { DialogProps } from './Dialog'
export type { DialogHeaderProps } from './DialogHeader'
export type { DialogBodyProps } from './DialogBody'
export type { DialogFooterProps } from './DialogFooter'
export type { DialogActionsProps } from './DialogActions'
export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog'
export type { DlcSelectionDialogProps } from './DlcSelectionDialog'
export type { AddDlcDialogProps } from './AddDlcDialog'
export type { ConflictDialogProps, Conflict } from './ConflictDialog'
export type { UnlockerSelectionDialogProps } from './UnlockerSelectionDialog'
+215
View File
@@ -0,0 +1,215 @@
import { useState, useEffect } from 'react'
import { findBestGameImage } from '@/services/ImageService'
import { Game } from '@/types'
import { ActionButton, ActionType, Button } from '@/components/buttons'
import { Icon } from '@/components/icons'
interface GameItemProps {
game: Game
onAction: (gameId: string, action: ActionType) => Promise<void>
onEdit?: (gameId: string) => void
onSmokeAPISettings?: (gameId: string) => void
}
/**
* Individual game card component
* Displays game information and action buttons
*/
const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps) => {
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
useEffect(() => {
// Function to fetch the game cover/image
const fetchGameImage = async () => {
// First check if we already have it (to prevent flickering on re-renders)
if (imageUrl) return
setIsLoading(true)
try {
// Try to find the best available image for this game
const bestImageUrl = await findBestGameImage(game.id)
if (bestImageUrl) {
setImageUrl(bestImageUrl)
setHasError(false)
} else {
setHasError(true)
}
} catch (error) {
console.error('Error fetching game image:', error)
setHasError(true)
} finally {
setIsLoading(false)
}
}
if (game.id) {
fetchGameImage()
}
}, [game.id, imageUrl])
// Determine if we should show CreamLinux buttons (only for native games)
const shouldShowCream = game.native && game.cream_installed // Only show if installed (for uninstall)
// Determine if we should show SmokeAPI buttons (only for non-native games with API files)
const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0
// Show generic button if nothing installed
const shouldShowUnlocker = game.native && !game.cream_installed && !game.smoke_installed
// Check if this is a Proton game without API files
const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0)
const handleCreamAction = () => {
if (game.installing) return
const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream'
onAction(game.id, action)
}
const handleSmokeAction = () => {
if (game.installing) return
const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'
onAction(game.id, action)
}
const handleUnlockerAction = () => {
if (game.installing) return
onAction(game.id, 'install_unlocker')
}
// Handle edit button click
const handleEdit = () => {
if (onEdit && game.cream_installed) {
onEdit(game.id)
}
}
// SmokeAPI settings handler
const handleSmokeAPISettings = () => {
if (onSmokeAPISettings && game.smoke_installed) {
onSmokeAPISettings(game.id)
}
}
// Determine background image
const backgroundImage =
!isLoading && imageUrl
? `url(${imageUrl})`
: hasError
? 'linear-gradient(135deg, #232323, #1A1A1A)'
: 'linear-gradient(135deg, #232323, #1A1A1A)'
return (
<div
className="game-item-card"
style={{
backgroundImage,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<div className="game-item-overlay">
<div className="game-badges">
<span className={`status-badge ${game.native ? 'native' : 'proton'}`}>
{game.native ? 'Native' : 'Proton'}
</span>
{game.cream_installed && <span className="status-badge cream">CreamLinux</span>}
{game.smoke_installed && <span className="status-badge smoke">SmokeAPI</span>}
</div>
<div className="game-title">
<h3>{game.title}</h3>
</div>
<div className="game-actions">
{/* Show generic "Install" button for native games with nothing installed */}
{shouldShowUnlocker && (
<ActionButton
action="install_unlocker"
isInstalled={false}
isWorking={!!game.installing}
onClick={handleUnlockerAction}
/>
)}
{/* Show CreamLinux uninstall button if CreamLinux is installed */}
{shouldShowCream && (
<ActionButton
action="uninstall_cream"
isInstalled={true}
isWorking={!!game.installing}
onClick={handleCreamAction}
/>
)}
{/* Show SmokeAPI button for Proton games OR native games with SmokeAPI installed */}
{shouldShowSmoke && (
<ActionButton
action={game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'}
isInstalled={!!game.smoke_installed}
isWorking={!!game.installing}
onClick={handleSmokeAction}
/>
)}
{/* Show SmokeAPI uninstall for native games if installed */}
{game.native && game.smoke_installed && (
<ActionButton
action="uninstall_smoke"
isInstalled={true}
isWorking={!!game.installing}
onClick={handleSmokeAction}
/>
)}
{/* Show message for Proton games without API files */}
{isProtonNoApi && (
<div className="api-not-found-message">
<span>Steam API DLL not found</span>
<Button
variant="warning"
size="small"
onClick={() => onAction(game.id, 'install_smoke')}
title="Attempt to scan again"
>
Rescan
</Button>
</div>
)}
{/* Edit button - only enabled if CreamLinux is installed */}
{game.cream_installed && (
<Button
variant="secondary"
size="small"
onClick={handleEdit}
disabled={!game.cream_installed || !!game.installing}
title="Manage DLCs"
className="edit-button settings-icon-button"
leftIcon={<Icon name="Settings" variant="solid" size="md" />}
iconOnly
/>
)}
{/* Edit button - only enabled if SmokeAPI is installed */}
{game.smoke_installed && (
<Button
variant="secondary"
size="small"
onClick={handleSmokeAPISettings}
disabled={!game.smoke_installed || !!game.installing}
title="Configure SmokeAPI"
className="edit-button settings-icon-button"
leftIcon={<Icon name="Settings" variant="solid" size="md" />}
iconOnly
/>
)}
</div>
</div>
</div>
)
}
export default GameItem
+68
View File
@@ -0,0 +1,68 @@
import { useState, useEffect, useMemo } from 'react'
import { GameItem, ImagePreloader } from '@/components/games'
import { ActionType } from '@/components/buttons'
import { Game } from '@/types'
import LoadingIndicator from '../common/LoadingIndicator'
interface GameListProps {
games: Game[]
isLoading: boolean
onAction: (gameId: string, action: ActionType) => Promise<void>
onEdit?: (gameId: string) => void
onSmokeAPISettings?: (gameId: string) => void
}
/**
* Main game list component
* Displays games in a grid with search and filtering applied
*/
const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings }: GameListProps) => {
const [imagesPreloaded, setImagesPreloaded] = useState(false)
// Sort games alphabetically by title
const sortedGames = useMemo(() => {
return [...games].sort((a, b) => a.title.localeCompare(b.title))
}, [games])
// Reset preloaded state when games change
useEffect(() => {
setImagesPreloaded(false)
}, [games])
const handlePreloadComplete = () => {
setImagesPreloaded(true)
}
if (isLoading) {
return (
<div className="game-list">
<LoadingIndicator type="spinner" size="large" message="Scanning for games..." />
</div>
)
}
return (
<div className="game-list">
<h2>Games ({games.length})</h2>
{!imagesPreloaded && games.length > 0 && (
<ImagePreloader
gameIds={sortedGames.map((game) => game.id)}
onComplete={handlePreloadComplete}
/>
)}
{games.length === 0 ? (
<div className="no-games-message">No games found</div>
) : (
<div className="game-grid">
{sortedGames.map((game) => (
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} onSmokeAPISettings={onSmokeAPISettings} />
))}
</div>
)}
</div>
)
}
export default GameList
+61
View File
@@ -0,0 +1,61 @@
import { useEffect } from 'react'
import { findBestGameImage } from '@/services/ImageService'
interface ImagePreloaderProps {
gameIds: string[]
onComplete?: () => void
}
/**
* Preloads game images to prevent flickering
* Only used internally by GameList component
*/
const ImagePreloader = ({ gameIds, onComplete }: ImagePreloaderProps) => {
useEffect(() => {
const preloadImages = async () => {
try {
// Only preload the first batch for performance (10 images max)
const batchToPreload = gameIds.slice(0, 10)
// Track loading progress
let loadedCount = 0
const totalImages = batchToPreload.length
// Load images in parallel
await Promise.allSettled(
batchToPreload.map(async (id) => {
await findBestGameImage(id)
loadedCount++
// If all images are loaded, call onComplete
if (loadedCount === totalImages && onComplete) {
onComplete()
}
})
)
// Fallback if Promise.allSettled doesn't trigger onComplete
if (onComplete) {
onComplete()
}
} catch (error) {
console.error('Error preloading images:', error)
// Continue even if there's an error
if (onComplete) {
onComplete()
}
}
}
if (gameIds.length > 0) {
preloadImages()
} else if (onComplete) {
onComplete()
}
}, [gameIds, onComplete])
// Invisible component that just handles preloading
return null
}
export default ImagePreloader
+4
View File
@@ -0,0 +1,4 @@
// Export all game components
export { default as GameList } from './GameList'
export { default as GameItem } from './GameItem'
export { default as ImagePreloader } from './ImagePreloader'
+159
View File
@@ -0,0 +1,159 @@
/**
* Icon component for displaying SVG icons with standardized properties
*/
import React from 'react'
// Import all icon variants
import * as StrokeIcons from './ui/stroke'
import * as SolidIcons from './ui/solid'
import * as BrandIcons from './brands'
export type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | number
export type IconVariant = 'solid' | 'stroke' | 'brand' | undefined
export type IconName = keyof typeof StrokeIcons | keyof typeof SolidIcons | keyof typeof BrandIcons
// Sets of icon names by type for determining default variants
const BRAND_ICON_NAMES = new Set(Object.keys(BrandIcons))
const STROKE_ICON_NAMES = new Set(Object.keys(StrokeIcons))
const SOLID_ICON_NAMES = new Set(Object.keys(SolidIcons))
export interface IconProps extends React.SVGProps<SVGSVGElement> {
/** Name of the icon to render */
name: IconName | string
/** Size of the icon */
size?: IconSize
/** Icon variant - solid, stroke, or brand */
variant?: IconVariant | string
/** Title for accessibility */
title?: string
/** Fill color (if not specified by the SVG itself) */
fillColor?: string
/** Stroke color (if not specified by the SVG itself) */
strokeColor?: string
/** Additional CSS class names */
className?: string
}
/**
* Convert size string to pixel value
*/
const getSizeValue = (size: IconSize): string => {
if (typeof size === 'number') return `${size}px`
const sizeMap: Record<string, string> = {
xs: '12px',
sm: '16px',
md: '24px',
lg: '32px',
xl: '48px',
}
return sizeMap[size] || sizeMap.md
}
/**
* Gets the icon component based on name and variant
*/
const getIconComponent = (
name: string,
variant: IconVariant | string
): React.ComponentType<React.SVGProps<SVGSVGElement>> | null => {
// Normalize variant to ensure it's a valid IconVariant
const normalizedVariant =
variant === 'solid' || variant === 'stroke' || variant === 'brand'
? (variant as IconVariant)
: undefined
// Try to get the icon from the specified variant
switch (normalizedVariant) {
case 'stroke':
return StrokeIcons[name as keyof typeof StrokeIcons] || null
case 'solid':
return SolidIcons[name as keyof typeof SolidIcons] || null
case 'brand':
return BrandIcons[name as keyof typeof BrandIcons] || null
default:
// If no variant specified, determine best default
if (BRAND_ICON_NAMES.has(name)) {
return BrandIcons[name as keyof typeof BrandIcons] || null
} else if (STROKE_ICON_NAMES.has(name)) {
return StrokeIcons[name as keyof typeof StrokeIcons] || null
} else if (SOLID_ICON_NAMES.has(name)) {
return SolidIcons[name as keyof typeof SolidIcons] || null
}
return null
}
}
/**
* Icon component
* Renders SVG icons with consistent sizing and styling
*/
const Icon: React.FC<IconProps> = ({
name,
size = 'md',
variant,
title,
fillColor,
strokeColor,
className = '',
...rest
}) => {
// Determine default variant based on icon type if no variant provided
let defaultVariant: IconVariant | string = variant
if (defaultVariant === undefined) {
if (BRAND_ICON_NAMES.has(name)) {
defaultVariant = 'brand'
} else {
defaultVariant = 'bold' // Default to bold for non-brand icons
}
}
// Get the icon component based on name and variant
let finalIconComponent = getIconComponent(name, defaultVariant)
let finalVariant = defaultVariant
// Try fallbacks if the icon doesn't exist in the requested variant
if (!finalIconComponent && defaultVariant !== 'outline') {
finalIconComponent = getIconComponent(name, 'outline')
finalVariant = 'outline'
}
if (!finalIconComponent && defaultVariant !== 'bold') {
finalIconComponent = getIconComponent(name, 'bold')
finalVariant = 'bold'
}
if (!finalIconComponent && defaultVariant !== 'brand') {
finalIconComponent = getIconComponent(name, 'brand')
finalVariant = 'brand'
}
// If still no icon found, return null
if (!finalIconComponent) {
console.warn(`Icon not found: ${name} (${defaultVariant})`)
return null
}
const sizeValue = getSizeValue(size)
const combinedClassName = `icon icon-${size} icon-${finalVariant} ${className}`.trim()
const IconComponentToRender = finalIconComponent
return (
<IconComponentToRender
className={combinedClassName}
width={sizeValue}
height={sizeValue}
fill={fillColor || 'currentColor'}
stroke={strokeColor || 'currentColor'}
role="img"
aria-hidden={!title}
aria-label={title}
{...rest}
/>
)
}
export default Icon
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.1.1 0 0 0-.07.03c-.18.33-.39.76-.53 1.09a16.1 16.1 0 0 0-4.8 0c-.14-.34-.35-.76-.54-1.09c-.01-.02-.04-.03-.07-.03c-1.5.26-2.93.71-4.27 1.33c-.01 0-.02.01-.03.02c-2.72 4.07-3.47 8.03-3.1 11.95c0 .02.01.04.03.05c1.8 1.32 3.53 2.12 5.24 2.65c.03.01.06 0 .07-.02c.4-.55.76-1.13 1.07-1.74c.02-.04 0-.08-.04-.09c-.57-.22-1.11-.48-1.64-.78c-.04-.02-.04-.08-.01-.11c.11-.08.22-.17.33-.25c.02-.02.05-.02.07-.01c3.44 1.57 7.15 1.57 10.55 0c.02-.01.05-.01.07.01c.11.09.22.17.33.26c.04.03.04.09-.01.11c-.52.31-1.07.56-1.64.78c-.04.01-.05.06-.04.09c.32.61.68 1.19 1.07 1.74c.03.01.06.02.09.01c1.72-.53 3.45-1.33 5.25-2.65c.02-.01.03-.03.03-.05c.44-4.53-.73-8.46-3.1-11.95c-.01-.01-.02-.02-.04-.02M8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.84 2.12-1.89 2.12m6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.83 2.12-1.89 2.12"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"/></svg>

After

Width:  |  Height:  |  Size: 679 B

+7
View File
@@ -0,0 +1,7 @@
// Bold variant icons
export { ReactComponent as Linux } from './linux.svg'
export { ReactComponent as Steam } from './steam.svg'
export { ReactComponent as Windows } from './windows.svg'
export { ReactComponent as Github } from './github.svg'
export { ReactComponent as Discord } from './discord.svg'
export { ReactComponent as Proton } from './proton.svg'
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M19.7 17.6c-.1-.2-.2-.4-.2-.6c0-.4-.2-.7-.5-1c-.1-.1-.3-.2-.4-.2c.6-1.8-.3-3.6-1.3-4.9c-.8-1.2-2-2.1-1.9-3.7c0-1.9.2-5.4-3.3-5.1c-3.6.2-2.6 3.9-2.7 5.2c0 1.1-.5 2.2-1.3 3.1c-.2.2-.4.5-.5.7c-1 1.2-1.5 2.8-1.5 4.3c-.2.2-.4.4-.5.6c-.1.1-.2.2-.2.3c-.1.1-.3.2-.5.3c-.4.1-.7.3-.9.7c-.1.3-.2.7-.1 1.1c.1.2.1.4 0 .7c-.2.4-.2.9 0 1.4c.3.4.8.5 1.5.6c.5 0 1.1.2 1.6.4c.5.3 1.1.5 1.7.5c.3 0 .7-.1 1-.2c.3-.2.5-.4.6-.7c.4 0 1-.2 1.7-.2c.6 0 1.2.2 2 .1c0 .1 0 .2.1.3c.2.5.7.9 1.3 1h.2c.8-.1 1.6-.5 2.1-1.1c.4-.4.9-.7 1.4-.9c.6-.3 1-.5 1.1-1c.1-.7-.1-1.1-.5-1.7M12.8 4.8c.6.1 1.1.6 1 1.2q0 .45-.3.9h-.1c-.2-.1-.3-.1-.4-.2c.1-.1.1-.3.2-.5c0-.4-.2-.7-.4-.7c-.3 0-.5.3-.5.7v.1c-.1-.1-.3-.1-.4-.2V6c-.1-.5.3-1.1.9-1.2m-.3 2c.1.1.3.2.4.2s.3.1.4.2c.2.1.4.2.4.5s-.3.6-.9.8c-.2.1-.3.1-.4.2c-.3.2-.6.3-1 .3c-.3 0-.6-.2-.8-.4c-.1-.1-.2-.2-.4-.3c-.1-.1-.3-.3-.4-.6c0-.1.1-.2.2-.3c.3-.2.4-.3.5-.4l.1-.1c.2-.3.6-.5 1-.5c.3.1.6.2.9.4M10.4 5c.4 0 .7.4.8 1.1v.2c-.1 0-.3.1-.4.2v-.2c0-.3-.2-.6-.4-.5c-.2 0-.3.3-.3.6c0 .2.1.3.2.4c0 0-.1.1-.2.1c-.2-.2-.4-.5-.4-.8c0-.6.3-1.1.7-1.1m-1 16.1c-.7.3-1.6.2-2.2-.2c-.6-.3-1.1-.4-1.8-.4c-.5-.1-1-.1-1.1-.3s-.1-.5.1-1q.15-.45 0-.9c-.1-.3-.1-.5 0-.8s.3-.4.6-.5s.5-.2.7-.4c.1-.1.2-.2.3-.4c.3-.4.5-.6.8-.6c.6.1 1.1 1 1.5 1.9c.2.3.4.7.7 1c.4.5.9 1.2.9 1.6c0 .5-.2.8-.5 1m4.9-2.2c0 .1 0 .1-.1.2c-1.2.9-2.8 1-4.1.3l-.6-.9c.9-.1.7-1.3-1.2-2.5c-2-1.3-.6-3.7.1-4.8c.1-.1.1 0-.3.8c-.3.6-.9 2.1-.1 3.2c0-.8.2-1.6.5-2.4c.7-1.3 1.2-2.8 1.5-4.3c.1.1.1.1.2.1c.1.1.2.2.3.2c.2.3.6.4.9.4h.1c.4 0 .8-.1 1.1-.4c.1-.1.2-.2.4-.2q.45-.15.9-.6c.4 1.3.8 2.5 1.4 3.6c.4.8.7 1.6.9 2.5c.3 0 .7.1 1 .3c.8.4 1.1.7 1 1.2H18c0-.3-.2-.6-.9-.9s-1.3-.3-1.5.4c-.1 0-.2.1-.3.1c-.8.4-.8 1.5-.9 2.6c.1.4 0 .7-.1 1.1m4.6.6c-.6.2-1.1.6-1.5 1.1c-.4.6-1.1 1-1.9.9c-.4 0-.8-.3-.9-.7c-.1-.6-.1-1.2.2-1.8c.1-.4.2-.7.3-1.1c.1-1.2.1-1.9.6-2.2c0 .5.3.8.7 1c.5 0 1-.1 1.4-.5h.2c.3 0 .5 0 .7.2s.3.5.3.7c0 .3.2.6.3.9c.5.5.5.8.5.9c-.1.2-.5.4-.9.6m-9-12c-.1 0-.1 0-.1.1c0 0 0 .1.1.1s.1.1.1.1c.3.4.8.6 1.4.7c.5-.1 1-.2 1.5-.6l.6-.3c.1 0 .1-.1.1-.1c0-.1 0-.1-.1-.1c-.2.1-.5.2-.7.3c-.4.3-.9.5-1.4.5s-.9-.3-1.2-.6c-.1 0-.2-.1-.3-.1"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 0 1 10 10a10 10 0 0 1-10 10c-4.6 0-8.45-3.08-9.64-7.27l3.83 1.58a2.84 2.84 0 0 0 2.78 2.27c1.56 0 2.83-1.27 2.83-2.83v-.13l3.4-2.43h.08c2.08 0 3.77-1.69 3.77-3.77s-1.69-3.77-3.77-3.77s-3.78 1.69-3.78 3.77v.05l-2.37 3.46l-.16-.01c-.59 0-1.14.18-1.59.49L2 11.2C2.43 6.05 6.73 2 12 2M8.28 17.17c.8.33 1.72-.04 2.05-.84s-.05-1.71-.83-2.04l-1.28-.53c.49-.18 1.04-.19 1.56.03c.53.21.94.62 1.15 1.15c.22.52.22 1.1 0 1.62c-.43 1.08-1.7 1.6-2.78 1.15c-.5-.21-.88-.59-1.09-1.04zm9.52-7.75c0 1.39-1.13 2.52-2.52 2.52a2.52 2.52 0 0 1-2.51-2.52a2.5 2.5 0 0 1 2.51-2.51a2.52 2.52 0 0 1 2.52 2.51m-4.4 0c0 1.04.84 1.89 1.89 1.89c1.04 0 1.88-.85 1.88-1.89s-.84-1.89-1.88-1.89c-1.05 0-1.89.85-1.89 1.89"/></svg>

After

Width:  |  Height:  |  Size: 820 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="m3.001 5.479l7.377-1.016v7.127H3zm0 13.042l7.377 1.017v-7.04H3zm8.188 1.125L21.001 21v-8.502h-9.812zm0-15.292v7.236h9.812V3z"/></svg>

After

Width:  |  Height:  |  Size: 245 B

+102
View File
@@ -0,0 +1,102 @@
// import { createIconComponent } from './IconFactory' <-- Broken atm
export { default as Icon } from './Icon'
export type { IconProps, IconSize, IconVariant, IconName } from './Icon'
// Re-export all icons by category for convenience
import * as StrokeIcons from './ui/stroke'
import * as SolidIcons from './ui/solid'
import * as BrandIcons from './brands'
export { StrokeIcons, SolidIcons, BrandIcons }
// Export individual icon names as constants
// UI icons
export const arrowUp = 'ArrowUp'
export const check = 'Check'
export const close = 'Close'
export const controller = 'Controller'
export const copy = 'Copy'
export const download = 'Download'
export const download1 = 'Download1'
export const edit = 'Edit'
export const error = 'Error'
export const info = 'Info'
export const layers = 'Layers'
export const refresh = 'Refresh'
export const search = 'Search'
export const trash = 'Trash'
export const warning = 'Warning'
export const wine = 'Wine'
export const diamond = 'Diamond'
export const settings = 'Settings'
// Brand icons
export const discord = 'Discord'
export const github = 'GitHub'
export const linux = 'Linux'
export const proton = 'Proton'
export const steam = 'Steam'
export const windows = 'Windows'
// Keep the IconNames object for backward compatibility and autocompletion
export const IconNames = {
// UI icons
ArrowUp: arrowUp,
Check: check,
Close: close,
Controller: controller,
Copy: copy,
Download: download,
Download1: download1,
Edit: edit,
Error: error,
Info: info,
Layers: layers,
Refresh: refresh,
Search: search,
Trash: trash,
Warning: warning,
Wine: wine,
Diamond: diamond,
Settings: settings,
// Brand icons
Discord: discord,
GitHub: github,
Linux: linux,
Proton: proton,
Steam: steam,
Windows: windows,
} as const
// Export direct icon components using createIconComponent from IconFactory
// UI icons (outline variant by default)
//export const ArrowUpIcon = createIconComponent(arrowUp, 'outline')
//export const CheckIcon = createIconComponent(check, 'outline')
//export const CloseIcon = createIconComponent(close, 'outline')
//export const ControllerIcon = createIconComponent(controller, 'outline')
//export const CopyIcon = createIconComponent(copy, 'outline')
//export const DownloadIcon = createIconComponent(download, 'outline')
//export const Download1Icon = createIconComponent(download1, 'outline')
//export const EditIcon = createIconComponent(edit, 'outline')
//export const ErrorIcon = createIconComponent(error, 'outline')
//export const InfoIcon = createIconComponent(info, 'outline')
//export const LayersIcon = createIconComponent(layers, 'outline')
//export const RefreshIcon = createIconComponent(refresh, 'outline')
//export const SearchIcon = createIconComponent(search, 'outline')
//export const TrashIcon = createIconComponent(trash, 'outline')
//export const WarningIcon = createIconComponent(warning, 'outline')
//export const WineIcon = createIconComponent(wine, 'outline')
// Brand icons
//export const DiscordIcon = createIconComponent(discord, 'brand')
//export const GitHubIcon = createIconComponent(github, 'brand')
//export const LinuxIcon = createIconComponent(linux, 'brand')
//export const SteamIcon = createIconComponent(steam, 'brand')
//export const WindowsIcon = createIconComponent(windows, 'brand')
// Bold variants for common icons
//export const CheckBoldIcon = createIconComponent(check, 'bold')
//export const InfoBoldIcon = createIconComponent(info, 'bold')
//export const WarningBoldIcon = createIconComponent(warning, 'bold')
//export const ErrorBoldIcon = createIconComponent(error, 'bold')
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
<path d="M12.9999 19.002C12.9999 19.5542 12.5522 20.002 11.9999 20.002C11.4476 20.0019 10.9999 19.5542 10.9999 19.002V6.74121C10.4264 7.25563 9.78116 7.94409 9.16198 8.65723C8.52607 9.38966 7.93619 10.1256 7.50378 10.6797C7.2881 10.9561 6.86131 11.5134 6.74011 11.6738C6.39996 12.0494 5.82401 12.1144 5.4071 11.8076C4.9626 11.4802 4.86709 10.8538 5.19421 10.4092C5.32082 10.2415 5.70349 9.73516 5.92663 9.44922C6.37213 8.87836 6.9859 8.11413 7.65222 7.34668C8.31419 6.58424 9.04804 5.79591 9.73327 5.19043C10.0746 4.8888 10.4273 4.61078 10.7714 4.40332C11.0881 4.2124 11.5238 4.00198 11.9999 4.00195L12.1766 4.01172C12.5829 4.05466 12.9504 4.23637 13.2274 4.40332C13.5716 4.61079 13.925 4.88871 14.2665 5.19043C14.9517 5.7959 15.6856 6.58427 16.3475 7.34668C17.0138 8.1141 17.6276 8.87836 18.0731 9.44922C18.2962 9.73513 18.6789 10.2415 18.8055 10.4092C19.1327 10.8538 19.0372 11.4792 18.5926 11.8066C18.1757 12.1137 17.5999 12.0496 17.2596 11.6738C17.2596 11.6738 17.0692 11.4269 17.0087 11.3467C16.8875 11.1862 16.7117 10.9561 16.496 10.6797C16.0635 10.1256 15.4737 9.38965 14.8378 8.65723C14.2185 7.94406 13.5734 7.25564 12.9999 6.74121V19.002Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.6905 5.77665C20.09 6.15799 20.1047 6.79098 19.7234 7.19048L9.22336 18.1905C9.03745 18.3852 8.78086 18.4968 8.51163 18.4999C8.2424 18.5031 7.98328 18.3975 7.79289 18.2071L4.29289 14.7071C3.90237 14.3166 3.90237 13.6834 4.29289 13.2929C4.68342 12.9024 5.31658 12.9024 5.70711 13.2929L8.48336 16.0692L18.2766 5.80953C18.658 5.41003 19.291 5.39531 19.6905 5.77665Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 560 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
<path d="M17.293 5.29295C17.6835 4.90243 18.3165 4.90243 18.707 5.29295C19.0976 5.68348 19.0976 6.31649 18.707 6.70702L13.4131 12L18.7061 17.293L18.7754 17.3691C19.0954 17.7619 19.0721 18.341 18.7061 18.707C18.3399 19.0731 17.7609 19.0958 17.3682 18.7754L17.292 18.707L11.999 13.414L6.70802 18.706C6.3175 19.0966 5.68449 19.0965 5.29396 18.706C4.90344 18.3155 4.90344 17.6825 5.29396 17.292L10.585 12L5.29298 6.70799L5.22462 6.63182C4.90423 6.23907 4.92691 5.66007 5.29298 5.29393C5.65897 4.92794 6.23811 4.9046 6.63087 5.22459L6.70705 5.29393L11.999 10.5859L17.293 5.29295Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 721 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.9319 3.87827C8.06823 4.41346 7.74489 4.95784 7.2097 5.09417L5.24685 5.59417C4.71165 5.7305 4.16728 5.40716 4.03095 4.87197C3.89461 4.33677 4.21796 3.79239 4.75315 3.65606L6.716 3.15606C7.25119 3.01973 7.79557 3.34308 7.9319 3.87827ZM16.0299 3.88258C16.1638 3.34679 16.7067 3.02103 17.2425 3.15498L19.2425 3.65498C19.7783 3.78892 20.1041 4.33186 19.9701 4.86765C19.8362 5.40345 19.2933 5.72921 18.7575 5.59526L16.7575 5.09526C16.2217 4.96131 15.8959 4.41838 16.0299 3.88258Z" fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.92509 6.17612C9.34102 5.1078 14.659 5.1078 19.0749 6.17612C20.1031 6.42487 20.8958 7.16741 21.2589 8.13694C21.8432 9.697 22.5131 12.3264 22.7403 15.8863C22.9054 18.4745 20.9807 19.8307 19.2803 20.6872C18.8223 20.9179 18.3491 20.9286 17.9171 20.7592C17.5185 20.603 17.2028 20.3134 16.96 20.025C16.4777 19.4522 16.1003 18.6588 15.8299 18.0493C15.6563 17.6579 15.2779 17.4105 14.8281 17.4105H9.17196C8.72218 17.4105 8.34378 17.6579 8.17012 18.0493C7.89974 18.6588 7.52233 19.4522 7.04 20.025C6.79722 20.3134 6.48151 20.603 6.08295 20.7592C5.6509 20.9286 5.17774 20.9179 4.7197 20.6872C3.03995 19.8411 1.09341 18.4935 1.25978 15.8863C1.48693 12.3264 2.15683 9.697 2.74109 8.13694C3.10419 7.16741 3.89689 6.42487 4.92509 6.17612ZM9.70691 9.41777C10.0974 9.8083 10.0974 10.4415 9.70691 10.832L8.91401 11.6249L9.70691 12.4178C10.0974 12.8083 10.0974 13.4415 9.70691 13.832C9.31638 14.2225 8.68322 14.2225 8.2927 13.832L7.4998 13.0391L6.70691 13.832C6.31638 14.2225 5.68322 14.2225 5.2927 13.832C4.90217 13.4415 4.90217 12.8083 5.2927 12.4178L6.08559 11.6249L5.2927 10.832C4.90217 10.4415 4.90217 9.8083 5.2927 9.41777C5.68322 9.02725 6.31638 9.02725 6.70691 9.41777L7.4998 10.2107L8.29269 9.41777C8.68322 9.02725 9.31638 9.02725 9.70691 9.41777ZM15.9971 11.1249H15.9881C15.4358 11.1249 14.9881 10.6772 14.9881 10.1249C14.9881 9.57259 15.4358 9.12488 15.9881 9.12488H15.9971C16.5493 9.12488 16.9971 9.57259 16.9971 10.1249C16.9971 10.6772 16.5493 11.1249 15.9971 11.1249ZM16.9881 13.1249C16.9881 12.5726 17.4358 12.1249 17.9881 12.1249H17.9971C18.5493 12.1249 18.9971 12.5726 18.9971 13.1249C18.9971 13.6772 18.5493 14.1249 17.9971 14.1249H17.9881C17.4358 14.1249 16.9881 13.6772 16.9881 13.1249Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
<path d="M6.74994 14.8569C6.74985 13.5627 6.74977 12.3758 6.87984 11.4084C7.02314 10.3425 7.36028 9.21504 8.28763 8.28769C9.21498 7.36034 10.3425 7.0232 11.4083 6.8799C12.3758 6.74983 13.5627 6.74991 14.8569 6.75L17.0931 6.75C17.3891 6.75 17.5371 6.75 17.6261 6.65419C17.7151 6.55838 17.7045 6.4142 17.6832 6.12584C17.6648 5.87546 17.6412 5.63892 17.6111 5.41544C17.4818 4.45589 17.2231 3.6585 16.6717 2.98663C16.4744 2.74612 16.2538 2.52558 16.0133 2.3282C15.3044 1.74638 14.4557 1.49055 13.4247 1.36868C12.4205 1.24998 11.1511 1.24999 9.54887 1.25H9.45103C7.84877 1.24999 6.57941 1.24998 5.57519 1.36868C4.54422 1.49054 3.69552 1.74638 2.98657 2.3282C2.74606 2.52558 2.52552 2.74612 2.32814 2.98663C1.74632 3.69558 1.49048 4.54428 1.36862 5.57525C1.24992 6.57947 1.24993 7.84882 1.24994 9.45108V9.54891C1.24993 11.1512 1.24992 12.4205 1.36862 13.4247C1.49048 14.4557 1.74632 15.3044 2.32814 16.0134C2.52552 16.2539 2.74606 16.4744 2.98657 16.6718C3.65844 17.2232 4.45583 17.4818 5.41538 17.6111C5.63886 17.6412 5.8754 17.6648 6.12578 17.6833C6.41414 17.7045 6.55831 17.7151 6.65413 17.6261C6.74994 17.5371 6.74994 17.3891 6.74994 17.0931V14.8569Z" fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.5535 9.09108C12.6229 9.62041 12.25 10.1058 11.7207 10.1752C11.0847 10.2586 10.7687 10.3983 10.5613 10.5688C10.3784 10.7193 10.2497 10.9156 10.1643 11.3404C10.0592 11.8638 9.54968 12.2029 9.02627 12.0978C8.50285 11.9927 8.16377 11.4831 8.2689 10.9597C8.41673 10.2237 8.71907 9.58093 9.33313 9.07578C9.93546 8.58028 10.6592 8.36455 11.4693 8.25831C11.9987 8.1889 12.4841 8.56174 12.5535 9.09108ZM18.4463 9.09108C18.5157 8.56174 19.0011 8.1889 19.5304 8.25831C20.3405 8.36455 21.0643 8.58028 21.6666 9.07578C22.2807 9.58093 22.583 10.2237 22.7308 10.9597C22.836 11.4831 22.4969 11.9927 21.9735 12.0978C21.4501 12.2029 20.9405 11.8638 20.8354 11.3404C20.7501 10.9156 20.6213 10.7193 20.4384 10.5688C20.2311 10.3983 19.915 10.2586 19.279 10.1752C18.7497 10.1058 18.3769 9.62041 18.4463 9.09108ZM13.0832 9.21676C13.0832 8.68289 13.516 8.25011 14.0499 8.25011H16.9498C17.4837 8.25011 17.9165 8.68289 17.9165 9.21676C17.9165 9.75063 17.4837 10.1834 16.9498 10.1834H14.0499C13.516 10.1834 13.0832 9.75063 13.0832 9.21676ZM9.21663 13.0834C9.75049 13.0834 10.1833 13.5162 10.1833 14.05V16.95C10.1833 17.4838 9.75049 17.9166 9.21663 17.9166C8.68276 17.9166 8.24997 17.4838 8.24997 16.95V14.05C8.24997 13.5162 8.68276 13.0834 9.21663 13.0834ZM21.7831 13.0834C22.317 13.0834 22.7498 13.5162 22.7498 14.05V16.95C22.7498 17.4838 22.317 17.9166 21.7831 17.9166C21.2492 17.9166 20.8165 17.4838 20.8165 16.95V14.05C20.8165 13.5162 21.2492 13.0834 21.7831 13.0834ZM9.02627 18.9022C9.54968 18.7971 10.0592 19.1362 10.1643 19.6596C10.2497 20.0844 10.3784 20.2807 10.5613 20.4312C10.7687 20.6017 11.0847 20.7414 11.7207 20.8248C12.25 20.8942 12.6229 21.3796 12.5535 21.9089C12.4841 22.4383 11.9987 22.8111 11.4693 22.7417C10.6592 22.6355 9.93546 22.4197 9.33313 21.9242C8.71906 21.4191 8.41673 20.7763 8.2689 20.0403C8.16377 19.5169 8.50285 19.0073 9.02627 18.9022ZM21.9735 18.9022C22.4969 19.0073 22.836 19.5169 22.7308 20.0403C22.583 20.7763 22.2807 21.4191 21.6666 21.9242C21.0643 22.4197 20.3405 22.6355 19.5304 22.7417C19.0011 22.8111 18.5157 22.4383 18.4463 21.9089C18.3769 21.3796 18.7497 20.8942 19.279 20.8248C19.915 20.7414 20.2311 20.6017 20.4384 20.4312C20.6213 20.2807 20.7501 20.0844 20.8354 19.6596C20.9405 19.1362 21.45 18.7971 21.9735 18.9022ZM13.0832 21.7832C13.0832 21.2494 13.516 20.8166 14.0499 20.8166H16.9498C17.4837 20.8166 17.9165 21.2494 17.9165 21.7832C17.9165 22.3171 17.4837 22.7499 16.9498 22.7499H14.0499C13.516 22.7499 13.0832 22.3171 13.0832 21.7832Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.32289 2.25003L9.23265 2.25001C8.47304 2.24984 7.96465 2.24972 7.47672 2.36883C7.25578 2.42277 7.03998 2.49476 6.83189 2.58414C6.37 2.78253 5.97535 3.08651 5.39532 3.53329L5.32472 3.58766L5.29273 3.61228C4.24651 4.41768 3.41746 5.0559 2.80797 5.62799C2.1851 6.21262 1.72591 6.7865 1.48506 7.48693C1.28734 8.06193 1.21295 8.66844 1.26722 9.27182C1.33356 10.0094 1.64582 10.6679 2.11451 11.3677C2.57202 12.0509 3.22764 12.8427 4.05236 13.8386L4.05239 13.8387L4.05242 13.8387L8.11023 18.7392L8.11025 18.7392L8.11026 18.7392C8.84673 19.6287 9.45218 20.3599 10.0079 20.8609C10.5914 21.3869 11.2168 21.75 12 21.75C12.7832 21.75 13.4086 21.3869 13.9921 20.8609C14.5478 20.3599 15.1533 19.6287 15.8898 18.7392L19.9476 13.8386C20.7724 12.8427 21.428 12.0509 21.8855 11.3677C22.3542 10.6679 22.6664 10.0094 22.7328 9.27182C22.7871 8.66844 22.7127 8.06193 22.5149 7.48693C22.2741 6.7865 21.8149 6.21262 21.192 5.62799C20.5825 5.05591 19.7535 4.41769 18.7073 3.6123L18.7072 3.61226L18.6753 3.58766L18.6047 3.53329C18.0246 3.08651 17.63 2.78253 17.1681 2.58414C16.96 2.49476 16.7442 2.42277 16.5233 2.36883C16.0353 2.24972 15.527 2.24984 14.7673 2.25001L14.6771 2.25003H9.32289ZM10 7.75C9.58579 7.75 9.25 8.08579 9.25 8.5C9.25 8.91421 9.58579 9.25 10 9.25H14C14.4142 9.25 14.75 8.91421 14.75 8.5C14.75 8.08579 14.4142 7.75 14 7.75H10Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
<path d="M16.1439 10.8544C15.7604 10.7888 15.2902 10.7658 14.7504 10.7567V4.99991C14.7504 4.5833 14.7563 4.22799 14.6732 3.91788C14.4652 3.1414 13.8589 2.5351 13.0824 2.32706C12.7723 2.24399 12.417 2.24991 12.0004 2.24991C11.5838 2.24991 11.2285 2.244 10.9183 2.32706C10.1419 2.5351 9.53459 3.1414 9.32654 3.91788C9.24355 4.22794 9.25037 4.5834 9.25037 4.99991V10.7567C8.71056 10.7658 8.24038 10.7888 7.85681 10.8544C7.344 10.9421 6.77397 11.1384 6.46033 11.6796L6.40174 11.7929L6.35193 11.9081C6.08178 12.5976 6.3948 13.2355 6.73279 13.7284C7.07715 14.2305 7.6246 14.832 8.28226 15.5546L8.31873 15.5946C9.03427 16.3808 9.62531 17.0262 10.1595 17.4687C10.7074 17.9223 11.2882 18.2426 11.9926 18.2499H12.0082C12.7125 18.2426 13.2934 17.9223 13.8412 17.4687C14.3754 17.0262 14.9665 16.3808 15.682 15.5946L15.7185 15.5546C16.3761 14.832 16.9236 14.2305 17.2679 13.7284C17.6059 13.2355 17.919 12.5976 17.6488 11.9081L17.599 11.7929L17.5404 11.6796C17.2268 11.1384 16.6567 10.9421 16.1439 10.8544Z" fill="currentColor" />
<path d="M18.75 19.7499C19.3023 19.7499 19.75 20.1976 19.75 20.7499C19.75 21.3022 19.3023 21.7499 18.75 21.7499H5.25C4.69772 21.7499 4.25 21.3022 4.25 20.7499C4.25 20.1976 4.69772 19.7499 5.25 19.7499H18.75Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
<path d="M18.799 3.0499C17.7324 1.98335 16.0032 1.98337 14.9366 3.04994L13.5236 4.46296L19.537 10.4763L20.9501 9.06321C22.0167 7.99665 22.0166 6.26746 20.9501 5.20092L18.799 3.0499Z" fill="currentColor" />
<path d="M18.4764 11.537L12.463 5.52363L4.35808 13.6286C3.66361 14.3231 3.20349 15.2172 3.04202 16.1859L2.26021 20.8767C2.22039 21.1156 2.29841 21.3591 2.46968 21.5303C2.64095 21.7016 2.88439 21.7796 3.12331 21.7398L7.81417 20.958C8.78294 20.7965 9.67706 20.3364 10.3715 19.642L18.4764 11.537Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 650 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
<path d="M12 1.25C17.9371 1.25 22.75 6.06294 22.75 12C22.75 17.9371 17.9371 22.75 12 22.75C6.06294 22.75 1.25 17.9371 1.25 12C1.25 6.06294 6.06294 1.25 12 1.25ZM12 14.9883C11.4477 14.9883 11 15.436 11 15.9883V15.998C11 16.5503 11.4477 16.998 12 16.998C12.5523 16.998 13 16.5503 13 15.998V15.9883C13 15.436 12.5523 14.9883 12 14.9883ZM12 7C11.4477 7 11 7.44772 11 8V12.5C11 13.0523 11.4477 13.5 12 13.5C12.5523 13.5 13 13.0523 13 12.5V8C13 7.44772 12.5523 7 12 7Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 609 B

+18
View File
@@ -0,0 +1,18 @@
// Solid variant icons
export { ReactComponent as ArrowUp } from './arrow-up.svg'
export { ReactComponent as Check } from './check.svg'
export { ReactComponent as Close } from './close.svg'
export { ReactComponent as Controller } from './controller.svg'
export { ReactComponent as Copy } from './copy.svg'
export { ReactComponent as Diamond } from './diamond.svg'
export { ReactComponent as Download } from './download.svg'
export { ReactComponent as Edit } from './edit.svg'
export { ReactComponent as Error } from './error.svg'
export { ReactComponent as Info } from './info.svg'
export { ReactComponent as Layers } from './layers.svg'
export { ReactComponent as Refresh } from './refresh.svg'
export { ReactComponent as Search } from './search.svg'
export { ReactComponent as Settings } from './settings.svg'
export { ReactComponent as Trash } from './trash.svg'
export { ReactComponent as Warning } from './warning.svg'
export { ReactComponent as Wine } from './wine.svg'
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
<path d="M12 1.25C17.9371 1.25 22.75 6.06294 22.75 12C22.75 17.9371 17.9371 22.75 12 22.75C6.06294 22.75 1.25 17.9371 1.25 12C1.25 6.06294 6.06294 1.25 12 1.25ZM12 10.5C11.4477 10.5 11 10.9477 11 11.5V16C11 16.5523 11.4477 17 12 17C12.5523 17 13 16.5523 13 16V11.5C13 10.9477 12.5523 10.5 12 10.5ZM12 6.99805C11.4477 6.99805 11 7.44576 11 7.99805V8.00781C11 8.5601 11.4477 9.00781 12 9.00781C12.5523 9.00781 13 8.5601 13 8.00781V7.99805C13 7.44576 12.5523 6.99805 12 6.99805Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 622 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
<path d="M9.43531 4.06585C10.4757 3.58724 11.2087 3.25 12 3.25C12.7913 3.25 13.5243 3.58724 14.5647 4.06585L19.6573 6.40525C20.513 6.79828 21.2323 7.12867 21.731 7.45333C22.2326 7.77985 22.75 8.25262 22.75 9C22.75 9.74738 22.2326 10.2202 21.731 10.5467C21.2323 10.8713 20.513 11.2017 19.6573 11.5948L14.5647 13.9341C13.5244 14.4128 12.7913 14.75 12 14.75C11.2087 14.75 10.4757 14.4128 9.43532 13.9342L9.4353 13.9341L4.3427 11.5947L4.34269 11.5947C3.487 11.2017 2.76767 10.8713 2.26898 10.5467C1.76745 10.2202 1.25 9.74738 1.25 9C1.25 8.25262 1.76745 7.77985 2.26898 7.45333C2.76767 7.12867 3.48701 6.79827 4.34271 6.40525L9.43531 4.06585Z" fill="currentColor" />
<path d="M3.43379 12.8281C2.97382 13.0479 2.57882 13.2518 2.26898 13.4535C1.76745 13.7801 1.25 14.2528 1.25 15.0002C1.25 15.7476 1.76745 16.2204 2.26898 16.5469C2.76766 16.8715 3.48698 17.2019 4.34265 17.5949L9.4353 19.9344C10.4756 20.413 11.2087 20.7502 12 20.7502C12.7913 20.7502 13.5244 20.413 14.5647 19.9344L19.6573 17.595C20.513 17.2019 21.2323 16.8715 21.731 16.5469C22.2326 16.2204 22.75 15.7476 22.75 15.0002C22.75 14.2528 22.2326 13.7801 21.731 13.4535C21.4212 13.2518 21.0262 13.0479 20.5662 12.8281C20.499 12.8591 20.4317 12.8899 20.3646 12.9208L15.0478 15.3634C14.1346 15.785 13.1268 16.2502 12 16.2502C10.8732 16.2502 9.86543 15.785 8.95222 15.3634L3.63539 12.9208C3.56827 12.8899 3.50101 12.8591 3.43379 12.8281Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
<path d="M2.25 12C2.25 6.61522 6.61521 2.25 12 2.25C15.1908 2.25 18.0209 3.78363 19.799 6.15095L19.8 6.15V3.225C19.8 2.68652 20.2365 2.25 20.775 2.25C21.3135 2.25 21.75 2.68652 21.75 3.225V6.15C21.75 6.81187 21.7524 7.40649 21.6881 7.88481C21.62 8.39116 21.4614 8.91039 21.0359 9.33589C20.6104 9.76139 20.0912 9.92002 19.5848 9.98811C19.1065 10.0524 18.5119 10.05 17.85 10.05H14.925C14.3865 10.05 13.95 9.61348 13.95 9.075C13.95 8.53652 14.3865 8.1 14.925 8.1H17.85C18.2154 8.1 18.5087 8.09857 18.7507 8.09238C17.4002 5.76433 14.8824 4.2 12 4.2C7.69217 4.2 4.2 7.69217 4.2 12C4.2 16.3078 7.69217 19.8 12 19.8C15.3946 19.8 18.285 17.631 19.3563 14.6003C19.5357 14.0926 20.093 13.8267 20.6007 14.0062C21.1082 14.1857 21.3742 14.7421 21.1949 15.2497C19.8569 19.0352 16.2467 21.75 12 21.75C6.61521 21.75 2.25 17.3848 2.25 12Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 968 B

Some files were not shown because too many files have changed in this diff Show More