├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug.yml
│ ├── config.yml
│ └── feature-request.yml
└── workflows
│ ├── Release.yml
│ └── SwiftLint.yml
├── .gitignore
├── .swiftlint.yml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── Libraries
└── cabextract
├── README.md
├── Whisky.xcodeproj
├── project.pbxproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm
│ │ └── Package.resolved
└── xcshareddata
│ ├── IDETemplateMacros.plist
│ └── xcschemes
│ ├── Whisky.xcscheme
│ ├── WhiskyCmd.xcscheme
│ └── WhiskyThumbnail.xcscheme
├── Whisky
├── AppDelegate.swift
├── Assets.xcassets
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── 128@2x.png
│ │ ├── 128R128x1.png
│ │ ├── 16@2x.png
│ │ ├── 16R64x1.png
│ │ ├── 256@2x.png
│ │ ├── 256R256x1.png
│ │ ├── 32@2x.png
│ │ ├── 32R64x1.png
│ │ ├── 512@2x.png
│ │ ├── 512R512x1.png
│ │ └── Contents.json
│ └── Contents.json
├── Extensions
│ ├── Animation+Extensions.swift
│ └── Bottle+Extensions.swift
├── Info.plist
├── Localizable.xcstrings
├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
├── Utils
│ ├── Constants.swift
│ ├── ProgramShortcut.swift
│ ├── WhiskyCmd.swift
│ └── Winetricks.swift
├── View Models
│ └── BottleVM.swift
├── Views
│ ├── Bottle
│ │ ├── BottleCreationView.swift
│ │ ├── BottleListEntry.swift
│ │ ├── BottleView.swift
│ │ ├── ConfigView.swift
│ │ ├── Pins
│ │ │ ├── PinAddView.swift
│ │ │ ├── PinCreationView.swift
│ │ │ └── PinView.swift
│ │ ├── RunningProcessesView.swift
│ │ └── WinetricksView.swift
│ ├── Common
│ │ ├── ActionView.swift
│ │ ├── BottomBar.swift
│ │ └── RenameView.swift
│ ├── ContentView.swift
│ ├── FileOpenView.swift
│ ├── Programs
│ │ ├── EnvironmentArgView.swift
│ │ ├── ProgramMenuView.swift
│ │ ├── ProgramView.swift
│ │ └── ProgramsView.swift
│ ├── Settings
│ │ └── SettingsView.swift
│ ├── Setup
│ │ ├── RosettaView.swift
│ │ ├── SetupView.swift
│ │ ├── WelcomeView.swift
│ │ ├── WhiskyWineDownloadView.swift
│ │ └── WhiskyWineInstallView.swift
│ ├── SparkleView.swift
│ └── WhiskyApp.swift
└── Whisky.entitlements
├── WhiskyCmd
└── Main.swift
├── WhiskyKit
├── .gitignore
├── .swiftpm
│ └── xcode
│ │ └── package.xcworkspace
│ │ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
├── Package.resolved
├── Package.swift
└── Sources
│ └── WhiskyKit
│ ├── BitmapInfo.swift
│ ├── Extensions
│ ├── Bundle+Extensions.swift
│ ├── FileHandle+Extensions.swift
│ ├── FileManager+Extensions.swift
│ ├── Logger+Extensions.swift
│ ├── Process+Extensions.swift
│ ├── Program+Extensions.swift
│ └── URL+Extensions.swift
│ ├── PE
│ ├── COFFFileHeader.swift
│ ├── Magic.swift
│ ├── OptionalHeader.swift
│ ├── PortableExecutable.swift
│ ├── RSRC
│ │ ├── ResourceDataEntry.swift
│ │ ├── ResourceDirectoryEntry.swift
│ │ ├── ResourceDirectoryTable.swift
│ │ └── ResourceType.swift
│ └── Section.swift
│ ├── ShellLink.swift
│ ├── Tar.swift
│ ├── Utils
│ └── Rosetta2.swift
│ ├── Whisky
│ ├── Bottle.swift
│ ├── BottleData.swift
│ ├── BottleSettings.swift
│ ├── Program.swift
│ └── ProgramSettings.swift
│ ├── WhiskyWine
│ └── WhiskyWineInstaller.swift
│ └── Wine
│ └── Wine.swift
├── WhiskyThumbnail
├── Icons.xcassets
│ ├── Contents.json
│ └── Icon.imageset
│ │ ├── 512R512x1.png
│ │ └── Contents.json
├── Info.plist
├── ThumbnailProvider.swift
└── WhiskyThumbnail.entitlements
├── crowdin.yml
└── images
├── cw-dark.png
└── cw-light.png
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | ko_fi: isaacmarovitz
4 | custom: ["https://ko-fi.com/gcenx", "https://www.codeweavers.com/store?ad=1010"]
5 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: Report a bug.
3 | type: "Bug"
4 | body:
5 | - type: textarea
6 | id: description
7 | attributes:
8 | label: Description
9 | description: Please provide a description of the bug.
10 | validations:
11 | required: true
12 | - type: textarea
13 | id: reproduce
14 | attributes:
15 | label: Steps to reproduce
16 | description: Please provide detailed step-by-step instructions on how to reproduce this issue.
17 | validations:
18 | required: true
19 | - type: textarea
20 | id: expected-behaviour
21 | attributes:
22 | label: Expected behaviour
23 | description: Please provide a clear a detailed description of what you expected to happen.
24 | validations:
25 | required: true
26 | - type: textarea
27 | id: logs
28 | attributes:
29 | label: Logs
30 | description: Please provide the logs from the launched Wine process. These can be found by pressing `CMD + L` in Whisky.
31 | render: bash
32 | validations:
33 | required: true
34 | - type: dropdown
35 | id: whisky-version
36 | attributes:
37 | label: What version of Whisky are you using?
38 | options:
39 | - "2.3.2"
40 | - "<2.3.2"
41 | validations:
42 | required: true
43 | - type: dropdown
44 | id: mac-version
45 | attributes:
46 | label: What version of macOS are you using?
47 | options:
48 | - "Sonoma (macOS 14)"
49 | validations:
50 | required: true
51 | - type: checkboxes
52 | attributes:
53 | label: Issue Language
54 | description: All issues must be written in clear plain English so that all devs are able to read them.
55 | options:
56 | - label: Yes my issue is written in English
57 | required: true
58 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | - name: Discord Support
3 | url: https://discord.gg/K4AQukwAke
4 | about: Please ask for support first to confirm the issue before submitting a report.
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request.yml:
--------------------------------------------------------------------------------
1 | name: Feature Request
2 | description: Request a new feature.
3 | type: "Enhancement"
4 | body:
5 | - type: textarea
6 | id: related-to-problem
7 | attributes:
8 | label: Is your feature request related to a problem?
9 | description: A clear and concise description of what the problem is.
10 | validations:
11 | required: true
12 | - type: textarea
13 | id: solution
14 | attributes:
15 | label: Describe the solution you'd like
16 | description: A clear and concise description of what you're suggesting.
17 | validations:
18 | required: true
19 | - type: textarea
20 | id: extra-info
21 | attributes:
22 | label: Anything else?
23 | description: Upload any relevant images, links, screenshots that might help realise your suggestion.
24 | validations:
25 | required: true
26 | - type: checkboxes
27 | attributes:
28 | label: Issue Language
29 | description: All issues must be written in clear plain English so that all devs are able to read them.
30 | options:
31 | - label: Yes my issue is written in English
32 | required: true
33 |
--------------------------------------------------------------------------------
/.github/workflows/Release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | release:
9 | name: Release
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Create Discord Embed
13 | uses: tsickert/discord-webhook@v5.3.0
14 | with:
15 | webhook-url: ${{ secrets.WEBHOOK }}
16 | embed-title: ${{ github.event.release.name }}
17 | embed-url: https://github.com/Whisky-App/Whisky/releases/download/${{ github.event.release.tag_name }}/Whisky.zip
18 | embed-description: ${{ github.event.release.body }}
19 | embed-color: 9442302
20 |
21 | - name: Update Homebrew Cask
22 | uses: macauley/action-homebrew-bump-cask@v1
23 | with:
24 | token: ${{secrets.BREW_TOKEN}}
25 | org: Whisky-App
26 | cask: whisky
27 |
--------------------------------------------------------------------------------
/.github/workflows/SwiftLint.yml:
--------------------------------------------------------------------------------
1 | name: SwiftLint
2 |
3 | concurrency:
4 | group: pr-checks-${{ github.event.number }}
5 | cancel-in-progress: true
6 |
7 | on:
8 | pull_request:
9 | push:
10 | workflow_dispatch:
11 |
12 | jobs:
13 | SwiftLint:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v3
17 | - name: SwiftLint
18 | uses: norio-nomura/action-swiftlint@3.2.1
19 | with:
20 | args: --strict
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | .DS_Store
6 |
7 | ## User settings
8 | xcuserdata/
9 |
10 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
11 | *.xcscmblueprint
12 | *.xccheckout
13 |
14 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
15 | build/
16 | DerivedData/
17 | *.moved-aside
18 | *.pbxuser
19 | !default.pbxuser
20 | *.mode1v3
21 | !default.mode1v3
22 | *.mode2v3
23 | !default.mode2v3
24 | *.perspectivev3
25 | !default.perspectivev3
26 |
27 | ## Obj-C/Swift specific
28 | *.hmap
29 |
30 | ## App packaging
31 | *.ipa
32 | *.dSYM.zip
33 | *.dSYM
34 |
35 | ## Playgrounds
36 | timeline.xctimeline
37 | playground.xcworkspace
38 |
39 | # Swift Package Manager
40 | #
41 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
42 | # Packages/
43 | # Package.pins
44 | # Package.resolved
45 | # *.xcodeproj
46 | #
47 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
48 | # hence it is not needed unless you have added a package configuration file to your project
49 | # .swiftpm
50 |
51 | .build/
52 |
53 | # CocoaPods
54 | #
55 | # We recommend against adding the Pods directory to your .gitignore. However
56 | # you should judge for yourself, the pros and cons are mentioned at:
57 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
58 | #
59 | # Pods/
60 | #
61 | # Add this line if you want to avoid checking in source code from the Xcode workspace
62 | # *.xcworkspace
63 |
64 | # Carthage
65 | #
66 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
67 | # Carthage/Checkouts
68 |
69 | Carthage/Build/
70 |
71 | # Accio dependency management
72 | Dependencies/
73 | .accio/
74 |
75 | # fastlane
76 | #
77 | # It is recommended to not store the screenshots in the git repo.
78 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
79 | # For more information about the recommended setup visit:
80 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
81 |
82 | fastlane/report.xml
83 | fastlane/Preview.html
84 | fastlane/screenshots/**/*.png
85 | fastlane/test_output
86 |
87 | # Code Injection
88 | #
89 | # After new code Injection tools there's a generated folder /iOSInjectionProject
90 | # https://github.com/johnno1962/injectionforxcode
91 |
92 | iOSInjectionProject/
93 |
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | opt_in_rules:
2 | - force_unwrapping
3 | - file_header
4 |
5 | excluded:
6 | - .build
7 |
8 | file_header:
9 | severity: error
10 | required_pattern: |
11 | \/\/ .*?\.swift
12 | \/\/ .*?
13 | \/\/
14 | \/\/ This file is part of Whisky\.
15 | \/\/
16 | \/\/ Whisky is free software: you can redistribute it and\/or modify it under the terms
17 | \/\/ of the GNU General Public License as published by the Free Software Foundation,
18 | \/\/ either version 3 of the License, or \(at your option\) any later version\.
19 | \/\/
20 | \/\/ Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
21 | \/\/ without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE\.
22 | \/\/ See the GNU General Public License for more details\.
23 | \/\/
24 | \/\/ You should have received a copy of the GNU General Public License along with Whisky\.
25 | \/\/ If not, see https:\/\/www\.gnu\.org\/licenses\/\.
26 | \/\/
27 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement on the [Whisky Discord](https://discord.gg/CsqAfs9CnM).
63 |
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to contribute
2 |
3 | Thanks for your interest! First, make a fork of Whisky, make a new branch for your changes, and get coding!
4 |
5 | # Build environment
6 |
7 | Whisky is built using Xcode 15 on macOS Sonoma. All external dependencies are handled through the Swift Package Manager.
8 |
9 | # Code style
10 |
11 | Every Whisky commit is automatically linted using SwiftLint. You can run these checks locally simply by building in Xcode, violations will appear as errors or warnings. For your pull request to be merged, you must meet all the requirements outlined by SwiftLint and have no violations.
12 |
13 | Generally, it is not advised to disable a SwiftLint rule, but there are certain situations where it is necessary. Please use your discretion when disabling rules temporarily.
14 |
15 | SwiftLint does not fully check indentation, but we ask that you indent with 4-width spaces. This can be automatically configured in Xcode's settings.
16 |
17 | All added strings must be properly localised and added to the EN strings file. Do not add keys for other languages or translate within your PR. All translations should be handled on [Crowdin](https://crowdin.com/project/whisky).
18 |
19 | # Making your PR
20 |
21 | Please provide a detailed description of your changes in your PR. If your commits contain UI changes, we ask that you provide screenshots.
22 |
23 | # Review
24 |
25 | Once your pull request passes CI SwiftLint checks and builds, it will be ready for review. You may receive feedback on code that should changed. Once you have received an approval, your code will be merged!
26 |
--------------------------------------------------------------------------------
/Libraries/cabextract:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Whisky-App/Whisky/fd5480a76b3ebfe3419a1ab86ca3695f5cc328f8/Libraries/cabextract
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Whisky 🥃
4 | *Wine but a bit stronger*
5 |
6 | 
7 | [](https://discord.gg/CsqAfs9CnM)
8 |
9 |
10 | ## Maintenance Notice
11 |
12 | [Whisky is no longer actively maintained](https://docs.getwhisky.app/maintenance-notice). Apps and games may break at any time.
13 |
14 |
15 |
16 | Familiar UI that integrates seamlessly with macOS
17 |
18 |
23 |
24 |
25 |
26 | Debug and profile with ease
27 |
28 | ---
29 |
30 | Whisky provides a clean and easy to use graphical wrapper for Wine built in native SwiftUI. You can make and manage bottles, install and run Windows apps and games, and unlock the full potential of your Mac with no technical knowledge required. Whisky is built on top of CrossOver 22.1.1, and Apple's own `Game Porting Toolkit`.
31 |
32 | Translated on [Crowdin](https://crowdin.com/project/whisky).
33 |
34 | ---
35 |
36 | ## System Requirements
37 | - CPU: Apple Silicon (M-series chips)
38 | - OS: macOS Sonoma 14.0 or later
39 |
40 | ## Homebrew
41 |
42 | Whisky is on homebrew! Install with
43 | `brew install --cask whisky`.
44 |
45 | ## My game isn't working!
46 |
47 | Some games need special steps to get working. Check out the [wiki](https://github.com/IsaacMarovitz/Whisky/wiki/Game-Support).
48 |
49 | ---
50 |
51 | ## Credits & Acknowledgments
52 |
53 | Whisky is possible thanks to the magic of several projects:
54 |
55 | - [msync](https://github.com/marzent/wine-msync) by marzent
56 | - [DXVK-macOS](https://github.com/Gcenx/DXVK-macOS) by Gcenx and doitsujin
57 | - [MoltenVK](https://github.com/KhronosGroup/MoltenVK) by KhronosGroup
58 | - [Sparkle](https://github.com/sparkle-project/Sparkle) by sparkle-project
59 | - [SemanticVersion](https://github.com/SwiftPackageIndex/SemanticVersion) by SwiftPackageIndex
60 | - [swift-argument-parser](https://github.com/apple/swift-argument-parser) by Apple
61 | - [SwiftTextTable](https://github.com/scottrhoyt/SwiftyTextTable) by scottrhoyt
62 | - [CrossOver 22.1.1](https://www.codeweavers.com/crossover) by CodeWeavers and WineHQ
63 | - D3DMetal by Apple
64 |
65 | Special thanks to Gcenx, ohaiibuzzle, and Nat Brown for their support and contributions!
66 |
67 | ---
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | Whisky doesn't exist without CrossOver. Support the work of CodeWeavers using our affiliate link.
79 |
80 |
81 |
82 |
--------------------------------------------------------------------------------
/Whisky.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Whisky.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Whisky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "progress.swift",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/jkandzi/Progress.swift",
7 | "state" : {
8 | "revision" : "fed6598735d7982058690acf8f52a0a5fdaeb3e0",
9 | "version" : "0.4.0"
10 | }
11 | },
12 | {
13 | "identity" : "semanticversion",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/SwiftPackageIndex/SemanticVersion",
16 | "state" : {
17 | "revision" : "ea8eea9d89842a29af1b8e6c7677f1c86e72fa42",
18 | "version" : "0.4.0"
19 | }
20 | },
21 | {
22 | "identity" : "sparkle",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/sparkle-project/Sparkle",
25 | "state" : {
26 | "branch" : "2.x",
27 | "revision" : "8de8db001ea3c781f5e2b1c9abe851209dd8c08a"
28 | }
29 | },
30 | {
31 | "identity" : "swift-argument-parser",
32 | "kind" : "remoteSourceControl",
33 | "location" : "https://github.com/apple/swift-argument-parser.git",
34 | "state" : {
35 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c",
36 | "version" : "1.5.0"
37 | }
38 | },
39 | {
40 | "identity" : "swiftytexttable",
41 | "kind" : "remoteSourceControl",
42 | "location" : "https://github.com/scottrhoyt/SwiftyTextTable",
43 | "state" : {
44 | "revision" : "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3",
45 | "version" : "0.9.0"
46 | }
47 | }
48 | ],
49 | "version" : 2
50 | }
51 |
--------------------------------------------------------------------------------
/Whisky.xcodeproj/xcshareddata/IDETemplateMacros.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | FILEHEADER
6 |
7 | // ___FILENAME___
8 | // ___TARGETNAME___
9 | //
10 | // This file is part of Whisky.
11 | //
12 | // Whisky is free software: you can redistribute it and/or modify it under the terms
13 | // of the GNU General Public License as published by the Free Software Foundation,
14 | // either version 3 of the License, or (at your option) any later version.
15 | //
16 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
17 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
18 | // See the GNU General Public License for more details.
19 | //
20 | // You should have received a copy of the GNU General Public License along with Whisky.
21 | // If not, see https://www.gnu.org/licenses/.
22 | //
23 |
24 |
25 |
--------------------------------------------------------------------------------
/Whisky.xcodeproj/xcshareddata/xcschemes/Whisky.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
42 |
44 |
50 |
51 |
52 |
53 |
59 |
61 |
67 |
68 |
69 |
70 |
72 |
73 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/Whisky.xcodeproj/xcshareddata/xcschemes/WhiskyCmd.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
43 |
45 |
51 |
52 |
53 |
54 |
57 |
58 |
59 |
60 |
66 |
68 |
74 |
75 |
76 |
77 |
79 |
80 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/Whisky.xcodeproj/xcshareddata/xcschemes/WhiskyThumbnail.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
45 |
46 |
58 |
60 |
66 |
67 |
68 |
69 |
77 |
79 |
85 |
86 |
87 |
88 |
90 |
91 |
94 |
95 |
96 |
--------------------------------------------------------------------------------
/Whisky/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import SwiftUI
21 |
22 | class AppDelegate: NSObject, NSApplicationDelegate {
23 | @AppStorage("hasShownMoveToApplicationsAlert") private var hasShownMoveToApplicationsAlert = false
24 |
25 | func application(_ application: NSApplication, open urls: [URL]) {
26 | // Test if automatic window tabbing is enabled
27 | // as it is disabled when ContentView appears
28 | if NSWindow.allowsAutomaticWindowTabbing, let url = urls.first {
29 | // Reopen the file after Whisky has been opened
30 | // so that the `onOpenURL` handler is actually called
31 | NSWorkspace.shared.open(url)
32 | }
33 | }
34 |
35 | func applicationDidFinishLaunching(_ notification: Notification) {
36 | if !hasShownMoveToApplicationsAlert && !AppDelegate.insideAppsFolder {
37 | DispatchQueue.main.asyncAfter(deadline: .now()) {
38 | NSApp.activate(ignoringOtherApps: true)
39 | self.showAlertOnFirstLaunch()
40 | self.hasShownMoveToApplicationsAlert = true
41 | }
42 | }
43 | }
44 |
45 | func applicationWillTerminate(_ notification: Notification) {
46 | if UserDefaults.standard.bool(forKey: "killOnTerminate") {
47 | WhiskyApp.killBottles()
48 | }
49 | }
50 |
51 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
52 | return true
53 | }
54 |
55 | private static var appUrl: URL? {
56 | Bundle.main.resourceURL?.deletingLastPathComponent().deletingLastPathComponent()
57 | }
58 |
59 | private static let expectedUrl = URL(fileURLWithPath: "/Applications/Whisky.app")
60 |
61 | private static var insideAppsFolder: Bool {
62 | if let url = appUrl {
63 | return url.path.contains("Xcode") || url.path.contains(expectedUrl.path)
64 | }
65 | return false
66 | }
67 |
68 | @MainActor
69 | private func showAlertOnFirstLaunch() {
70 | let alert = NSAlert()
71 | alert.messageText = String(localized: "showAlertOnFirstLaunch.messageText")
72 | alert.informativeText = String(localized: "showAlertOnFirstLaunch.informativeText")
73 | alert.addButton(withTitle: String(localized: "showAlertOnFirstLaunch.button.moveToApplications"))
74 | alert.addButton(withTitle: String(localized: "showAlertOnFirstLaunch.button.dontMove"))
75 |
76 | let response = alert.runModal()
77 |
78 | if response == .alertFirstButtonReturn {
79 | let appURL = Bundle.main.bundleURL
80 |
81 | do {
82 | _ = try FileManager.default.replaceItemAt(AppDelegate.expectedUrl, withItemAt: appURL)
83 | NSWorkspace.shared.open(AppDelegate.expectedUrl)
84 | } catch {
85 | print("Failed to move the app: \(error)")
86 | }
87 | }
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/Whisky/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "platform" : "universal",
6 | "reference" : "systemOrangeColor"
7 | },
8 | "idiom" : "universal"
9 | }
10 | ],
11 | "info" : {
12 | "author" : "xcode",
13 | "version" : 1
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Whisky/Assets.xcassets/AppIcon.appiconset/128@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Whisky-App/Whisky/fd5480a76b3ebfe3419a1ab86ca3695f5cc328f8/Whisky/Assets.xcassets/AppIcon.appiconset/128@2x.png
--------------------------------------------------------------------------------
/Whisky/Assets.xcassets/AppIcon.appiconset/128R128x1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Whisky-App/Whisky/fd5480a76b3ebfe3419a1ab86ca3695f5cc328f8/Whisky/Assets.xcassets/AppIcon.appiconset/128R128x1.png
--------------------------------------------------------------------------------
/Whisky/Assets.xcassets/AppIcon.appiconset/16@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Whisky-App/Whisky/fd5480a76b3ebfe3419a1ab86ca3695f5cc328f8/Whisky/Assets.xcassets/AppIcon.appiconset/16@2x.png
--------------------------------------------------------------------------------
/Whisky/Assets.xcassets/AppIcon.appiconset/16R64x1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Whisky-App/Whisky/fd5480a76b3ebfe3419a1ab86ca3695f5cc328f8/Whisky/Assets.xcassets/AppIcon.appiconset/16R64x1.png
--------------------------------------------------------------------------------
/Whisky/Assets.xcassets/AppIcon.appiconset/256@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Whisky-App/Whisky/fd5480a76b3ebfe3419a1ab86ca3695f5cc328f8/Whisky/Assets.xcassets/AppIcon.appiconset/256@2x.png
--------------------------------------------------------------------------------
/Whisky/Assets.xcassets/AppIcon.appiconset/256R256x1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Whisky-App/Whisky/fd5480a76b3ebfe3419a1ab86ca3695f5cc328f8/Whisky/Assets.xcassets/AppIcon.appiconset/256R256x1.png
--------------------------------------------------------------------------------
/Whisky/Assets.xcassets/AppIcon.appiconset/32@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Whisky-App/Whisky/fd5480a76b3ebfe3419a1ab86ca3695f5cc328f8/Whisky/Assets.xcassets/AppIcon.appiconset/32@2x.png
--------------------------------------------------------------------------------
/Whisky/Assets.xcassets/AppIcon.appiconset/32R64x1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Whisky-App/Whisky/fd5480a76b3ebfe3419a1ab86ca3695f5cc328f8/Whisky/Assets.xcassets/AppIcon.appiconset/32R64x1.png
--------------------------------------------------------------------------------
/Whisky/Assets.xcassets/AppIcon.appiconset/512@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Whisky-App/Whisky/fd5480a76b3ebfe3419a1ab86ca3695f5cc328f8/Whisky/Assets.xcassets/AppIcon.appiconset/512@2x.png
--------------------------------------------------------------------------------
/Whisky/Assets.xcassets/AppIcon.appiconset/512R512x1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Whisky-App/Whisky/fd5480a76b3ebfe3419a1ab86ca3695f5cc328f8/Whisky/Assets.xcassets/AppIcon.appiconset/512R512x1.png
--------------------------------------------------------------------------------
/Whisky/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "16R64x1.png",
5 | "idiom" : "mac",
6 | "scale" : "1x",
7 | "size" : "16x16"
8 | },
9 | {
10 | "filename" : "16@2x.png",
11 | "idiom" : "mac",
12 | "scale" : "2x",
13 | "size" : "16x16"
14 | },
15 | {
16 | "filename" : "32R64x1.png",
17 | "idiom" : "mac",
18 | "scale" : "1x",
19 | "size" : "32x32"
20 | },
21 | {
22 | "filename" : "32@2x.png",
23 | "idiom" : "mac",
24 | "scale" : "2x",
25 | "size" : "32x32"
26 | },
27 | {
28 | "filename" : "128R128x1.png",
29 | "idiom" : "mac",
30 | "scale" : "1x",
31 | "size" : "128x128"
32 | },
33 | {
34 | "filename" : "128@2x.png",
35 | "idiom" : "mac",
36 | "scale" : "2x",
37 | "size" : "128x128"
38 | },
39 | {
40 | "filename" : "256R256x1.png",
41 | "idiom" : "mac",
42 | "scale" : "1x",
43 | "size" : "256x256"
44 | },
45 | {
46 | "filename" : "256@2x.png",
47 | "idiom" : "mac",
48 | "scale" : "2x",
49 | "size" : "256x256"
50 | },
51 | {
52 | "filename" : "512R512x1.png",
53 | "idiom" : "mac",
54 | "scale" : "1x",
55 | "size" : "512x512"
56 | },
57 | {
58 | "filename" : "512@2x.png",
59 | "idiom" : "mac",
60 | "scale" : "2x",
61 | "size" : "512x512"
62 | }
63 | ],
64 | "info" : {
65 | "author" : "xcode",
66 | "version" : 1
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/Whisky/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Whisky/Extensions/Animation+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Animation+Extensions.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 |
21 | extension Animation {
22 | static var whiskyDefault: Animation {
23 | .easeInOut(duration: 0.2)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Whisky/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDocumentTypes
6 |
7 |
8 | CFBundleTypeIconFile
9 |
10 | CFBundleTypeIconSystemGenerated
11 | 0
12 | CFBundleTypeName
13 | Microsoft MSI Installer
14 | CFBundleTypeRole
15 | Editor
16 | LSHandlerRank
17 | Owner
18 | LSItemContentTypes
19 |
20 | com.microsoft.msi-installer
21 |
22 |
23 |
24 | CFBundleTypeIconFile
25 |
26 | CFBundleTypeIconSystemGenerated
27 | 0
28 | CFBundleTypeName
29 | Microsoft Batch File
30 | CFBundleTypeRole
31 | Editor
32 | LSHandlerRank
33 | Owner
34 | LSItemContentTypes
35 |
36 | com.microsoft.bat
37 |
38 |
39 |
40 | CFBundleTypeIconFile
41 |
42 | CFBundleTypeIconSystemGenerated
43 | 0
44 | CFBundleTypeName
45 | Microsoft Executable
46 | CFBundleTypeRole
47 | Editor
48 | LSHandlerRank
49 | Owner
50 | LSItemContentTypes
51 |
52 | com.microsoft.windows-executable
53 |
54 |
55 |
56 | SUFeedURL
57 | https://data.getwhisky.app/appcast.xml
58 | SUPublicEDKey
59 | tnZFAvPUfCpM7Tr7Sx5gKRm6BUQQ6htJQeOMP44evms=
60 | UTExportedTypeDeclarations
61 |
62 |
63 | UTTypeConformsTo
64 |
65 | public.data
66 |
67 | UTTypeDescription
68 | Microsoft MSI Installer
69 | UTTypeIconFile
70 |
71 | UTTypeIcons
72 |
73 | UTTypeIdentifier
74 | com.microsoft.msi-installer
75 | UTTypeTagSpecification
76 |
77 | public.filename-extension
78 |
79 | msi
80 |
81 |
82 |
83 |
84 | UTTypeConformsTo
85 |
86 | public.data
87 |
88 | UTTypeDescription
89 | Microsoft Batch File
90 | UTTypeIconFile
91 |
92 | UTTypeIcons
93 |
94 | UTTypeIdentifier
95 | com.microsoft.bat
96 | UTTypeTagSpecification
97 |
98 | public.filename-extension
99 |
100 | bat
101 |
102 |
103 |
104 |
105 |
106 |
107 |
--------------------------------------------------------------------------------
/Whisky/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Whisky/Utils/Constants.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Constants.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 |
21 | enum ViewWidth {
22 | static let small: Double = 400
23 | static let medium: Double = 500
24 | static let large: Double = 600
25 | }
26 |
--------------------------------------------------------------------------------
/Whisky/Utils/ProgramShortcut.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProgramShortcut.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import AppKit
21 | import QuickLookThumbnailing
22 | import WhiskyKit
23 |
24 | class ProgramShortcut {
25 | public static func createShortcut(_ program: Program, app: URL, name: String) async {
26 | let contents = app.appending(path: "Contents")
27 | let macos = contents.appending(path: "MacOS")
28 | do {
29 | try FileManager.default.createDirectory(at: macos, withIntermediateDirectories: true)
30 |
31 | // First create shell script
32 | let script = """
33 | #!/bin/bash
34 | \(program.generateTerminalCommand())
35 | """
36 | let scriptUrl = macos.appending(path: "launch")
37 | try script.write(to: scriptUrl,
38 | atomically: false,
39 | encoding: .utf8)
40 |
41 | // Make shell script runable
42 | try FileManager.default.setAttributes([.posixPermissions: 0o777],
43 | ofItemAtPath: scriptUrl.path(percentEncoded: false))
44 |
45 | // Create Info.plist (set category for Game mode)
46 | let info = """
47 |
48 |
49 |
50 |
51 | CFBundleExecutable
52 | launch
53 | CFBundleSupportedPlatforms
54 |
55 | MacOSX
56 |
57 | LSMinimumSystemVersion
58 | 14.0
59 | LSApplicationCategoryType
60 | public.app-category.games
61 |
62 |
63 | """
64 | try info.write(to: contents.appending(path: "Info")
65 | .appendingPathExtension("plist"),
66 | atomically: false,
67 | encoding: .utf8)
68 |
69 | // Set bundle icon
70 | let request = QLThumbnailGenerator.Request(fileAt: program.url,
71 | size: CGSize(width: 512, height: 512),
72 | scale: 2.0,
73 | representationTypes: .thumbnail)
74 | let thumbnail = try await QLThumbnailGenerator.shared.generateBestRepresentation(for: request)
75 | NSWorkspace.shared.setIcon(thumbnail.nsImage,
76 | forFile: app.path(percentEncoded: false),
77 | options: NSWorkspace.IconCreationOptions())
78 | NSWorkspace.shared.activateFileViewerSelecting([app])
79 | } catch {
80 | print(error)
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/Whisky/Utils/WhiskyCmd.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WhiskyCmd.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import AppKit
21 |
22 | class WhiskyCmd {
23 | static func install() async {
24 | let whiskyCmdURL = Bundle.main.url(forResource: "WhiskyCmd", withExtension: nil)
25 |
26 | if let whiskyCmdURL = whiskyCmdURL {
27 | // swiftlint:disable line_length
28 | let script = """
29 | do shell script "ln -fs \(whiskyCmdURL.path(percentEncoded: false)) /usr/local/bin/whisky" with administrator privileges
30 | """
31 | // swiftlint:enable line_length
32 |
33 | var error: NSDictionary?
34 | // Use AppleScript because somehow in 2023 Apple doesn't have good privileged file ops APIs
35 | if let appleScript = NSAppleScript(source: script) {
36 | appleScript.executeAndReturnError(&error)
37 |
38 | if let error = error {
39 | print(error)
40 | if let description = error["NSAppleScriptErrorMessage"] as? String {
41 | await MainActor.run {
42 | let alert = NSAlert()
43 | alert.messageText = String(localized: "alert.message")
44 | alert.informativeText = String(localized: "alert.info")
45 | + description
46 | alert.alertStyle = .critical
47 | alert.addButton(withTitle: String(localized: "button.ok"))
48 | alert.runModal()
49 | }
50 | }
51 | }
52 | }
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Whisky/Utils/Winetricks.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Winetricks.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import AppKit
21 | import WhiskyKit
22 |
23 | enum WinetricksCategories: String {
24 | case apps
25 | case benchmarks
26 | case dlls
27 | case fonts
28 | case games
29 | case settings
30 | }
31 |
32 | struct WinetricksVerb: Identifiable {
33 | var id = UUID()
34 |
35 | var name: String
36 | var description: String
37 | }
38 |
39 | struct WinetricksCategory {
40 | var category: WinetricksCategories
41 | var verbs: [WinetricksVerb]
42 | }
43 |
44 | class Winetricks {
45 | static let winetricksURL: URL = WhiskyWineInstaller.libraryFolder
46 | .appending(path: "winetricks")
47 |
48 | static func runCommand(command: String, bottle: Bottle) async {
49 | guard let resourcesURL = Bundle.main.url(forResource: "cabextract", withExtension: nil)?
50 | .deletingLastPathComponent() else { return }
51 | // swiftlint:disable:next line_length
52 | let winetricksCmd = #"PATH=\"\#(WhiskyWineInstaller.binFolder.path):\#(resourcesURL.path(percentEncoded: false)):$PATH\" WINE=wine64 WINEPREFIX=\"\#(bottle.url.path)\" \"\#(winetricksURL.path(percentEncoded: false))\" \#(command)"#
53 |
54 | let script = """
55 | tell application "Terminal"
56 | activate
57 | do script "\(winetricksCmd)"
58 | end tell
59 | """
60 |
61 | var error: NSDictionary?
62 | if let appleScript = NSAppleScript(source: script) {
63 | appleScript.executeAndReturnError(&error)
64 |
65 | if let error = error {
66 | print(error)
67 | if let description = error["NSAppleScriptErrorMessage"] as? String {
68 | await MainActor.run {
69 | let alert = NSAlert()
70 | alert.messageText = String(localized: "alert.message")
71 | alert.informativeText = String(localized: "alert.info")
72 | + " \(command): "
73 | + description
74 | alert.alertStyle = .critical
75 | alert.addButton(withTitle: String(localized: "button.ok"))
76 | alert.runModal()
77 | }
78 | }
79 | }
80 | }
81 | }
82 |
83 | static func parseVerbs() async -> [WinetricksCategory] {
84 | // Grab the verbs file
85 | let verbsURL = WhiskyWineInstaller.libraryFolder.appending(path: "verbs.txt")
86 | let verbs: String = await { () async -> String in
87 | do {
88 | let (data, _) = try await URLSession.shared.data(from: verbsURL)
89 | return String(data: data, encoding: .utf8) ?? String()
90 | } catch {
91 | return String()
92 | }
93 | }()
94 |
95 | // Read the file line by line
96 | let lines = verbs.components(separatedBy: "\n")
97 | var categories: [WinetricksCategory] = []
98 | var currentCategory: WinetricksCategory?
99 |
100 | for line in lines {
101 | // Categories are label as "===== ====="
102 | if line.starts(with: "=====") {
103 | // If we have a current category, add it to the list
104 | if let currentCategory = currentCategory {
105 | categories.append(currentCategory)
106 | }
107 |
108 | // Create a new category
109 | // Capitalize the first letter of the category name
110 | let categoryName = line.replacingOccurrences(of: "=====", with: "").trimmingCharacters(in: .whitespaces)
111 | if let cateogry = WinetricksCategories(rawValue: categoryName) {
112 | currentCategory = WinetricksCategory(category: cateogry,
113 | verbs: [])
114 | } else {
115 | currentCategory = nil
116 | }
117 | } else {
118 | guard currentCategory != nil else {
119 | continue
120 | }
121 |
122 | // If we have a current category, add the verb to it
123 | // Verbs eg. "3m_library 3M Cloud Library (3M Company, 2015) [downloadable]"
124 | let verbName = line.components(separatedBy: " ")[0]
125 | let verbDescription = line.replacingOccurrences(of: "\(verbName) ", with: "")
126 | .trimmingCharacters(in: .whitespaces)
127 | currentCategory?.verbs.append(WinetricksVerb(name: verbName, description: verbDescription))
128 | }
129 | }
130 |
131 | // Add the last category
132 | if let currentCategory = currentCategory {
133 | categories.append(currentCategory)
134 | }
135 |
136 | return categories
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/Whisky/View Models/BottleVM.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BottleVM.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import SemanticVersion
21 | import WhiskyKit
22 |
23 | // swiftlint:disable:next todo
24 | // TODO: Don't use unchecked!
25 | final class BottleVM: ObservableObject, @unchecked Sendable {
26 | @MainActor static let shared = BottleVM()
27 |
28 | var bottlesList = BottleData()
29 | @Published var bottles: [Bottle] = []
30 |
31 | @MainActor
32 | func loadBottles() {
33 | bottles = bottlesList.loadBottles()
34 | }
35 |
36 | func countActive() -> Int {
37 | return bottles.filter { $0.isAvailable == true }.count
38 | }
39 |
40 | func createNewBottle(bottleName: String, winVersion: WinVersion, bottleURL: URL) -> URL {
41 | let newBottleDir = bottleURL.appending(path: UUID().uuidString)
42 |
43 | Task.detached {
44 | var bottleId: Bottle?
45 | do {
46 | try FileManager.default.createDirectory(atPath: newBottleDir.path(percentEncoded: false),
47 | withIntermediateDirectories: true)
48 | let bottle = Bottle(bottleUrl: newBottleDir, inFlight: true)
49 | bottleId = bottle
50 |
51 | await MainActor.run {
52 | self.bottles.append(bottle)
53 | }
54 |
55 | bottle.settings.windowsVersion = winVersion
56 | bottle.settings.name = bottleName
57 | try await Wine.changeWinVersion(bottle: bottle, win: winVersion)
58 | let wineVer = try await Wine.wineVersion()
59 | bottle.settings.wineVersion = SemanticVersion(wineVer) ?? SemanticVersion(0, 0, 0)
60 | // Add record
61 | await MainActor.run {
62 | self.bottlesList.paths.append(newBottleDir)
63 | self.loadBottles()
64 | }
65 | } catch {
66 | print("Failed to create new bottle: \(error)")
67 | if let bottle = bottleId {
68 | await MainActor.run {
69 | if let index = self.bottles.firstIndex(of: bottle) {
70 | self.bottles.remove(at: index)
71 | }
72 | }
73 | }
74 | }
75 | }
76 | return newBottleDir
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/Whisky/Views/Bottle/BottleCreationView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BottleCreationView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 |
22 | struct BottleCreationView: View {
23 | @Binding var newlyCreatedBottleURL: URL?
24 |
25 | @State private var newBottleName: String = ""
26 | @State private var newBottleVersion: WinVersion = .win10
27 | @State private var newBottleURL: URL = UserDefaults.standard.url(forKey: "defaultBottleLocation")
28 | ?? BottleData.defaultBottleDir
29 | @State private var nameValid: Bool = false
30 |
31 | @Environment(\.dismiss) private var dismiss
32 |
33 | var body: some View {
34 | NavigationStack {
35 | Form {
36 | TextField("create.name", text: $newBottleName)
37 | .onChange(of: newBottleName) { _, name in
38 | nameValid = !name.isEmpty
39 | }
40 |
41 | Picker("create.win", selection: $newBottleVersion) {
42 | ForEach(WinVersion.allCases.reversed(), id: \.self) {
43 | Text($0.pretty())
44 | }
45 | }
46 |
47 | ActionView(
48 | text: "create.path",
49 | subtitle: newBottleURL.prettyPath(),
50 | actionName: "create.browse"
51 | ) {
52 | let panel = NSOpenPanel()
53 | panel.canChooseFiles = false
54 | panel.canChooseDirectories = true
55 | panel.allowsMultipleSelection = false
56 | panel.canCreateDirectories = true
57 | panel.directoryURL = BottleData.containerDir
58 | panel.begin { result in
59 | if result == .OK, let url = panel.urls.first {
60 | newBottleURL = url
61 | }
62 | }
63 | }
64 | }
65 | .formStyle(.grouped)
66 | .navigationTitle("create.title")
67 | .toolbar {
68 | ToolbarItem(placement: .cancellationAction) {
69 | Button("create.cancel") {
70 | dismiss()
71 | }
72 | .keyboardShortcut(.cancelAction)
73 | }
74 | ToolbarItem(placement: .primaryAction) {
75 | Button("create.create") {
76 | submit()
77 | }
78 | .keyboardShortcut(.defaultAction)
79 | .disabled(!nameValid)
80 | }
81 | }
82 | .onSubmit {
83 | submit()
84 | }
85 | }
86 | .fixedSize(horizontal: false, vertical: true)
87 | .frame(width: ViewWidth.small)
88 | }
89 |
90 | func submit() {
91 | newlyCreatedBottleURL = BottleVM.shared.createNewBottle(bottleName: newBottleName,
92 | winVersion: newBottleVersion,
93 | bottleURL: newBottleURL)
94 | dismiss()
95 | }
96 | }
97 |
98 | #Preview {
99 | BottleCreationView(newlyCreatedBottleURL: .constant(nil))
100 | }
101 |
--------------------------------------------------------------------------------
/Whisky/Views/Bottle/BottleListEntry.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BottleListEntry.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 | import UniformTypeIdentifiers
22 |
23 | struct BottleListEntry: View {
24 | let bottle: Bottle
25 | @Binding var selected: URL?
26 | @Binding var refresh: Bool
27 |
28 | @State private var showBottleRename: Bool = false
29 | @State private var name: String = ""
30 |
31 | var body: some View {
32 | Text(name)
33 | .opacity(bottle.isAvailable ? 1.0 : 0.5)
34 | .onChange(of: refresh, initial: true) {
35 | name = bottle.settings.name
36 | }
37 | .sheet(isPresented: $showBottleRename) {
38 | RenameView("rename.bottle.title", name: name) { newName in
39 | name = newName
40 | bottle.rename(newName: newName)
41 | }
42 | }
43 | .contextMenu {
44 | Button("button.rename", systemImage: "pencil.line") {
45 | showBottleRename.toggle()
46 | }
47 | .disabled(!bottle.isAvailable)
48 | .labelStyle(.titleAndIcon)
49 | Button("button.removeAlert", systemImage: "trash") {
50 | showRemoveAlert(bottle: bottle)
51 | }
52 | .labelStyle(.titleAndIcon)
53 | Divider()
54 | Button("button.moveBottle", systemImage: "shippingbox.and.arrow.backward") {
55 | let panel = NSOpenPanel()
56 | panel.canChooseFiles = false
57 | panel.canChooseDirectories = true
58 | panel.allowsMultipleSelection = false
59 | panel.canCreateDirectories = true
60 | panel.begin { result in
61 | if result == .OK {
62 | if let url = panel.urls.first {
63 | let newBottePath = url
64 | .appending(path: bottle.url.lastPathComponent)
65 |
66 | bottle.move(destination: newBottePath)
67 | selected = newBottePath
68 | }
69 | }
70 | }
71 | }
72 | .disabled(!bottle.isAvailable)
73 | .labelStyle(.titleAndIcon)
74 | Button("button.exportBottle", systemImage: "arrowshape.turn.up.right") {
75 | let panel = NSSavePanel()
76 | panel.canCreateDirectories = true
77 | panel.allowedContentTypes = [UTType.gzip]
78 | panel.allowsOtherFileTypes = false
79 | panel.isExtensionHidden = false
80 | panel.nameFieldStringValue = bottle.settings.name + ".tar"
81 | panel.begin { result in
82 | if result == .OK {
83 | if let url = panel.url {
84 | Task.detached(priority: .background) {
85 | bottle.exportAsArchive(destination: url)
86 | }
87 | }
88 | }
89 | }
90 | }
91 | .disabled(!bottle.isAvailable)
92 | .labelStyle(.titleAndIcon)
93 | Divider()
94 | Button("button.showInFinder", systemImage: "folder") {
95 | NSWorkspace.shared.activateFileViewerSelecting([bottle.url])
96 | }
97 | .disabled(!bottle.isAvailable)
98 | .labelStyle(.titleAndIcon)
99 | }
100 | }
101 |
102 | func showRemoveAlert(bottle: Bottle) {
103 | let checkbox = NSButton(checkboxWithTitle: String(localized: "button.removeAlert.checkbox"),
104 | target: self, action: nil)
105 | let alert = NSAlert()
106 | alert.messageText = String(format: String(localized: "button.removeAlert.msg"),
107 | bottle.settings.name)
108 | alert.informativeText = String(localized: "button.removeAlert.info")
109 | alert.alertStyle = .warning
110 | let delete = alert.addButton(withTitle: String(localized: "button.removeAlert.delete"))
111 | delete.hasDestructiveAction = true
112 | alert.addButton(withTitle: String(localized: "button.removeAlert.cancel"))
113 | if bottle.isAvailable {
114 | alert.accessoryView = checkbox
115 | }
116 |
117 | let response = alert.runModal()
118 |
119 | if response == .alertFirstButtonReturn {
120 | Task(priority: .userInitiated) {
121 | if selected == bottle.url {
122 | selected = nil
123 | }
124 |
125 | bottle.remove(delete: checkbox.state == .on)
126 | }
127 | }
128 | }
129 | }
130 |
131 | #Preview {
132 | BottleListEntry(
133 | bottle: Bottle(bottleUrl: URL(filePath: "")),
134 | selected: .constant(nil),
135 | refresh: .constant(false)
136 | )
137 | }
138 |
--------------------------------------------------------------------------------
/Whisky/Views/Bottle/Pins/PinAddView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PinAddView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 |
22 | struct PinAddView: View {
23 | let bottle: Bottle
24 | @State private var showingSheet = false
25 |
26 | var body: some View {
27 | VStack {
28 | Button {
29 | showingSheet = true
30 | } label: {
31 | Image(systemName: "plus.circle")
32 | .resizable()
33 | .foregroundStyle(.secondary)
34 | }
35 | .buttonStyle(.plain)
36 | .frame(width: 45, height: 45)
37 | Spacer()
38 | Text("pin.help")
39 | .multilineTextAlignment(.center)
40 | .lineLimit(2, reservesSpace: true)
41 | }
42 | .frame(width: 90, height: 90)
43 | .padding(10)
44 | .sheet(isPresented: $showingSheet) {
45 | PinCreationView(bottle: bottle)
46 | }
47 | }
48 | }
49 |
50 | #Preview {
51 | PinAddView(bottle: Bottle(bottleUrl: URL(filePath: "")))
52 | }
53 |
--------------------------------------------------------------------------------
/Whisky/Views/Bottle/Pins/PinCreationView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PinCreationView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import UniformTypeIdentifiers
21 | import WhiskyKit
22 |
23 | struct PinCreationView: View {
24 | let bottle: Bottle
25 |
26 | @State private var newPinURL: URL?
27 | @State private var pinPath: String = ""
28 | @State private var newPinName: String = ""
29 | @State private var isDuplicate: Bool = false
30 |
31 | @Environment(\.dismiss) private var dismiss
32 |
33 | var body: some View {
34 | NavigationStack {
35 | Form {
36 | TextField("pin.name", text: $newPinName)
37 |
38 | ActionView(
39 | text: "pin.path",
40 | subtitle: pinPath,
41 | actionName: "create.browse"
42 | ) {
43 | let panel = NSOpenPanel()
44 | panel.canChooseFiles = true
45 | panel.allowedContentTypes = [UTType.exe,
46 | UTType(exportedAs: "com.microsoft.msi-installer"),
47 | UTType(exportedAs: "com.microsoft.bat")]
48 | panel.directoryURL = newPinURL ?? bottle.url.appending(path: "drive_c")
49 | panel.canChooseDirectories = false
50 | panel.allowsMultipleSelection = false
51 | panel.canCreateDirectories = false
52 | panel.begin { result in
53 | if result == .OK, let url = panel.urls.first {
54 | newPinURL = url
55 | }
56 | }
57 | }
58 | }
59 | .formStyle(.grouped)
60 | .navigationTitle("pin.title")
61 | .toolbar {
62 | ToolbarItem(placement: .cancellationAction) {
63 | Button("create.cancel") {
64 | dismiss()
65 | }
66 | .keyboardShortcut(.cancelAction)
67 | }
68 | ToolbarItem(placement: .primaryAction) {
69 | Button("pin.create") {
70 | submit()
71 | }
72 | .keyboardShortcut(.defaultAction)
73 | .disabled(newPinName.isEmpty || newPinURL == nil)
74 | .alert("pin.error.title", isPresented: $isDuplicate) {
75 | } message: {
76 | Text("pin.error.duplicate.\(newPinURL?.lastPathComponent ?? "unknown")")
77 | }
78 | }
79 | }
80 | .onChange(of: newPinURL, initial: true) { oldValue, newValue in
81 | guard let newValue = newValue else { return }
82 |
83 | // Only reset newPinName if the textbox hasn't been modified
84 | if newPinName.isEmpty ||
85 | newPinName == oldValue?.deletingPathExtension().lastPathComponent {
86 |
87 | newPinName = newValue.deletingPathExtension().lastPathComponent
88 | }
89 |
90 | pinPath = newValue.prettyPath()
91 | }
92 | .onSubmit {
93 | submit()
94 | }
95 | }
96 | .fixedSize(horizontal: false, vertical: true)
97 | .frame(minWidth: ViewWidth.small)
98 | }
99 |
100 | func submit() {
101 | guard let newPinURL else { return }
102 |
103 | // Ensure this pin doesn't already exist
104 | guard !bottle.settings.pins.contains(where: { $0.url == newPinURL })
105 | else {
106 | isDuplicate = true
107 | return
108 | }
109 |
110 | bottle.settings.pins.append(PinnedProgram(name: newPinName, url: newPinURL))
111 |
112 | // Trigger a reload
113 | bottle.updateInstalledPrograms()
114 | dismiss()
115 | }
116 | }
117 |
118 | #Preview {
119 | PinCreationView(bottle: Bottle(bottleUrl: URL(filePath: "")))
120 | }
121 |
--------------------------------------------------------------------------------
/Whisky/Views/Bottle/Pins/PinView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PinView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 |
22 | struct PinView: View {
23 | @ObservedObject var bottle: Bottle
24 | @ObservedObject var program: Program
25 | @State var pin: PinnedProgram
26 | @Binding var path: NavigationPath
27 |
28 | @State private var image: Image?
29 | @State private var showRenameSheet = false
30 | @State private var name: String = ""
31 | @State private var opening: Bool = false
32 |
33 | var body: some View {
34 | VStack {
35 | Group {
36 | if let image = image {
37 | image
38 | .resizable()
39 | } else {
40 | Image(systemName: "app.dashed")
41 | .resizable()
42 | }
43 | }
44 | .frame(width: 45, height: 45)
45 | .scaleEffect(opening ? 2 : 1)
46 | .opacity(opening ? 0 : 1)
47 | Spacer()
48 | Text(name)
49 | .multilineTextAlignment(.center)
50 | .lineLimit(2, reservesSpace: true)
51 | }
52 | .frame(width: 90, height: 90)
53 | .padding(10)
54 | .overlay {
55 | HStack {
56 | Spacer()
57 | Image(systemName: "play.fill")
58 | .resizable()
59 | .foregroundColor(.green)
60 | .frame(width: 16, height: 16)
61 | }
62 | .frame(width: 45, height: 45)
63 | .padding(EdgeInsets(top: 0, leading: 0, bottom: 12, trailing: 0))
64 | }
65 | .contextMenu {
66 | ProgramMenuView(program: program, path: $path)
67 |
68 | Button("button.rename", systemImage: "pencil.line") {
69 | showRenameSheet.toggle()
70 | }
71 | .labelStyle(.titleAndIcon)
72 | Button("button.showInFinder", systemImage: "folder") {
73 | NSWorkspace.shared.activateFileViewerSelecting([program.url])
74 | }
75 | .labelStyle(.titleAndIcon)
76 | }
77 | .onTapGesture(count: 2) {
78 | runProgram()
79 | }
80 | .sheet(isPresented: $showRenameSheet) {
81 | RenameView("rename.pin.title", name: name) { newName in
82 | name = newName
83 | }
84 | }
85 | .task {
86 | name = pin.name
87 | guard let peFile = program.peFile else { return }
88 | let task = Task.detached {
89 | guard let image = peFile.bestIcon() else { return nil as Image? }
90 | return Image(nsImage: image)
91 | }
92 | self.image = await task.value
93 | }
94 | .onChange(of: name) {
95 | if let index = bottle.settings.pins.firstIndex(where: {
96 | let exists = FileManager.default.fileExists(atPath: pin.url?.path(percentEncoded: false) ?? "")
97 | return $0.url == pin.url && exists
98 | }) {
99 | bottle.settings.pins[index].name = name
100 | }
101 | }
102 | }
103 |
104 | func runProgram() {
105 | withAnimation(.easeIn(duration: 0.25)) {
106 | opening = true
107 | } completion: {
108 | withAnimation(.easeOut(duration: 0.1)) {
109 | opening = false
110 | }
111 | }
112 |
113 | program.run()
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/Whisky/Views/Bottle/RunningProcessesView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RunningProcessView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 |
22 | struct BottleProcess: Identifiable {
23 | var id = UUID()
24 | var pid: String
25 | var procName: String
26 | }
27 |
28 | struct RunningProcessesView: View {
29 | @ObservedObject var bottle: Bottle
30 |
31 | @State private var processes = [BottleProcess]()
32 | @State private var processSortOrder = [KeyPathComparator(\BottleProcess.pid)]
33 | @State private var selectedProcess: BottleProcess.ID?
34 |
35 | var body: some View {
36 | ZStack {
37 | if !processes.isEmpty {
38 | VStack {
39 | Table(processes, selection: $selectedProcess, sortOrder: $processSortOrder) {
40 | TableColumn("process.table.pid", value: \.pid)
41 | TableColumn("process.table.executable", value: \.procName)
42 | }
43 | .frame(maxWidth: .infinity, maxHeight: .infinity)
44 |
45 | HStack {
46 | Spacer()
47 | Button("process.table.refresh") {
48 | Task.detached(priority: .userInitiated) {
49 | await fetchProcesses()
50 | }
51 | }
52 | Button("process.table.kill") {
53 | Task.detached(priority: .userInitiated) {
54 | await killProcess()
55 | }
56 | }
57 | }
58 | .padding()
59 | }
60 | } else {
61 | HStack(alignment: .center) {
62 | Spacer()
63 | VStack(alignment: .center) {
64 | ProgressView()
65 | .padding()
66 | Text("process.table.loading")
67 | }
68 | Spacer()
69 | }
70 | }
71 | }
72 | .onAppear {
73 | Task.detached(priority: .userInitiated) {
74 | await fetchProcesses()
75 | }
76 | }
77 | }
78 |
79 | func fetchProcesses() async {
80 | var newProcessList = [BottleProcess]()
81 | let output: String?
82 |
83 | do {
84 | output = try await Wine.runWine(["tasklist.exe"], bottle: bottle)
85 | } catch {
86 | print("Error running tasklist.exe: \(error)")
87 | output = ""
88 | }
89 |
90 | let lines = output?.split(omittingEmptySubsequences: true, whereSeparator: \.isNewline)
91 | for line in lines ?? [] {
92 | let lineParts = line.split(separator: ",", omittingEmptySubsequences: true)
93 | if lineParts.count > 1 {
94 | let pid = String(lineParts[1])
95 | let procName = String(lineParts[0])
96 | newProcessList.append(BottleProcess(pid: pid, procName: procName))
97 | }
98 | }
99 | processes = newProcessList
100 | }
101 |
102 | func killProcess() async {
103 | if let thisProcess = processes.first(where: { $0.id == selectedProcess }) {
104 | do {
105 | try await Wine.runWine(["taskkill.exe", "/PID", thisProcess.pid, "/F"], bottle: bottle)
106 | try await Task.sleep(nanoseconds: 2000)
107 | } catch {
108 | print("Error running taskkill.exe: \(error)")
109 | }
110 | await fetchProcesses()
111 | }
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/Whisky/Views/Bottle/WinetricksView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WinetricksView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 |
22 | struct WinetricksView: View {
23 | var bottle: Bottle
24 | @State private var winetricks: [WinetricksCategory]?
25 | @State private var selectedTrick: UUID?
26 | @Environment(\.dismiss) var dismiss
27 |
28 | var body: some View {
29 | VStack {
30 | VStack {
31 | Text("winetricks.title")
32 | .font(.title)
33 | }
34 | .padding(.bottom)
35 |
36 | // Tabbed view
37 | if let winetricks = winetricks {
38 | TabView {
39 | ForEach(winetricks, id: \.category) { category in
40 | Table(category.verbs, selection: $selectedTrick) {
41 | TableColumn("winetricks.table.name", value: \.name)
42 | TableColumn("winetricks.table.description", value: \.description)
43 | }
44 | .tabItem {
45 | let key = "winetricks.category.\(category.category.rawValue)"
46 | Text(NSLocalizedString(key, comment: ""))
47 | }
48 | }
49 | }
50 | .toolbar {
51 | ToolbarItem(placement: .cancellationAction) {
52 | Button("create.cancel") {
53 | dismiss()
54 | }
55 | }
56 | ToolbarItem(placement: .primaryAction) {
57 | Button("button.run") {
58 | guard let selectedTrick = selectedTrick else {
59 | return
60 | }
61 |
62 | let trick = winetricks.flatMap { $0.verbs }.first(where: { $0.id == selectedTrick })
63 | if let trickName = trick?.name {
64 | Task.detached {
65 | await Winetricks.runCommand(command: trickName, bottle: bottle)
66 | }
67 | }
68 | dismiss()
69 | }
70 | .buttonStyle(.borderedProminent)
71 | }
72 | }
73 | } else {
74 | Spacer()
75 | ProgressView()
76 | .progressViewStyle(.circular)
77 | .controlSize(.large)
78 | Spacer()
79 | }
80 | }
81 | .padding()
82 | .onAppear {
83 | Task.detached {
84 | let tricks = await Winetricks.parseVerbs()
85 |
86 | await MainActor.run {
87 | winetricks = tricks
88 | }
89 | }
90 | }
91 | .frame(minWidth: ViewWidth.large, minHeight: 400)
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/Whisky/Views/Common/ActionView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ActionView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 |
21 | struct ActionView: View {
22 | let text: LocalizedStringKey
23 | let subtitle: String
24 | let actionName: LocalizedStringKey
25 | let action: () -> Void
26 |
27 | init(
28 | text: LocalizedStringKey,
29 | subtitle: String = "",
30 | actionName: LocalizedStringKey,
31 | action: @escaping () -> Void
32 | ) {
33 | self.text = text
34 | self.subtitle = subtitle
35 | self.actionName = actionName
36 | self.action = action
37 | }
38 |
39 | var body: some View {
40 | HStack(alignment: subtitle.isEmpty ? .center : .top) {
41 | VStack(alignment: .leading) {
42 | Text(text)
43 | .foregroundStyle(.primary)
44 |
45 | if !subtitle.isEmpty {
46 | Text(subtitle)
47 | .font(.callout)
48 | .foregroundStyle(.secondary)
49 | .truncationMode(.middle)
50 | .lineLimit(2)
51 | .help(subtitle)
52 | }
53 | }
54 | Spacer()
55 | Button(actionName) {
56 | action()
57 | }
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/Whisky/Views/Common/BottomBar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BottomBar.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 |
21 | extension View {
22 | func bottomBar(
23 | @ViewBuilder content: () -> Content
24 | ) -> some View where Content: View {
25 | modifier(BottomBarViewModifier(barContent: content()))
26 | }
27 | }
28 |
29 | private struct BottomBarViewModifier: ViewModifier where BarContent: View {
30 | var barContent: BarContent
31 |
32 | func body(content: Content) -> some View {
33 | content
34 | .safeAreaInset(edge: .bottom, spacing: 0) {
35 | VStack(spacing: 0) {
36 | Divider()
37 | barContent
38 | }
39 | .background(.regularMaterial)
40 | .buttonStyle(BottomBarButtonStyle())
41 | }
42 | }
43 | }
44 |
45 | struct BottomBarButtonStyle: PrimitiveButtonStyle {
46 | func makeBody(configuration: Configuration) -> some View {
47 | Button {
48 | configuration.trigger()
49 | } label: {
50 | configuration.label
51 | .foregroundStyle(.foreground)
52 | }
53 | }
54 | }
55 |
56 | #Preview {
57 | Form {
58 | Text(String("Hello World"))
59 | }
60 | .formStyle(.grouped)
61 | .bottomBar {
62 | HStack {
63 | Spacer()
64 | Button {
65 | } label: {
66 | Text(String("Button 1"))
67 | }
68 | Button {
69 | } label: {
70 | Text(String("Button 2"))
71 | }
72 | }
73 | .padding()
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Whisky/Views/Common/RenameView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RenameView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 |
21 | struct RenameView: View {
22 | let title: Text
23 | var renameAction: (String) -> Void
24 |
25 | @State private var name: String = ""
26 | @Environment(\.dismiss) private var dismiss
27 |
28 | init(_ title: LocalizedStringKey, name: String, renameAction: @escaping (String) -> Void) {
29 | self.title = Text(title)
30 | self._name = State(initialValue: name)
31 | self.renameAction = renameAction
32 | }
33 |
34 | var body: some View {
35 | NavigationStack {
36 | Form {
37 | TextField("rename.name", text: $name)
38 | }
39 | .formStyle(.grouped)
40 | .navigationTitle(title)
41 | .toolbar {
42 | ToolbarItem(placement: .cancellationAction) {
43 | Button("create.cancel") {
44 | dismiss()
45 | }
46 | .keyboardShortcut(.cancelAction)
47 | }
48 | ToolbarItem(placement: .primaryAction) {
49 | Button("rename.rename") {
50 | submit()
51 | }
52 | .keyboardShortcut(.defaultAction)
53 | .disabled(!isNameValid)
54 | }
55 | }
56 | .onSubmit {
57 | submit()
58 | }
59 | }
60 | .fixedSize(horizontal: false, vertical: true)
61 | .frame(minWidth: ViewWidth.small)
62 | }
63 |
64 | var isNameValid: Bool {
65 | !name.isEmpty
66 | }
67 |
68 | func submit() {
69 | renameAction(name)
70 | dismiss()
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Whisky/Views/FileOpenView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FileOpenView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 |
22 | struct FileOpenView: View {
23 | var fileURL: URL
24 | var currentBottle: URL?
25 | var bottles: [Bottle]
26 |
27 | @State private var selection: URL = URL(filePath: "")
28 | @Environment(\.dismiss) private var dismiss
29 |
30 | var body: some View {
31 | NavigationStack {
32 | Form {
33 | Picker("run.bottle", selection: $selection) {
34 | ForEach(bottles, id: \.self) {
35 | Text($0.settings.name)
36 | .tag($0.url)
37 | }
38 | }
39 | }
40 | .frame(maxHeight: .infinity)
41 | .formStyle(.grouped)
42 | .navigationTitle(String(format: String(localized: "run.title"), fileURL.lastPathComponent))
43 | .toolbar {
44 | ToolbarItem(placement: .cancellationAction) {
45 | Button("create.cancel") {
46 | dismiss()
47 | }
48 | .keyboardShortcut(.cancelAction)
49 | }
50 | ToolbarItem(placement: .primaryAction) {
51 | Button("button.run") {
52 | run()
53 | }
54 | .keyboardShortcut(.defaultAction)
55 | }
56 | }
57 | }
58 | .fixedSize(horizontal: false, vertical: true)
59 | .frame(width: ViewWidth.small)
60 | .onAppear {
61 | // Makes sure there are more than 0 bottles.
62 | // Otherwise, it will crash on the nil cascade
63 | if bottles.count <= 0 {
64 | dismiss()
65 | return
66 | }
67 |
68 | selection = bottles.first(where: { $0.url == currentBottle })?.url ?? bottles[0].url
69 |
70 | if bottles.count == 1 {
71 | // If the user only has one bottle
72 | // there's nothing for them to select
73 | run()
74 | }
75 | }
76 | }
77 |
78 | func run() {
79 | if let bottle = bottles.first(where: { $0.url == selection }) {
80 | Task.detached(priority: .userInitiated) {
81 | do {
82 | if fileURL.pathExtension == "bat" {
83 | try await Wine.runBatchFile(url: fileURL,
84 | bottle: bottle)
85 | } else {
86 | try await Wine.runProgram(at: fileURL, bottle: bottle)
87 | }
88 | } catch {
89 | print(error)
90 | }
91 | }
92 | dismiss()
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/Whisky/Views/Programs/EnvironmentArgView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EnvironmentArgView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 |
22 | enum KeySection {
23 | case key, value
24 | }
25 |
26 | enum Focusable: Hashable {
27 | case row(id: UUID, section: KeySection)
28 | }
29 |
30 | class Key: Identifiable {
31 | static func == (lhs: Key, rhs: Key) -> Bool {
32 | return lhs.id == rhs.id
33 | }
34 |
35 | var id: UUID = UUID()
36 | @Published var key: String
37 | @Published var value: String
38 |
39 | init(key: String, value: String) {
40 | self.key = key
41 | self.value = value
42 | }
43 | }
44 |
45 | struct EnvironmentArgView: View {
46 | @ObservedObject var program: Program
47 | @Binding var isExpanded: Bool
48 |
49 | @FocusState var focus: Focusable?
50 | @State private var environmentKeys: [Key] = []
51 | @State private var movedToIllegalKey = false
52 |
53 | var body: some View {
54 | Section(isExpanded: $isExpanded) {
55 | List(environmentKeys, id: \.id) { key in
56 | KeyItem(focus: _focus,
57 | environmentKeys: $environmentKeys,
58 | key: key)
59 | }
60 | .alternatingRowBackgrounds(.enabled)
61 | .onAppear {
62 | let keys = program.settings.environment.map { (key: String, value: String) in
63 | return Key(key: key, value: value)
64 | }
65 | environmentKeys = keys.sorted(by: { $0.key < $1.key })
66 | }
67 | .onDisappear {
68 | program.settings.environment.removeAll()
69 | for key in environmentKeys where !key.key.isEmpty {
70 | program.settings.environment[key.key] = key.value
71 | }
72 | }
73 | } header: {
74 | HStack {
75 | Text("program.env").frame(maxWidth: .infinity, alignment: .leading)
76 | .multilineTextAlignment(.leading)
77 | Button("environment.add", systemImage: "plus") {
78 | createNewKey()
79 | }
80 | .buttonStyle(.plain)
81 | .labelStyle(.titleAndIcon)
82 | .opacity(isExpanded ? 1 : 0)
83 | }
84 | }
85 | .onChange(of: focus) { oldValue, newValue in
86 | switch oldValue {
87 | case .row(let id, let section):
88 | if let key = environmentKeys.first(where: { $0.id == id }) {
89 | switch newValue {
90 | case .row(let newId, _):
91 | // Remove empty keys
92 | if key.key.isEmpty && newId != id {
93 | environmentKeys.removeAll(where: { $0.id == key.id })
94 | focus = nil
95 | return
96 | }
97 | case .none: break
98 | }
99 |
100 | // A key with this value already exists, so its invalid
101 | if environmentKeys.contains(where: { $0.key == key.key && $0.id != key.id }) && !movedToIllegalKey {
102 | movedToIllegalKey = true
103 | focus = .row(id: key.id, section: .key)
104 | return
105 | }
106 |
107 | movedToIllegalKey = false
108 |
109 | if newValue == nil && section == .value {
110 | // Value has been submitted, move ot next row
111 | if var index = environmentKeys.firstIndex(where: { $0.id == id }) {
112 | index += 1
113 | if index >= environmentKeys.endIndex {
114 | index = 0
115 | }
116 |
117 | let nextKey = environmentKeys[index]
118 | focus = .row(id: nextKey.id, section: .key)
119 | }
120 | }
121 | }
122 | case .none: break
123 | }
124 | }
125 | }
126 |
127 | func createNewKey() {
128 | if let key = environmentKeys.first(where: { $0.key.isEmpty }) {
129 | focus = .row(id: key.id, section: .key)
130 | return
131 | }
132 |
133 | let key = Key(key: "", value: "")
134 | environmentKeys.append(key)
135 | focus = .row(id: key.id, section: .key)
136 | }
137 | }
138 |
139 | struct KeyItem: View {
140 | @FocusState var focus: Focusable?
141 | @Binding var environmentKeys: [Key]
142 | @State var key: Key
143 | @State var hovered: Bool = false
144 |
145 | var body: some View {
146 | HStack {
147 | TextField(String(), text: $key.key)
148 | .textFieldStyle(.roundedBorder)
149 | .labelsHidden()
150 | .frame(maxHeight: .infinity)
151 | .focused($focus, equals: .row(id: key.id, section: .key))
152 | .onChange(of: key.key) {
153 | key.key = key.key.trimmingCharacters(in: .whitespacesAndNewlines)
154 | }
155 | .onSubmit {
156 | // Try to move on to value
157 | focus = .row(id: key.id, section: .value)
158 | }
159 | TextField(String(), text: $key.value)
160 | .textFieldStyle(.roundedBorder)
161 | .labelsHidden()
162 | .frame(maxHeight: .infinity)
163 | .focused($focus, equals: .row(id: key.id, section: .value))
164 | Button {
165 | environmentKeys.removeAll(where: { $0.id == key.id })
166 | } label: {
167 | Image(systemName: "xmark.circle.fill")
168 | .help("environment.remove")
169 | }
170 | .buttonStyle(.plain)
171 | .foregroundStyle(.secondary)
172 | .opacity(hovered ? 1 : 0)
173 | }
174 | .padding(.vertical, 4)
175 | .onHover { hover in
176 | hovered = hover
177 | }
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/Whisky/Views/Programs/ProgramMenuView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProgramMenuView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 |
22 | struct ProgramMenuView: View {
23 | @ObservedObject var program: Program
24 | @Binding var path: NavigationPath
25 |
26 | var body: some View {
27 | Button("button.run", systemImage: "play") {
28 | program.run()
29 | }
30 | .labelStyle(.titleAndIcon)
31 | Section("program.settings") {
32 | Button("program.config", systemImage: "gearshape") {
33 | path.append(program)
34 | }
35 | .labelStyle(.titleAndIcon)
36 |
37 | let buttonName = program.pinned
38 | ? String(localized: "button.unpin")
39 | : String(localized: "button.pin")
40 |
41 | Button(buttonName, systemImage: "pin") {
42 | program.pinned.toggle()
43 | }
44 | .labelStyle(.titleAndIcon)
45 | .symbolVariant(program.pinned ? .slash : .none)
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Whisky/Views/Programs/ProgramView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProgramView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 | import UniformTypeIdentifiers
22 |
23 | struct ProgramView: View {
24 | @ObservedObject var program: Program
25 | @State var programLoading: Bool = false
26 | @State var cachedIconImage: Image?
27 | @AppStorage("configSectionExapnded") private var configSectionExpanded: Bool = true
28 | @AppStorage("envArgsSectionExpanded") private var envArgsSectionExpanded: Bool = true
29 |
30 | var body: some View {
31 | Form {
32 | Section("program.config", isExpanded: $configSectionExpanded) {
33 | Picker("locale.title", selection: $program.settings.locale) {
34 | ForEach(Locales.allCases, id: \.self) { locale in
35 | Text(locale.pretty()).id(locale)
36 | }
37 | }
38 | VStack {
39 | HStack {
40 | Text("program.args")
41 | Spacer()
42 | }
43 | TextField("program.args", text: $program.settings.arguments)
44 | .textFieldStyle(.roundedBorder)
45 | .font(.system(.body, design: .monospaced))
46 | .labelsHidden()
47 | }
48 | }
49 | EnvironmentArgView(program: program, isExpanded: $envArgsSectionExpanded)
50 | }
51 | .bottomBar {
52 | HStack {
53 | Spacer()
54 | Button("button.showInFinder") {
55 | NSWorkspace.shared.activateFileViewerSelecting([program.url])
56 | }
57 | Button("button.createShortcut") {
58 | let panel = NSSavePanel()
59 | let applicationDir = FileManager.default.urls(for: .applicationDirectory, in: .localDomainMask)[0]
60 | let name = program.name.replacingOccurrences(of: ".exe", with: "")
61 | panel.directoryURL = applicationDir
62 | panel.canCreateDirectories = true
63 | panel.allowedContentTypes = [UTType.applicationBundle]
64 | panel.allowsOtherFileTypes = false
65 | panel.isExtensionHidden = true
66 | panel.nameFieldStringValue = name + ".app"
67 | panel.begin { result in
68 | if result == .OK {
69 | if let url = panel.url {
70 | let name = url.deletingPathExtension().lastPathComponent
71 | Task(priority: .userInitiated) {
72 | await ProgramShortcut.createShortcut(program, app: url, name: name)
73 | }
74 | }
75 | }
76 | }
77 | }
78 | Button("button.run") {
79 | programLoading = true
80 | program.run()
81 | }
82 | .disabled(programLoading)
83 | if programLoading {
84 | Spacer()
85 | .frame(width: 10)
86 | ProgressView()
87 | .controlSize(.small)
88 | }
89 | }
90 | .padding()
91 | }
92 | .toolbar {
93 | if let image = cachedIconImage {
94 | ToolbarItem(id: "ProgramViewIcon", placement: .navigation) {
95 | image
96 | .resizable()
97 | .frame(width: 25, height: 25)
98 | .padding(.trailing, 5)
99 | }
100 | } else {
101 | ToolbarItem(id: "ProgramViewIcon", placement: .navigation) {
102 | Image(systemName: "app.dashed")
103 | .resizable()
104 | .frame(width: 25, height: 25)
105 | .padding(.trailing, 5)
106 | }
107 | }
108 | }
109 | .navigationTitle(program.name)
110 | .formStyle(.grouped)
111 | .animation(.whiskyDefault, value: configSectionExpanded)
112 | .animation(.whiskyDefault, value: envArgsSectionExpanded)
113 | .task {
114 | if let fetchedImage = program.peFile?.bestIcon() { self.cachedIconImage = Image(nsImage: fetchedImage) }
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/Whisky/Views/Settings/SettingsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 |
22 | struct SettingsView: View {
23 | @AppStorage("SUEnableAutomaticChecks") var whiskyUpdate = true
24 | @AppStorage("killOnTerminate") var killOnTerminate = true
25 | @AppStorage("checkWhiskyWineUpdates") var checkWhiskyWineUpdates = true
26 | @AppStorage("defaultBottleLocation") var defaultBottleLocation = BottleData.defaultBottleDir
27 |
28 | var body: some View {
29 | Form {
30 | Section("settings.general") {
31 | Toggle("settings.toggle.kill.on.terminate", isOn: $killOnTerminate)
32 | ActionView(
33 | text: "settings.path",
34 | subtitle: defaultBottleLocation.prettyPath(),
35 | actionName: "create.browse"
36 | ) {
37 | let panel = NSOpenPanel()
38 | panel.canChooseFiles = false
39 | panel.canChooseDirectories = true
40 | panel.allowsMultipleSelection = false
41 | panel.canCreateDirectories = true
42 | panel.directoryURL = BottleData.containerDir
43 | panel.begin { result in
44 | if result == .OK, let url = panel.urls.first {
45 | defaultBottleLocation = url
46 | }
47 | }
48 | }
49 | }
50 | Section("settings.updates") {
51 | Toggle("settings.toggle.whisky.updates", isOn: $whiskyUpdate)
52 | Toggle("settings.toggle.whiskywine.updates", isOn: $checkWhiskyWineUpdates)
53 | }
54 | }
55 | .formStyle(.grouped)
56 | .fixedSize(horizontal: false, vertical: true)
57 | .frame(width: ViewWidth.medium)
58 | }
59 | }
60 |
61 | #Preview {
62 | SettingsView()
63 | }
64 |
--------------------------------------------------------------------------------
/Whisky/Views/Setup/RosettaView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RosettaView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 |
22 | struct RosettaView: View {
23 | @State var installing: Bool = true
24 | @State var successful: Bool = true
25 | @Binding var path: [SetupStage]
26 | @Binding var showSetup: Bool
27 |
28 | var body: some View {
29 | VStack {
30 | Text("setup.rosetta")
31 | .font(.title)
32 | .fontWeight(.bold)
33 | Text("setup.rosetta.subtitle")
34 | .font(.subheadline)
35 | .foregroundStyle(.secondary)
36 | Spacer()
37 | Group {
38 | if installing {
39 | ProgressView()
40 | .scaleEffect(2)
41 | } else {
42 | if successful {
43 | Image(systemName: "checkmark.circle")
44 | .resizable()
45 | .foregroundStyle(.green)
46 | .frame(width: 80, height: 80)
47 | } else {
48 | VStack {
49 | Image(systemName: "xmark.circle")
50 | .resizable()
51 | .foregroundStyle(.red)
52 | .frame(width: 80, height: 80)
53 | .padding(.bottom, 20)
54 | Text("setup.rosetta.fail")
55 | .font(.subheadline)
56 | }
57 | }
58 | }
59 | }
60 | Spacer()
61 | HStack {
62 | if !successful {
63 | Button("setup.quit") {
64 | exit(0)
65 | }
66 | .keyboardShortcut(.cancelAction)
67 | Spacer()
68 | Button("setup.retry") {
69 | installing = true
70 | successful = true
71 |
72 | Task.detached {
73 | await checkOrInstall()
74 | }
75 | }
76 | .keyboardShortcut(.defaultAction)
77 | }
78 | }
79 | }
80 | .frame(width: 400, height: 200)
81 | .onAppear {
82 | Task.detached {
83 | await checkOrInstall()
84 | }
85 | }
86 | }
87 |
88 | func checkOrInstall() async {
89 | if Rosetta2.isRosettaInstalled {
90 | installing = false
91 | sleep(2)
92 | proceed()
93 | } else {
94 | do {
95 | successful = try await Rosetta2.installRosetta()
96 | installing = false
97 | try await Task.sleep(for: .seconds(2))
98 | proceed()
99 | } catch {
100 | successful = false
101 | installing = false
102 | }
103 | }
104 | }
105 |
106 | @MainActor
107 | func proceed() {
108 | if !WhiskyWineInstaller.isWhiskyWineInstalled() {
109 | path.append(.whiskyWineDownload)
110 | return
111 | }
112 |
113 | showSetup = false
114 | }
115 | }
116 |
117 | #Preview {
118 | RosettaView(path: .constant([]), showSetup: .constant(true))
119 | }
120 |
--------------------------------------------------------------------------------
/Whisky/Views/Setup/SetupView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SetupView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 |
21 | enum SetupStage {
22 | case rosetta
23 | case whiskyWineDownload
24 | case whiskyWineInstall
25 | }
26 |
27 | struct SetupView: View {
28 | @State private var path: [SetupStage] = []
29 | @State var tarLocation: URL = URL(fileURLWithPath: "")
30 | @Binding var showSetup: Bool
31 | var firstTime: Bool = true
32 |
33 | var body: some View {
34 | VStack {
35 | NavigationStack(path: $path) {
36 | WelcomeView(path: $path, showSetup: $showSetup, firstTime: firstTime)
37 | .navigationBarBackButtonHidden(true)
38 | .navigationDestination(for: SetupStage.self) { stage in
39 | switch stage {
40 | case .rosetta:
41 | RosettaView(path: $path, showSetup: $showSetup)
42 | case .whiskyWineDownload:
43 | WhiskyWineDownloadView(tarLocation: $tarLocation, path: $path)
44 | case .whiskyWineInstall:
45 | WhiskyWineInstallView(tarLocation: $tarLocation, path: $path, showSetup: $showSetup)
46 | }
47 | }
48 | }
49 | }
50 | .padding()
51 | .interactiveDismissDisabled()
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Whisky/Views/Setup/WelcomeView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WelcomeView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 |
22 | struct WelcomeView: View {
23 | @State var rosettaInstalled: Bool?
24 | @State var whiskyWineInstalled: Bool?
25 | @State var shouldCheckInstallStatus: Bool = false
26 | @Binding var path: [SetupStage]
27 | @Binding var showSetup: Bool
28 | var firstTime: Bool
29 |
30 | var body: some View {
31 | VStack {
32 | VStack {
33 | if firstTime {
34 | Text("setup.welcome")
35 | .font(.title)
36 | .fontWeight(.bold)
37 | Text("setup.welcome.subtitle")
38 | .font(.subheadline)
39 | .foregroundStyle(.secondary)
40 | } else {
41 | Text("setup.title")
42 | .font(.title)
43 | .fontWeight(.bold)
44 | Text("setup.subtitle")
45 | .font(.subheadline)
46 | .foregroundStyle(.secondary)
47 | }
48 | }
49 | .padding(.horizontal)
50 | Spacer()
51 | Form {
52 | InstallStatusView(isInstalled: $rosettaInstalled,
53 | shouldCheckInstallStatus: $shouldCheckInstallStatus,
54 | name: "Rosetta")
55 | InstallStatusView(isInstalled: $whiskyWineInstalled,
56 | shouldCheckInstallStatus: $shouldCheckInstallStatus,
57 | showUninstall: true,
58 | name: "WhiskyWine")
59 | }
60 | .formStyle(.grouped)
61 | .scrollDisabled(true)
62 | .onAppear {
63 | checkInstallStatus()
64 | }
65 | .onChange(of: shouldCheckInstallStatus) {
66 | checkInstallStatus()
67 | }
68 | Spacer()
69 | HStack {
70 | if let rosettaInstalled = rosettaInstalled,
71 | let whiskyWineInstalled = whiskyWineInstalled {
72 | if !rosettaInstalled || !whiskyWineInstalled {
73 | Button("setup.quit") {
74 | exit(0)
75 | }
76 | .keyboardShortcut(.cancelAction)
77 | }
78 | Spacer()
79 | Button(rosettaInstalled && whiskyWineInstalled ? "setup.done" : "setup.next") {
80 | if !rosettaInstalled {
81 | path.append(.rosetta)
82 | return
83 | }
84 |
85 | if !whiskyWineInstalled {
86 | path.append(.whiskyWineDownload)
87 | return
88 | }
89 |
90 | showSetup = false
91 | }
92 | .keyboardShortcut(.defaultAction)
93 | }
94 | }
95 | }
96 | .frame(width: 400, height: 200)
97 | }
98 |
99 | func checkInstallStatus() {
100 | rosettaInstalled = Rosetta2.isRosettaInstalled
101 | whiskyWineInstalled = WhiskyWineInstaller.isWhiskyWineInstalled()
102 | }
103 | }
104 |
105 | struct InstallStatusView: View {
106 | @Binding var isInstalled: Bool?
107 | @Binding var shouldCheckInstallStatus: Bool
108 | @State var showUninstall: Bool = false
109 | @State var name: String
110 | @State var text: String = String(localized: "setup.install.checking")
111 |
112 | var body: some View {
113 | HStack {
114 | Group {
115 | if let installed = isInstalled {
116 | Circle()
117 | .foregroundColor(installed ? .green : .red)
118 | } else {
119 | ProgressView()
120 | .controlSize(.small)
121 | }
122 | }
123 | .frame(width: 10)
124 | Text(String.init(format: text, name))
125 | Spacer()
126 | if let installed = isInstalled {
127 | if installed && showUninstall {
128 | Button("setup.uninstall") {
129 | uninstall()
130 | }
131 | }
132 | }
133 | }
134 | .onChange(of: isInstalled) {
135 | if let installed = isInstalled {
136 | if installed {
137 | text = String(localized: "setup.install.installed")
138 | } else {
139 | text = String(localized: "setup.install.notInstalled")
140 | }
141 | } else {
142 | text = String(localized: "setup.install.checking")
143 | }
144 | }
145 | }
146 |
147 | func uninstall() {
148 | if name == "WhiskyWine" {
149 | WhiskyWineInstaller.uninstall()
150 | }
151 |
152 | shouldCheckInstallStatus.toggle()
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/Whisky/Views/Setup/WhiskyWineDownloadView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WhiskyWineDownloadView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 |
22 | struct WhiskyWineDownloadView: View {
23 | @State private var fractionProgress: Double = 0
24 | @State private var completedBytes: Int64 = 0
25 | @State private var totalBytes: Int64 = 0
26 | @State private var downloadSpeed: Double = 0
27 | @State private var downloadTask: URLSessionDownloadTask?
28 | @State private var observation: NSKeyValueObservation?
29 | @State private var startTime: Date?
30 | @Binding var tarLocation: URL
31 | @Binding var path: [SetupStage]
32 | var body: some View {
33 | VStack {
34 | VStack {
35 | Text("setup.whiskywine.download")
36 | .font(.title)
37 | .fontWeight(.bold)
38 | Text("setup.whiskywine.download.subtitle")
39 | .font(.subheadline)
40 | .foregroundStyle(.secondary)
41 | Spacer()
42 | VStack {
43 | ProgressView(value: fractionProgress, total: 1)
44 | HStack {
45 | HStack {
46 | Text(String(format: String(localized: "setup.whiskywine.progress"),
47 | formatBytes(bytes: completedBytes),
48 | formatBytes(bytes: totalBytes)))
49 | + Text(String(" "))
50 | + (shouldShowEstimate() ?
51 | Text(String(format: String(localized: "setup.whiskywine.eta"),
52 | formatRemainingTime(remainingBytes: totalBytes - completedBytes)))
53 | : Text(String()))
54 | Spacer()
55 | }
56 | .font(.subheadline)
57 | .monospacedDigit()
58 | }
59 | }
60 | .padding(.horizontal)
61 | Spacer()
62 | }
63 | Spacer()
64 | }
65 | .frame(width: 400, height: 200)
66 | .onAppear {
67 | Task {
68 | if let url: URL = URL(string: "https://data.getwhisky.app/Wine/Libraries.tar.gz") {
69 | downloadTask = URLSession(configuration: .ephemeral).downloadTask(with: url) { url, _, _ in
70 | Task.detached {
71 | await MainActor.run {
72 | if let url = url {
73 | tarLocation = url
74 | proceed()
75 | }
76 | }
77 | }
78 | }
79 | observation = downloadTask?.observe(\.countOfBytesReceived) { task, _ in
80 | Task {
81 | await MainActor.run {
82 | let currentTime = Date()
83 | let elapsedTime = currentTime.timeIntervalSince(startTime ?? currentTime)
84 | if completedBytes > 0 {
85 | downloadSpeed = Double(completedBytes) / elapsedTime
86 | }
87 | totalBytes = task.countOfBytesExpectedToReceive
88 | completedBytes = task.countOfBytesReceived
89 | fractionProgress = Double(completedBytes) / Double(totalBytes)
90 | }
91 | }
92 | }
93 | startTime = Date()
94 | downloadTask?.resume()
95 | }
96 | }
97 | }
98 | }
99 |
100 | func formatBytes(bytes: Int64) -> String {
101 | let formatter = ByteCountFormatter()
102 | formatter.countStyle = .file
103 | formatter.zeroPadsFractionDigits = true
104 | return formatter.string(fromByteCount: bytes)
105 | }
106 |
107 | func shouldShowEstimate() -> Bool {
108 | let elapsedTime = Date().timeIntervalSince(startTime ?? Date())
109 | return Int(elapsedTime.rounded()) > 5 && completedBytes != 0
110 | }
111 |
112 | func formatRemainingTime(remainingBytes: Int64) -> String {
113 | let remainingTimeInSeconds = Double(remainingBytes) / downloadSpeed
114 |
115 | let formatter = DateComponentsFormatter()
116 | formatter.allowedUnits = [.hour, .minute, .second]
117 | formatter.unitsStyle = .full
118 | if shouldShowEstimate() {
119 | return formatter.string(from: TimeInterval(remainingTimeInSeconds)) ?? ""
120 | } else {
121 | return ""
122 | }
123 | }
124 |
125 | func proceed() {
126 | path.append(.whiskyWineInstall)
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/Whisky/Views/Setup/WhiskyWineInstallView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WhiskyWineInstallView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import WhiskyKit
21 |
22 | struct WhiskyWineInstallView: View {
23 | @State var installing: Bool = true
24 | @Binding var tarLocation: URL
25 | @Binding var path: [SetupStage]
26 | @Binding var showSetup: Bool
27 |
28 | var body: some View {
29 | VStack {
30 | VStack {
31 | Text("setup.whiskywine.install")
32 | .font(.title)
33 | .fontWeight(.bold)
34 | Text("setup.whiskywine.install.subtitle")
35 | .font(.subheadline)
36 | .foregroundStyle(.secondary)
37 | Spacer()
38 | if installing {
39 | ProgressView()
40 | .progressViewStyle(.circular)
41 | .frame(width: 80)
42 | } else {
43 | Image(systemName: "checkmark.circle")
44 | .resizable()
45 | .frame(width: 80, height: 80)
46 | .foregroundStyle(.green)
47 | }
48 | Spacer()
49 | }
50 | Spacer()
51 | }
52 | .frame(width: 400, height: 200)
53 | .onAppear {
54 | Task.detached {
55 | await WhiskyWineInstaller.install(from: tarLocation)
56 | await MainActor.run {
57 | installing = false
58 | }
59 | sleep(2)
60 | await proceed()
61 | }
62 | }
63 | }
64 |
65 | @MainActor
66 | func proceed() {
67 | showSetup = false
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Whisky/Views/SparkleView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SparkleView.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import SwiftUI
20 | import Sparkle
21 |
22 | struct SparkleView: View {
23 | @ObservedObject private var checkForUpdatesViewModel: CheckForUpdatesViewModel
24 | private let updater: SPUUpdater
25 |
26 | init(updater: SPUUpdater) {
27 | self.updater = updater
28 | self.checkForUpdatesViewModel = CheckForUpdatesViewModel(updater: updater)
29 | }
30 |
31 | var body: some View {
32 | Button("check.updates", action: updater.checkForUpdates)
33 | .disabled(!checkForUpdatesViewModel.canCheckForUpdates)
34 | }
35 | }
36 |
37 | // This view model class publishes when new updates can be checked by the user
38 | final class CheckForUpdatesViewModel: ObservableObject {
39 | @Published var canCheckForUpdates = false
40 |
41 | init(updater: SPUUpdater) {
42 | updater.publisher(for: \.canCheckForUpdates)
43 | .assign(to: &$canCheckForUpdates)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Whisky/Whisky.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.automation.apple-events
6 |
7 | com.apple.security.cs.allow-unsigned-executable-memory
8 |
9 | com.apple.security.device.audio-input
10 |
11 | com.apple.security.device.camera
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/WhiskyKit/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/WhiskyKit/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/WhiskyKit/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "semanticversion",
5 | "kind" : "remoteSourceControl",
6 | "location" : "git@github.com:SwiftPackageIndex/SemanticVersion.git",
7 | "state" : {
8 | "revision" : "45e2ec89fee3b76cd6dde3f9a507e4348f194b79",
9 | "version" : "0.3.7"
10 | }
11 | }
12 | ],
13 | "version" : 2
14 | }
15 |
--------------------------------------------------------------------------------
/WhiskyKit/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | //
3 | // PortableExecutable.swift
4 | // WhiskyKit
5 | //
6 | // This file is part of Whisky.
7 | //
8 | // Whisky is free software: you can redistribute it and/or modify it under the terms
9 | // of the GNU General Public License as published by the Free Software Foundation,
10 | // either version 3 of the License, or (at your option) any later version.
11 | //
12 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
13 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
14 | // See the GNU General Public License for more details.
15 | //
16 | // You should have received a copy of the GNU General Public License along with Whisky.
17 | // If not, see https://www.gnu.org/licenses/.
18 | //
19 |
20 | import PackageDescription
21 |
22 | let package = Package(
23 | name: "WhiskyKit",
24 | platforms: [
25 | .macOS(.v14)
26 | ],
27 | products: [
28 | .library(
29 | name: "WhiskyKit",
30 | targets: ["WhiskyKit"]
31 | )
32 | ],
33 | dependencies: [
34 | .package(url: "git@github.com:SwiftPackageIndex/SemanticVersion.git", from: "0.3.0")
35 | ],
36 | targets: [
37 | .target(
38 | name: "WhiskyKit",
39 | dependencies: ["SemanticVersion"]
40 | )
41 | ],
42 | swiftLanguageVersions: [.version("6")]
43 | )
44 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/Extensions/Bundle+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Bundle+Extension.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 |
21 | public extension Bundle {
22 | static var whiskyBundleIdentifier: String {
23 | return Bundle.main.bundleIdentifier ?? "com.isaacmarovitz.Whisky"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/Extensions/FileHandle+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FileHandle+Extensions.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import os.log
21 | import SemanticVersion
22 |
23 | extension FileHandle {
24 | func extract(_ type: T.Type, offset: UInt64 = 0) -> T? {
25 | do {
26 | try self.seek(toOffset: offset)
27 | if let data = try self.read(upToCount: MemoryLayout.size) {
28 | return data.withUnsafeBytes { $0.loadUnaligned(as: T.self)}
29 | } else {
30 | return nil
31 | }
32 | } catch {
33 | return nil
34 | }
35 | }
36 |
37 | func write(line: String) {
38 | do {
39 | guard let data = line.data(using: .utf8) else { return }
40 | try write(contentsOf: data)
41 | } catch {
42 | Logger.wineKit.info("Failed to write line: \(error)")
43 | }
44 | }
45 |
46 | // swiftlint:disable line_length
47 | func writeApplicaitonInfo() {
48 | var header = String()
49 | let macOSVersion = ProcessInfo.processInfo.operatingSystemVersion
50 |
51 | header += "Whisky Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "")\n"
52 | header += "Date: \(ISO8601DateFormatter().string(from: Date.now))\n"
53 | header += "macOS Version: \(macOSVersion.majorVersion).\(macOSVersion.minorVersion).\(macOSVersion.patchVersion)\n\n"
54 | write(line: header)
55 | }
56 | // swiftlint:enable line_length
57 |
58 | func writeInfo(for process: Process) {
59 | var header = String()
60 |
61 | if let arguments = process.arguments {
62 | header += "Arguments: \(arguments.joined(separator: " "))\n\n"
63 | }
64 |
65 | if let environment = process.environment, !environment.isEmpty {
66 | header += "Environment:\n\(environment as AnyObject)\n\n"
67 | }
68 |
69 | write(line: header)
70 | }
71 |
72 | func writeInfo(for bottle: Bottle) {
73 | var header = String()
74 | header += "Bottle Name: \(bottle.settings.name)\n"
75 | header += "Bottle URL: \(bottle.url.path)\n\n"
76 |
77 | if let version = WhiskyWineInstaller.whiskyWineVersion() {
78 | header += "WhiskyWine Version: \(version.major).\(version.minor).\(version.patch)\n"
79 | }
80 | header += "Windows Version: \(bottle.settings.windowsVersion)\n"
81 | header += "Enhanced Sync: \(bottle.settings.enhancedSync)\n\n"
82 |
83 | header += "Metal HUD: \(bottle.settings.metalHud)\n"
84 | header += "Metal Trace: \(bottle.settings.metalTrace)\n\n"
85 |
86 | if bottle.settings.dxvk {
87 | header += "DXVK: \(bottle.settings.dxvk)\n"
88 | header += "DXVK Async: \(bottle.settings.dxvkAsync)\n"
89 | header += "DXVK HUD: \(bottle.settings.dxvkHud)\n\n"
90 | }
91 |
92 | write(line: header)
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/Extensions/FileManager+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FileHandle+Extensions.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 |
21 | extension FileManager {
22 | func replaceDLLs(
23 | in destinationDirectory: URL, withContentsIn sourceDirectory: URL, makeOriginalCopy: Bool = false
24 | ) throws {
25 | let enumerator = FileManager.default.enumerator(
26 | at: sourceDirectory, includingPropertiesForKeys: [.isRegularFileKey])
27 |
28 | while let fileURL = enumerator?.nextObject() as? URL {
29 | guard fileURL.pathExtension == "dll" else { return }
30 | let originalURL = destinationDirectory.appending(path: fileURL.lastPathComponent)
31 | try FileManager.default.replaceFile(at: originalURL, with: fileURL, makeOriginalCopy: makeOriginalCopy)
32 | }
33 | }
34 |
35 | func replaceFile(at originalURL: URL, with replacementURL: URL, makeOriginalCopy: Bool = true) throws {
36 | if fileExists(atPath: originalURL.path(percentEncoded: false)) {
37 | if makeOriginalCopy {
38 | let copyURL = originalURL.appendingPathExtension("orig")
39 |
40 | if fileExists(atPath: copyURL.path(percentEncoded: false)) {
41 | try FileManager.default.removeItem(at: copyURL)
42 | }
43 |
44 | try FileManager.default.moveItem(at: originalURL, to: copyURL)
45 | } else {
46 | try FileManager.default.removeItem(at: originalURL)
47 | }
48 |
49 | try FileManager.default.copyItem(at: replacementURL, to: originalURL)
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/Extensions/Logger+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Logger+Extensions.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import os.log
21 |
22 | public extension Logger {
23 | /// A global logger for WineKit
24 | static let wineKit = Logger(
25 | subsystem: Bundle.whiskyBundleIdentifier, category: "WineKit"
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/Extensions/Process+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Process+Extensions.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import os.log
21 |
22 | public enum ProcessOutput: Hashable {
23 | case started(Process)
24 | case message(String)
25 | case error(String)
26 | case terminated(Process)
27 | }
28 |
29 | public extension Process {
30 | /// Run the process returning a stream output
31 | func runStream(name: String, fileHandle: FileHandle?) throws -> AsyncStream {
32 | let stream = makeStream(name: name, fileHandle: fileHandle)
33 | self.logProcessInfo(name: name)
34 | fileHandle?.writeInfo(for: self)
35 | try run()
36 | return stream
37 | }
38 |
39 | private func makeStream(name: String, fileHandle: FileHandle?) -> AsyncStream {
40 | let pipe = Pipe()
41 | let errorPipe = Pipe()
42 | standardOutput = pipe
43 | standardError = errorPipe
44 |
45 | return AsyncStream { continuation in
46 | continuation.onTermination = { termination in
47 | switch termination {
48 | case .finished:
49 | break
50 | case .cancelled:
51 | guard self.isRunning else { return }
52 | self.terminate()
53 | @unknown default:
54 | break
55 | }
56 | }
57 |
58 | continuation.yield(.started(self))
59 |
60 | pipe.fileHandleForReading.readabilityHandler = { pipe in
61 | guard let line = pipe.nextLine() else { return }
62 | continuation.yield(.message(line))
63 | guard !line.isEmpty else { return }
64 | Logger.wineKit.info("\(line, privacy: .public)")
65 | fileHandle?.write(line: line)
66 | }
67 |
68 | errorPipe.fileHandleForReading.readabilityHandler = { pipe in
69 | guard let line = pipe.nextLine() else { return }
70 | continuation.yield(.error(line))
71 | guard !line.isEmpty else { return }
72 | Logger.wineKit.warning("\(line, privacy: .public)")
73 | fileHandle?.write(line: line)
74 | }
75 |
76 | terminationHandler = { (process: Process) in
77 | do {
78 | _ = try pipe.fileHandleForReading.readToEnd()
79 | _ = try errorPipe.fileHandleForReading.readToEnd()
80 | try fileHandle?.close()
81 | } catch {
82 | Logger.wineKit.error("Error while clearing data: \(error)")
83 | }
84 |
85 | process.logTermination(name: name)
86 | continuation.yield(.terminated(process))
87 | continuation.finish()
88 | }
89 | }
90 | }
91 |
92 | private func logTermination(name: String) {
93 | if terminationStatus == 0 {
94 | Logger.wineKit.info(
95 | "Terminated \(name) with status code '\(self.terminationStatus, privacy: .public)'"
96 | )
97 | } else {
98 | Logger.wineKit.warning(
99 | "Terminated \(name) with status code '\(self.terminationStatus, privacy: .public)'"
100 | )
101 | }
102 | }
103 |
104 | private func logProcessInfo(name: String) {
105 | Logger.wineKit.info("Running process \(name)")
106 |
107 | if let arguments = arguments {
108 | Logger.wineKit.info("Arguments: `\(arguments.joined(separator: " "))`")
109 | }
110 | if let executableURL = executableURL {
111 | Logger.wineKit.info("Executable: `\(executableURL.path(percentEncoded: false))`")
112 | }
113 | if let directory = currentDirectoryURL {
114 | Logger.wineKit.info("Directory: `\(directory.path(percentEncoded: false))`")
115 | }
116 | if let environment = environment {
117 | Logger.wineKit.info("Environment: \(environment)")
118 | }
119 | }
120 | }
121 |
122 | extension FileHandle {
123 | func nextLine() -> String? {
124 | guard let line = String(data: availableData, encoding: .utf8) else { return nil }
125 | if !line.isEmpty {
126 | return line
127 | } else {
128 | return nil
129 | }
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/Extensions/Program+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Program+Extensions.swift
3 | // Whisky
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import AppKit
21 | import os.log
22 |
23 | extension Program {
24 | public func run() {
25 | if NSEvent.modifierFlags.contains(.shift) {
26 | self.runInTerminal()
27 | } else {
28 | self.runInWine()
29 | }
30 | }
31 |
32 | func runInWine() {
33 | let arguments = settings.arguments.split { $0.isWhitespace }.map(String.init)
34 | let environment = generateEnvironment()
35 |
36 | Task.detached(priority: .userInitiated) {
37 | do {
38 | try await Wine.runProgram(
39 | at: self.url, args: arguments, bottle: self.bottle, environment: environment
40 | )
41 | } catch {
42 | await MainActor.run {
43 | self.showRunError(message: error.localizedDescription)
44 | }
45 | }
46 | }
47 | }
48 |
49 | public func generateTerminalCommand() -> String {
50 | return Wine.generateRunCommand(
51 | at: self.url, bottle: bottle, args: settings.arguments, environment: generateEnvironment()
52 | )
53 | }
54 |
55 | public func runInTerminal() {
56 | let wineCmd = generateTerminalCommand().replacingOccurrences(of: "\\", with: "\\\\")
57 |
58 | let script = """
59 | tell application "Terminal"
60 | activate
61 | do script "\(wineCmd)"
62 | end tell
63 | """
64 |
65 | Task.detached(priority: .userInitiated) {
66 | var error: NSDictionary?
67 | guard let appleScript = NSAppleScript(source: script) else { return }
68 | appleScript.executeAndReturnError(&error)
69 |
70 | if let error = error {
71 | Logger.wineKit.error("Failed to run terminal script \(error)")
72 | guard let description = error["NSAppleScriptErrorMessage"] as? String else { return }
73 | await self.showRunError(message: String(describing: description))
74 | }
75 | }
76 | }
77 |
78 | @MainActor private func showRunError(message: String) {
79 | let alert = NSAlert()
80 | alert.messageText = String(localized: "alert.message")
81 | alert.informativeText = String(localized: "alert.info")
82 | + " \(self.url.lastPathComponent): "
83 | + message
84 | alert.alertStyle = .critical
85 | alert.addButton(withTitle: String(localized: "button.ok"))
86 | alert.runModal()
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/Extensions/URL+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URL+Extensions.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 |
21 | extension String {
22 | public var esc: String {
23 | let esc = ["\\", "\"", "'", " ", "(", ")", "[", "]", "{", "}", "&", "|",
24 | ";", "<", ">", "`", "$", "!", "*", "?", "#", "~", "="]
25 | var str = self
26 | for char in esc {
27 | str = str.replacingOccurrences(of: char, with: "\\" + char)
28 | }
29 | return str
30 | }
31 | }
32 |
33 | extension URL {
34 | public var esc: String {
35 | path.esc
36 | }
37 |
38 | public func prettyPath() -> String {
39 | var prettyPath = path(percentEncoded: false)
40 | prettyPath = prettyPath
41 | .replacingOccurrences(of: Bundle.main.bundleIdentifier ?? Bundle.whiskyBundleIdentifier, with: "Whisky")
42 | .replacingOccurrences(of: "/Users/\(NSUserName())", with: "~")
43 | return prettyPath
44 | }
45 |
46 | // NOT to be used for logic only as UI decoration
47 | public func prettyPath(_ bottle: Bottle) -> String {
48 | var prettyPath = path(percentEncoded: false)
49 | prettyPath = prettyPath
50 | .replacingOccurrences(of: bottle.url.path(percentEncoded: false), with: "")
51 | .replacingOccurrences(of: "/drive_c/", with: "C:\\")
52 | .replacingOccurrences(of: "/", with: "\\")
53 | return prettyPath
54 | }
55 |
56 | // There is probably a better way to do this
57 | public func updateParentBottle(old: URL, new: URL) -> URL {
58 | let originalPath = path(percentEncoded: false)
59 |
60 | var oldBottlePath = old.path(percentEncoded: false)
61 | if oldBottlePath.last != "/" {
62 | oldBottlePath += "/"
63 | }
64 |
65 | var newBottlePath = new.path(percentEncoded: false)
66 | if newBottlePath.last != "/" {
67 | newBottlePath += "/"
68 | }
69 |
70 | let newPath = originalPath.replacingOccurrences(of: oldBottlePath,
71 | with: newBottlePath)
72 | return URL(filePath: newPath)
73 | }
74 | }
75 |
76 | extension URL: @retroactive Identifiable {
77 | public var id: URL { self }
78 | }
79 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/PE/COFFFileHeader.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PortableExecutable+COFFFileHeader.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 |
21 | extension PEFile {
22 | /// COFF File Header (Object and Image)
23 | ///
24 | /// https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#coff-file-header-object-and-image
25 | public struct COFFFileHeader: Hashable, Equatable, Sendable {
26 | public let machine: UInt16
27 | public let numberOfSections: UInt16
28 | public let timeDateStamp: Date
29 | public let pointerToSymbolTable: UInt32
30 | public let numberOfSymbols: UInt32
31 | public let sizeOfOptionalHeader: UInt16
32 | public let characteristics: UInt16
33 |
34 | init(handle: FileHandle, offset: UInt64) {
35 | var offset = offset + 4 // Skip signature
36 |
37 | self.machine = handle.extract(UInt16.self, offset: offset) ?? 0
38 | offset += 2
39 |
40 | self.numberOfSections = handle.extract(UInt16.self, offset: offset) ?? 0
41 | offset += 2
42 |
43 | let timeDateStamp = handle.extract(UInt32.self, offset: offset) ?? 0
44 | self.timeDateStamp = Date(timeIntervalSince1970: TimeInterval(timeDateStamp))
45 | offset += 4
46 |
47 | self.pointerToSymbolTable = handle.extract(UInt32.self, offset: offset) ?? 0
48 | offset += 4
49 |
50 | self.numberOfSymbols = handle.extract(UInt32.self, offset: offset) ?? 0
51 | offset += 4
52 |
53 | self.sizeOfOptionalHeader = handle.extract(UInt16.self, offset: offset) ?? 0
54 | offset += 2
55 |
56 | self.characteristics = handle.extract(UInt16.self, offset: offset) ?? 0
57 | offset += 2
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/PE/Magic.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PortableExecutable+Magic.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 |
21 | extension PEFile {
22 | public enum Magic: UInt16, Hashable, Equatable, CustomStringConvertible, Sendable {
23 | case unknown = 0x0
24 | case pe32 = 0x10b
25 | case pe32Plus = 0x20b
26 |
27 | // MARK: - CustomStringConvertible
28 |
29 | public var description: String {
30 | switch self {
31 | case .unknown:
32 | return "unknown"
33 | case .pe32:
34 | return "PE32"
35 | case .pe32Plus:
36 | return "PE32+"
37 | }
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/PE/RSRC/ResourceDataEntry.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ResourceDataEntry.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 |
21 | /// Each Resource Data entry describes an actual unit of raw data in the Resource Data area
22 | ///
23 | /// https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#resource-data-entry
24 | public struct ResourceDataEntry: Hashable, Equatable {
25 | public let dataRVA: UInt32
26 | public let size: UInt32
27 | public let codePage: UInt32
28 |
29 | init?(handle: FileHandle, offset: UInt64) {
30 | var offset = offset
31 | self.dataRVA = handle.extract(UInt32.self, offset: offset) ?? 0
32 | offset += 4
33 | self.size = handle.extract(UInt32.self, offset: offset) ?? 0
34 | offset += 4
35 | self.codePage = handle.extract(UInt32.self, offset: offset) ?? 0
36 | offset += 4
37 | let reserved = handle.extract(UInt32.self, offset: offset) ?? 0
38 | offset += 4
39 | guard reserved == 0 else { return nil }
40 | }
41 |
42 | public func resolveRVA(sections: [PEFile.Section]) -> UInt32? {
43 | sections
44 | .first { section in
45 | section.virtualAddress <= dataRVA && dataRVA < (section.virtualAddress + section.virtualSize)
46 | }
47 | .map { section in
48 | section.pointerToRawData + (dataRVA - section.virtualAddress)
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/PE/RSRC/ResourceDirectoryEntry.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ResourceDirectoryEntry.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 |
21 | /// The directory entries make up the rows of a table.
22 | ///
23 | /// https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#resource-directory-entries
24 | public enum ResourceDirectoryEntry {
25 | public struct ID { // swiftlint:disable:this type_name
26 | public let type: ResourceType
27 | private let rawOffset: UInt32
28 |
29 | init(handle: FileHandle, offset: UInt64) {
30 | var offset = offset
31 | let rawType = handle.extract(UInt32.self, offset: offset) ?? 0
32 | self.type = ResourceType(rawValue: rawType) ?? .unknown
33 | offset += 4
34 | self.rawOffset = handle.extract(UInt32.self, offset: offset) ?? 0
35 | offset += 4
36 | }
37 |
38 | /// Check if the entry is a directory entry
39 | var isDirectory: Bool {
40 | (rawOffset & 0x80000000) != 0
41 | }
42 |
43 | /// The offset of the entry
44 | var offset: UInt32 {
45 | if isDirectory {
46 | return rawOffset & 0x7FFFFFFF
47 | } else {
48 | return rawOffset
49 | }
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/PE/RSRC/ResourceDirectoryTable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ResourceDirectoryTable.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import SemanticVersion
21 |
22 | /// This data structure should be considered the heading of a table,
23 | /// because the table actually consists of directory entries
24 | ///
25 | /// https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#resource-directory-table
26 | public struct ResourceDirectoryTable: Hashable, Equatable {
27 | public let characteristics: UInt32
28 | public let timeDateStamp: Date
29 | public let version: SemanticVersion
30 | public let numberOfNameEntries: UInt16
31 | public let numberOfIdEntries: UInt16
32 |
33 | public let subtables: [ResourceDirectoryTable]
34 | public let entries: [ResourceDataEntry]
35 |
36 | /// Read the Resource Directory Table
37 | ///
38 | /// - Parameters:
39 | /// - fileHandle: The file handle to read the data from.
40 | /// - pointerToRawData: The offset to the Resource Directory Table in the file handle.
41 | /// - types: Only read entrys of the given types. Only applies to the root table. Defaults to `nil`.
42 | init(handle: FileHandle, pointerToRawData: UInt64, types: [ResourceType]?) {
43 | self.init(handle: handle, pointerToRawData: pointerToRawData, offset: 0, types: types)
44 | }
45 |
46 | /// Read the Resource Directory Table
47 | ///
48 | /// - Parameters:
49 | /// - fileHandle: The file handle to read the data from.
50 | /// - pointerToRawData: The offset to the Resource Directory Table in the file handle.
51 | /// - offset: Additional offset to the `pointerToRawData`.
52 | /// Use only for sub-tables. The root-table has the offset 0.
53 | /// - types: Only read entrys of the given types. Only applies to the root table. Defaults to `nil`.
54 | init(
55 | handle: FileHandle,
56 | pointerToRawData: UInt64,
57 | offset initialOffset: UInt64,
58 | types: [ResourceType]? = nil
59 | ) {
60 | var offset = pointerToRawData + initialOffset
61 | self.characteristics = handle.extract(UInt32.self, offset: offset) ?? 0
62 | offset += 4
63 | let timeDateStamp = handle.extract(UInt32.self, offset: offset) ?? 0
64 | self.timeDateStamp = Date(timeIntervalSince1970: TimeInterval(timeDateStamp))
65 | offset += 4
66 | let majorVersion = handle.extract(UInt16.self, offset: offset) ?? 0
67 | offset += 2
68 | let minorVersion = handle.extract(UInt16.self, offset: offset) ?? 0
69 | offset += 2
70 | self.version = SemanticVersion(Int(majorVersion), Int(minorVersion), 0)
71 | let numberOfNameEntries = handle.extract(UInt16.self, offset: offset) ?? 0
72 | self.numberOfNameEntries = numberOfNameEntries
73 | offset += 2
74 | let numberOfIdEntries = handle.extract(UInt16.self, offset: offset) ?? 0
75 | self.numberOfIdEntries = numberOfIdEntries
76 | offset += 2
77 |
78 | var subtables: [ResourceDirectoryTable] = []
79 | var entries: [ResourceDataEntry] = []
80 |
81 | for _ in 0.. Program? {
24 | var offset: UInt64 = 0
25 | let headerSize = handle.extract(UInt32.self) ?? 0
26 | // Move past headerSize and linkCLSID
27 | offset += 4 + 16
28 | let rawLinkFlags = handle.extract(UInt32.self, offset: offset) ?? 0
29 | let linkFlags = LinkFlags(rawValue: rawLinkFlags)
30 |
31 | offset = UInt64(headerSize)
32 | if linkFlags.contains(.hasLinkTargetIDList) {
33 | // We don't need this section so just get the size, and skip ahead
34 | offset += UInt64(handle.extract(UInt16.self, offset: offset) ?? 0) + 2
35 | }
36 |
37 | if linkFlags.contains(.hasLinkInfo) {
38 | let linkInfo = LinkInfo(handle: handle,
39 | bottle: bottle,
40 | offset: &offset)
41 | return linkInfo.program
42 | } else {
43 | return nil
44 | }
45 | }
46 | }
47 |
48 | public struct LinkFlags: OptionSet, Hashable, Sendable {
49 | public let rawValue: UInt32
50 |
51 | public init(rawValue: UInt32) {
52 | self.rawValue = rawValue
53 | }
54 |
55 | static let hasLinkTargetIDList = LinkFlags(rawValue: 1 << 0)
56 | static let hasLinkInfo = LinkFlags(rawValue: 1 << 1)
57 | static let hasIconLocation = LinkFlags(rawValue: 1 << 6)
58 | }
59 |
60 | public struct LinkInfo: Hashable {
61 | public var linkInfoFlags: LinkInfoFlags
62 | public var program: Program?
63 |
64 | public init(handle: FileHandle, bottle: Bottle, offset: inout UInt64) {
65 | let startOfSection = offset
66 |
67 | let linkInfoSize = handle.extract(UInt32.self, offset: offset) ?? 0
68 |
69 | offset += 4
70 | let linkInfoHeaderSize = handle.extract(UInt32.self, offset: offset) ?? 0
71 |
72 | offset += 4
73 | let rawLinkInfoFlags = handle.extract(UInt32.self, offset: offset) ?? 0
74 | linkInfoFlags = LinkInfoFlags(rawValue: rawLinkInfoFlags)
75 |
76 | if linkInfoFlags.contains(.volumeIDAndLocalBasePath) {
77 | if linkInfoHeaderSize >= 0x00000024 {
78 | offset += 20
79 | let localBasePathOffsetUnicode = handle.extract(UInt32.self, offset: offset) ?? 0
80 | let localPathOffset = startOfSection + UInt64(localBasePathOffsetUnicode)
81 |
82 | program = getProgram(handle: handle,
83 | offset: localPathOffset,
84 | bottle: bottle,
85 | unicode: true)
86 | } else {
87 | offset += 8
88 | let localBasePathOffset = handle.extract(UInt32.self, offset: offset) ?? 0
89 | let localPathOffset = startOfSection + UInt64(localBasePathOffset)
90 |
91 | program = getProgram(handle: handle,
92 | offset: localPathOffset,
93 | bottle: bottle,
94 | unicode: false)
95 | }
96 | }
97 |
98 | offset = startOfSection + UInt64(linkInfoSize)
99 | }
100 |
101 | func getProgram(handle: FileHandle, offset: UInt64, bottle: Bottle, unicode: Bool) -> Program? {
102 | do {
103 | try handle.seek(toOffset: offset)
104 | if let pathData = try handle.readToEnd() {
105 | if let nullRange = pathData.firstIndex(of: 0) {
106 | let encoding: String.Encoding = unicode ? .utf16 : .windowsCP1254
107 | if var string = String(data: pathData[.. Bool {
30 | let process = Process()
31 | let fileHandle = try Wine.makeFileHandle()
32 |
33 | process.launchPath = "/usr/sbin/softwareupdate"
34 | process.arguments = ["--install-rosetta", "--agree-to-license"]
35 | process.standardOutput = fileHandle
36 | process.standardError = fileHandle
37 | fileHandle.writeApplicaitonInfo()
38 | fileHandle.writeInfo(for: process)
39 |
40 | return try await withCheckedThrowingContinuation { continuation in
41 | process.terminationHandler = { (process: Process) in
42 | do {
43 | try fileHandle.close()
44 | continuation.resume(returning: process.terminationStatus == 0)
45 | } catch {
46 | Logger.wineKit.error("Error while closing file handle: \(error)")
47 | continuation.resume(throwing: error)
48 | }
49 | }
50 |
51 | do {
52 | try process.run()
53 | } catch {
54 | continuation.resume(throwing: error)
55 | }
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/Whisky/Bottle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Bottle.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import SwiftUI
21 | import os.log
22 |
23 | // swiftlint:disable:next todo
24 | // TODO: Should not be unchecked!
25 | public final class Bottle: ObservableObject, Equatable, Hashable, Identifiable, Comparable, @unchecked Sendable {
26 | public let url: URL
27 | private let metadataURL: URL
28 | @Published public var settings: BottleSettings {
29 | didSet { saveSettings() }
30 | }
31 | @Published public var programs: [Program] = []
32 | @Published public var inFlight: Bool = false
33 | public var isAvailable: Bool = false
34 |
35 | /// All pins with their associated programs
36 | public var pinnedPrograms: [(pin: PinnedProgram, program: Program, // swiftlint:disable:this large_tuple
37 | id: String)] {
38 | return settings.pins.compactMap { pin in
39 | let exists = FileManager.default.fileExists(atPath: pin.url?.path(percentEncoded: false) ?? "")
40 | guard let program = programs.first(where: { $0.url == pin.url && exists }) else { return nil }
41 | return (pin, program, "\(pin.name)//\(program.url)")
42 | }
43 | }
44 |
45 | public init(bottleUrl: URL, inFlight: Bool = false, isAvailable: Bool = false) {
46 | let metadataURL = bottleUrl.appending(path: "Metadata").appendingPathExtension("plist")
47 | self.url = bottleUrl
48 | self.inFlight = inFlight
49 | self.isAvailable = isAvailable
50 | self.metadataURL = metadataURL
51 |
52 | do {
53 | self.settings = try BottleSettings.decode(from: metadataURL)
54 | } catch {
55 | Logger.wineKit.error(
56 | "Failed to load settings for bottle `\(metadataURL.path(percentEncoded: false))`: \(error)"
57 | )
58 | self.settings = BottleSettings()
59 | }
60 |
61 | // Get rid of duplicates and pins that reference removed files
62 | var found: Set = []
63 | self.settings.pins = self.settings.pins.filter { pin in
64 | guard let url = pin.url else { return false }
65 | guard !found.contains(url) else { return false }
66 | found.insert(url)
67 | let urlPath = url.path(percentEncoded: false)
68 | let volume: URL?
69 | do {
70 | volume = try url.resourceValues(forKeys: [.volumeURLKey]).volume ?? nil
71 | } catch {
72 | volume = nil
73 | }
74 | let legallyRemoved = pin.removable && volume == nil
75 | return FileManager.default.fileExists(atPath: urlPath) || legallyRemoved
76 | }
77 | }
78 |
79 | /// Encode and save the bottle settings
80 | private func saveSettings() {
81 | do {
82 | try settings.encode(to: self.metadataURL)
83 | } catch {
84 | Logger.wineKit.error(
85 | "Failed to encode settings for bottle `\(self.metadataURL.path(percentEncoded: false))`: \(error)"
86 | )
87 | }
88 | }
89 |
90 | // MARK: - Equatable
91 |
92 | public static func == (lhs: Bottle, rhs: Bottle) -> Bool {
93 | return lhs.url == rhs.url
94 | }
95 |
96 | // MARK: - Hashable
97 |
98 | public func hash(into hasher: inout Hasher) {
99 | return hasher.combine(url)
100 | }
101 |
102 | // MARK: - Identifiable
103 |
104 | public var id: URL {
105 | self.url
106 | }
107 |
108 | // MARK: - Comparable
109 |
110 | public static func < (lhs: Bottle, rhs: Bottle) -> Bool {
111 | lhs.settings.name.lowercased() < rhs.settings.name.lowercased()
112 | }
113 | }
114 |
115 | public extension Sequence where Iterator.Element == Program {
116 | /// Filter all pinned programs
117 | var pinned: [Program] {
118 | return self.filter({ $0.pinned })
119 | }
120 |
121 | /// Filter all unpinned programs
122 | var unpinned: [Program] {
123 | return self.filter({ !$0.pinned })
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/Whisky/BottleData.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BottleData.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import SemanticVersion
21 |
22 | public struct BottleData: Codable {
23 | public static let containerDir = FileManager.default.homeDirectoryForCurrentUser
24 | .appending(path: "Library")
25 | .appending(path: "Containers")
26 | .appending(path: Bundle.whiskyBundleIdentifier)
27 |
28 | public static let bottleEntriesDir = containerDir
29 | .appending(path: "BottleVM")
30 | .appendingPathExtension("plist")
31 |
32 | public static let defaultBottleDir = containerDir
33 | .appending(path: "Bottles")
34 |
35 | static let currentVersion = SemanticVersion(1, 0, 0)
36 |
37 | private var fileVersion: SemanticVersion
38 | public var paths: [URL] = [] {
39 | didSet {
40 | encode()
41 | }
42 | }
43 |
44 | public init() {
45 | fileVersion = Self.currentVersion
46 |
47 | if !decode() {
48 | encode()
49 | }
50 | }
51 |
52 | public mutating func loadBottles() -> [Bottle] {
53 | var bottles: [Bottle] = []
54 |
55 | for path in paths {
56 | let bottleMetadata = path
57 | .appending(path: "Metadata")
58 | .appendingPathExtension("plist")
59 | .path(percentEncoded: false)
60 |
61 | if FileManager.default.fileExists(atPath: bottleMetadata) {
62 | bottles.append(Bottle(bottleUrl: path, isAvailable: true))
63 | } else {
64 | bottles.append(Bottle(bottleUrl: path))
65 | }
66 | }
67 |
68 | return bottles
69 | }
70 |
71 | @discardableResult
72 | private mutating func decode() -> Bool {
73 | let decoder = PropertyListDecoder()
74 | do {
75 | let data = try Data(contentsOf: Self.bottleEntriesDir)
76 | self = try decoder.decode(BottleData.self, from: data)
77 | if self.fileVersion != Self.currentVersion {
78 | print("Invalid file version \(self.fileVersion)")
79 | return false
80 | }
81 | return true
82 | } catch {
83 | return false
84 | }
85 | }
86 |
87 | @discardableResult
88 | private func encode() -> Bool {
89 | let encoder = PropertyListEncoder()
90 | encoder.outputFormat = .xml
91 |
92 | do {
93 | try FileManager.default.createDirectory(at: Self.containerDir, withIntermediateDirectories: true)
94 | let data = try encoder.encode(self)
95 | try data.write(to: Self.bottleEntriesDir)
96 | return true
97 | } catch {
98 | return false
99 | }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/Whisky/Program.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Program.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import SwiftUI
21 | import os.log
22 |
23 | // swiftlint:disable:next todo
24 | // TODO: Should not be unchecked!
25 | public final class Program: ObservableObject, Equatable, Hashable, Identifiable, @unchecked Sendable {
26 | public let bottle: Bottle
27 | public let url: URL
28 | public let settingsURL: URL
29 |
30 | public var name: String {
31 | url.lastPathComponent
32 | }
33 |
34 | @Published public var settings: ProgramSettings {
35 | didSet { saveSettings() }
36 | }
37 |
38 | @Published public var pinned: Bool {
39 | didSet {
40 | if pinned {
41 | bottle.settings.pins.append(PinnedProgram(
42 | name: name.replacingOccurrences(of: ".exe", with: ""),
43 | url: url
44 | ))
45 | } else {
46 | bottle.settings.pins.removeAll(where: { $0.url == url })
47 | }
48 | }
49 | }
50 |
51 | public let peFile: PEFile?
52 |
53 | public init(url: URL, bottle: Bottle) {
54 | let name = url.lastPathComponent
55 | self.bottle = bottle
56 | self.url = url
57 | self.pinned = bottle.settings.pins.contains(where: { $0.url == url })
58 |
59 | // Warning: This will break if two programs share the same name such as "Launch.exe"
60 | // Best to add some sort of UUID in the path or file
61 | let settingsFolder = bottle.url.appending(path: "Program Settings")
62 | let settingsUrl = settingsFolder.appending(path: name).appendingPathExtension("plist")
63 | self.settingsURL = settingsUrl
64 |
65 | do {
66 | if !FileManager.default.fileExists(atPath: settingsFolder.path(percentEncoded: false)) {
67 | try FileManager.default.createDirectory(at: settingsFolder, withIntermediateDirectories: true)
68 | }
69 |
70 | self.settings = try ProgramSettings.decode(from: settingsUrl)
71 | } catch {
72 | Logger.wineKit.error("Failed to load settings for `\(name)`: \(error)")
73 | self.settings = ProgramSettings()
74 | }
75 |
76 | do {
77 | self.peFile = try PEFile(url: url)
78 | } catch {
79 | self.peFile = nil
80 | }
81 | }
82 |
83 | public func generateEnvironment() -> [String: String] {
84 | var environment = settings.environment
85 | if settings.locale != .auto {
86 | environment["LC_ALL"] = settings.locale.rawValue
87 | }
88 | return environment
89 | }
90 |
91 | /// Save the settings to file
92 | private func saveSettings() {
93 | do {
94 | try settings.encode(to: settingsURL)
95 | } catch {
96 | Logger.wineKit.error("Failed to save settings for `\(self.name)`: \(error)")
97 | }
98 | }
99 |
100 | // MARK: - Equatable
101 |
102 | public static func == (lhs: Program, rhs: Program) -> Bool {
103 | return lhs.url == rhs.url
104 | }
105 |
106 | // MARK: - Hashable
107 |
108 | public func hash(into hasher: inout Hasher) {
109 | return hasher.combine(url)
110 | }
111 |
112 | // MARK: - Identifiable
113 |
114 | public var id: URL {
115 | self.url
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/Whisky/ProgramSettings.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProgramSettings.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 |
21 | public enum Locales: String, Codable, CaseIterable {
22 | case auto = ""
23 | case german = "de_DE.UTF-8"
24 | case english = "en_US"
25 | case spanish = "es_ES.UTF-8"
26 | case french = "fr_FR.UTF-8"
27 | case italian = "it_IT.UTF-8"
28 | case japanese = "ja_JP.UTF-8"
29 | case korean = "ko_KR.UTF-8"
30 | case russian = "ru_RU.UTF-8"
31 | case ukranian = "uk_UA.UTF-8"
32 | case thai = "th_TH.UTF-8"
33 | case chineseSimplified = "zh_CN.UTF-8"
34 | case chineseTraditional = "zh_TW.UTF-8"
35 |
36 | // swiftlint:disable:next cyclomatic_complexity
37 | public func pretty() -> String {
38 | switch self {
39 | case .auto:
40 | return String(localized: "locale.auto")
41 | case .german:
42 | return "Deutsch"
43 | case .english:
44 | return "English"
45 | case .spanish:
46 | return "Español"
47 | case .french:
48 | return "Français"
49 | case .italian:
50 | return "Italiano"
51 | case .japanese:
52 | return "日本語"
53 | case .korean:
54 | return "한국어"
55 | case .russian:
56 | return "Русский"
57 | case .ukranian:
58 | return "Українська"
59 | case .thai:
60 | return "ไทย"
61 | case .chineseSimplified:
62 | return "简体中文"
63 | case .chineseTraditional:
64 | return "繁體中文"
65 | }
66 | }
67 | }
68 |
69 | public struct ProgramSettings: Codable {
70 | public var locale: Locales = .auto
71 | public var environment: [String: String] = [:]
72 | public var arguments: String = ""
73 |
74 | static func decode(from settingsURL: URL) throws -> ProgramSettings {
75 | guard FileManager.default.fileExists(atPath: settingsURL.path(percentEncoded: false)) else {
76 | let settings = ProgramSettings()
77 | try settings.encode(to: settingsURL)
78 | return settings
79 | }
80 |
81 | let data = try Data(contentsOf: settingsURL)
82 | return try PropertyListDecoder().decode(ProgramSettings.self, from: data)
83 | }
84 |
85 | func encode(to settingsURL: URL) throws {
86 | let encoder = PropertyListEncoder()
87 | encoder.outputFormat = .xml
88 | let data = try encoder.encode(self)
89 | try data.write(to: settingsURL)
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/WhiskyKit/Sources/WhiskyKit/WhiskyWine/WhiskyWineInstaller.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WhiskyWineInstaller.swift
3 | // WhiskyKit
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import SemanticVersion
21 |
22 | public class WhiskyWineInstaller {
23 | /// The Whisky application folder
24 | public static let applicationFolder = FileManager.default.urls(
25 | for: .applicationSupportDirectory, in: .userDomainMask
26 | )[0].appending(path: Bundle.whiskyBundleIdentifier)
27 |
28 | /// The folder of all the libfrary files
29 | public static let libraryFolder = applicationFolder.appending(path: "Libraries")
30 |
31 | /// URL to the installed `wine` `bin` directory
32 | public static let binFolder: URL = libraryFolder.appending(path: "Wine").appending(path: "bin")
33 |
34 | public static func isWhiskyWineInstalled() -> Bool {
35 | return whiskyWineVersion() != nil
36 | }
37 |
38 | public static func install(from: URL) {
39 | do {
40 | if !FileManager.default.fileExists(atPath: applicationFolder.path) {
41 | try FileManager.default.createDirectory(at: applicationFolder, withIntermediateDirectories: true)
42 | } else {
43 | // Recreate it
44 | try FileManager.default.removeItem(at: applicationFolder)
45 | try FileManager.default.createDirectory(at: applicationFolder, withIntermediateDirectories: true)
46 | }
47 |
48 | try Tar.untar(tarBall: from, toURL: applicationFolder)
49 | try FileManager.default.removeItem(at: from)
50 | } catch {
51 | print("Failed to install WhiskyWine: \(error)")
52 | }
53 | }
54 |
55 | public static func uninstall() {
56 | do {
57 | try FileManager.default.removeItem(at: libraryFolder)
58 | } catch {
59 | print("Failed to uninstall WhiskyWine: \(error)")
60 | }
61 | }
62 |
63 | public static func shouldUpdateWhiskyWine() async -> (Bool, SemanticVersion) {
64 | let versionPlistURL = "https://data.getwhisky.app/Wine/WhiskyWineVersion.plist"
65 | let localVersion = whiskyWineVersion()
66 |
67 | var remoteVersion: SemanticVersion?
68 |
69 | if let remoteUrl = URL(string: versionPlistURL) {
70 | remoteVersion = await withCheckedContinuation { continuation in
71 | URLSession(configuration: .ephemeral).dataTask(with: URLRequest(url: remoteUrl)) { data, _, error in
72 | do {
73 | if error == nil, let data = data {
74 | let decoder = PropertyListDecoder()
75 | let remoteInfo = try decoder.decode(WhiskyWineVersion.self, from: data)
76 | let remoteVersion = remoteInfo.version
77 |
78 | continuation.resume(returning: remoteVersion)
79 | return
80 | }
81 | if let error = error {
82 | print(error)
83 | }
84 | } catch {
85 | print(error)
86 | }
87 |
88 | continuation.resume(returning: nil)
89 | }.resume()
90 | }
91 | }
92 |
93 | if let localVersion = localVersion, let remoteVersion = remoteVersion {
94 | if localVersion < remoteVersion {
95 | return (true, remoteVersion)
96 | }
97 | }
98 |
99 | return (false, SemanticVersion(0, 0, 0))
100 | }
101 |
102 | public static func whiskyWineVersion() -> SemanticVersion? {
103 | do {
104 | let versionPlist = libraryFolder
105 | .appending(path: "WhiskyWineVersion")
106 | .appendingPathExtension("plist")
107 |
108 | let decoder = PropertyListDecoder()
109 | let data = try Data(contentsOf: versionPlist)
110 | let info = try decoder.decode(WhiskyWineVersion.self, from: data)
111 | return info.version
112 | } catch {
113 | print(error)
114 | return nil
115 | }
116 | }
117 | }
118 |
119 | struct WhiskyWineVersion: Codable {
120 | var version: SemanticVersion = SemanticVersion(1, 0, 0)
121 | }
122 |
--------------------------------------------------------------------------------
/WhiskyThumbnail/Icons.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/WhiskyThumbnail/Icons.xcassets/Icon.imageset/512R512x1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Whisky-App/Whisky/fd5480a76b3ebfe3419a1ab86ca3695f5cc328f8/WhiskyThumbnail/Icons.xcassets/Icon.imageset/512R512x1.png
--------------------------------------------------------------------------------
/WhiskyThumbnail/Icons.xcassets/Icon.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "512R512x1.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/WhiskyThumbnail/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSExtension
6 |
7 | NSExtensionAttributes
8 |
9 | QLSupportedContentTypes
10 |
11 | com.microsoft.windows-executable
12 |
13 | QLThumbnailMinimumDimension
14 | 0
15 |
16 | NSExtensionPointIdentifier
17 | com.apple.quicklook.thumbnail
18 | NSExtensionPrincipalClass
19 | $(PRODUCT_MODULE_NAME).ThumbnailProvider
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/WhiskyThumbnail/ThumbnailProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ThumbnailProvider.swift
3 | // WhiskyThumbnail
4 | //
5 | // This file is part of Whisky.
6 | //
7 | // Whisky is free software: you can redistribute it and/or modify it under the terms
8 | // of the GNU General Public License as published by the Free Software Foundation,
9 | // either version 3 of the License, or (at your option) any later version.
10 | //
11 | // Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
12 | // without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
13 | // See the GNU General Public License for more details.
14 | //
15 | // You should have received a copy of the GNU General Public License along with Whisky.
16 | // If not, see https://www.gnu.org/licenses/.
17 | //
18 |
19 | import Foundation
20 | import QuickLookThumbnailing
21 | import AppKit
22 | import WhiskyKit
23 |
24 | class ThumbnailProvider: QLThumbnailProvider {
25 | override func provideThumbnail(for request: QLFileThumbnailRequest,
26 | _ handler: @escaping (QLThumbnailReply?, Error?) -> Void) {
27 | let thumbnailSize = CGSize(width: request.maximumSize.width,
28 | height: request.maximumSize.height)
29 |
30 | // % of thumbnail occupied by icon
31 | let iconScaleFactor = 0.9
32 | let whiskyIconScaleFactor = 0.4
33 |
34 | // AppKit coordinate system origin is in the bottom-left
35 | // Icon is centered
36 | let iconFrame = CGRect(x: (request.maximumSize.width - request.maximumSize.width * iconScaleFactor) / 2.0,
37 | y: (request.maximumSize.height - request.maximumSize.height * iconScaleFactor) / 2.0,
38 | width: request.maximumSize.width * iconScaleFactor,
39 | height: request.maximumSize.height * iconScaleFactor)
40 |
41 | // Whisky icon is aligned bottom-right
42 | let whiskyIconFrame = CGRect(x: request.maximumSize.width - request.maximumSize.width * whiskyIconScaleFactor,
43 | y: 0,
44 | width: request.maximumSize.width * whiskyIconScaleFactor,
45 | height: request.maximumSize.height * whiskyIconScaleFactor)
46 | do {
47 | var image: NSImage?
48 |
49 | let peFile = try PEFile(url: request.fileURL)
50 | image = peFile.bestIcon()
51 |
52 | let reply: QLThumbnailReply = QLThumbnailReply.init(contextSize: thumbnailSize) { () -> Bool in
53 | if let image = image {
54 | image.draw(in: iconFrame)
55 | let whiskyIcon = NSImage(named: NSImage.Name("Icon"))
56 | whiskyIcon?.draw(in: whiskyIconFrame, from: .zero, operation: .sourceOver, fraction: 1)
57 | return true
58 | }
59 |
60 | // We didn't draw anything
61 | return false
62 | }
63 |
64 | handler(reply, nil)
65 | } catch {
66 | handler(nil, nil)
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/WhiskyThumbnail/WhiskyThumbnail.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.files.user-selected.read-only
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/crowdin.yml:
--------------------------------------------------------------------------------
1 | files:
2 | - source: /Whisky/Localizable.xcstrings
3 | translation: /Whisky/Localizable.xcstrings
4 | multilingual: 1
5 |
--------------------------------------------------------------------------------
/images/cw-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Whisky-App/Whisky/fd5480a76b3ebfe3419a1ab86ca3695f5cc328f8/images/cw-dark.png
--------------------------------------------------------------------------------
/images/cw-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Whisky-App/Whisky/fd5480a76b3ebfe3419a1ab86ca3695f5cc328f8/images/cw-light.png
--------------------------------------------------------------------------------