├── .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 | ![](https://img.shields.io/github/actions/workflow/status/IsaacMarovitz/Whisky/SwiftLint.yml?style=for-the-badge) 7 | [![](https://img.shields.io/discord/1115955071549702235?style=for-the-badge)](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 | Config 15 | 16 | Familiar UI that integrates seamlessly with macOS 17 | 18 |
19 | New Bottle 20 | 21 | One-click bottle creation and management 22 |
23 | 24 | debug 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 | 77 | 80 | 81 |
72 | 73 | 74 | 75 | 76 | 78 | Whisky doesn't exist without CrossOver. Support the work of CodeWeavers using our affiliate link. 79 |
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 --------------------------------------------------------------------------------