├── .config
└── dotnet-tools.json
├── .devinit.json
├── .github
├── dependabot.yml
├── template
│ └── build-signed
│ │ └── action.yaml
└── workflows
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── BrowserPicker.sln
├── BrowserPicker.sln.DotSettings
├── LICENSE
├── dist
├── Dependent
│ ├── Dependent.wixproj
│ └── Product.wxs
├── Portable
│ ├── Portable.wixproj
│ └── Product.wxs
└── code_signing.cer
├── docs
├── config_add_browser.png
├── config_add_browser_exe_picked.png
├── config_behaviour.png
├── config_defaults_browsers.png
├── config_defaults_empty.png
├── config_defaults_match_type.png
├── config_defaults_test_no_match.png
├── config_disabled.png
├── config_list.png
├── config_list_with_notepad.png
├── selector_edit_url.png
├── selector_edited_url.png
└── selector_two_running.png
├── nuget.config
├── readme.md
└── src
├── BrowserPicker.App
├── .editorconfig
├── App.xaml
├── App.xaml.cs
├── BrowserPicker.App.csproj
├── Converter
│ └── IconConverter.cs
├── Program.cs
├── Properties
│ ├── PublishProfiles
│ │ └── FolderProfile.pubxml
│ └── launchSettings.json
├── Resources
│ ├── ResourceDictionary.xaml
│ ├── privacy.png
│ ├── web_icon.ico
│ └── web_icon.png
├── View
│ ├── BrowserEditor.xaml
│ ├── BrowserEditor.xaml.cs
│ ├── BrowserList.xaml
│ ├── BrowserList.xaml.cs
│ ├── Configuration.xaml
│ ├── Configuration.xaml.cs
│ ├── ExceptionReport.xaml
│ ├── ExceptionReport.xaml.cs
│ ├── LoadingWindow.xaml
│ ├── LoadingWindow.xaml.cs
│ ├── MainWindow.xaml
│ └── MainWindow.xaml.cs
├── ViewModel
│ ├── ApplicationViewModel.cs
│ ├── BrowserViewModel.cs
│ ├── ConfigurationViewModel.cs
│ └── ExceptionViewModel.cs
└── appsettings.json
├── BrowserPicker.Windows
├── AppSettings.cs
├── BrowserPicker.Windows.csproj
└── RegistryHelpers.cs
├── BrowserPicker
├── BrowserModel.cs
├── BrowserPicker.csproj
├── BrowserSorter.cs
├── DefaultSetting.cs
├── ExceptionModel.cs
├── Framework
│ ├── DelegateCommand.cs
│ ├── ModelBase.cs
│ └── ViewModelBase.cs
├── IApplicationSettings.cs
├── IBrowserPickerConfiguration.cs
├── ILongRunningProcess.cs
├── KeyBinding.cs
├── LoggingExtensions.cs
├── MatchType.cs
├── Pattern.cs
├── SerializableSettings.cs
├── UrlHandler.cs
└── WellKnownBrowsers.cs
├── Directory.Build.props
└── Directory.Packages.props
/.config/dotnet-tools.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "isRoot": true,
4 | "tools": {
5 | "wix": {
6 | "version": "6.0.0",
7 | "commands": [
8 | "wix"
9 | ]
10 | }
11 | }
12 | }
--------------------------------------------------------------------------------
/.devinit.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/devinit.schema-4.0",
3 | "run": [
4 | {
5 | "comments": "Installs the Wix toolset",
6 | "tool": "msi-install",
7 | "input": "https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311.exe"
8 | }
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "nuget" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/.github/template/build-signed/action.yaml:
--------------------------------------------------------------------------------
1 | name: build-signed
2 | description: builds a signed executable
3 |
4 | inputs:
5 | configuration:
6 | required: true
7 | description: "dotnet build configuration"
8 | default: "Release"
9 |
10 | solution_path:
11 | required: true
12 | description: "The path to the solution file"
13 | default: "BrowserPicker.sln"
14 |
15 | project_path:
16 | required: true
17 | description: "The path to the application project file to publish"
18 | default: "src/BrowserPicker.App/BrowserPicker.App.csproj"
19 |
20 | dotnet_args:
21 | required: true
22 | description: "Extra arguments for dotnet"
23 |
24 | package_project:
25 | required: true
26 | description: "Path to wixproj to build"
27 |
28 | package_version:
29 | required: true
30 | description: "MSI package VersionPrefix"
31 |
32 | package:
33 | required: true
34 | description: "Path to msi package to build and sign"
35 |
36 | package_name:
37 | required: true
38 | description: "Name of the uploaded package artifact"
39 |
40 | package_path:
41 | required: true
42 | description: "Path to the package to upload"
43 |
44 | binaries:
45 | required: true
46 | description: "Pattern matching binaries to be signed and bundled"
47 | default: ""
48 |
49 | bundle_name:
50 | required: true
51 | description: "Name of the uploaded bundle artifact"
52 |
53 | bundle_path:
54 | required: true
55 | description: "Path to the files to bundle and upload"
56 |
57 | runs:
58 | using: composite
59 | steps:
60 | - name: Install .NET Core
61 | uses: actions/setup-dotnet@v4
62 | with:
63 | dotnet-version: 9.x
64 |
65 | # Restore dotnet tools
66 | - name: Restore tools
67 | shell: bash
68 | run: dotnet tool restore
69 |
70 | # Restore the application to populate the obj folder with RuntimeIdentifiers
71 | - name: Restore the application
72 | shell: bash
73 | run: dotnet restore ${{ inputs.solution_path }} ${{ inputs.dotnet_args }}
74 |
75 | # Build and publish the application
76 | - name: Build application
77 | shell: bash
78 | run: dotnet publish -c ${{ inputs.configuration }} ${{ inputs.project_path }} ${{ inputs.dotnet_args }}
79 |
80 | # Create the app package by building and packaging the Windows Application Packaging project
81 | - name: Create the installer
82 | shell: bash
83 | run: dotnet build ${{ inputs.package_project }} --no-dependencies -c ${{ inputs.configuration }} -p Version=${{ inputs.package_version }}
84 |
85 | - name: Upload msi
86 | uses: actions/upload-artifact@v4
87 | with:
88 | name: ${{ inputs.package_name }}
89 | path: ${{ inputs.package_path }}
90 |
91 | - name: Upload bundle
92 | uses: actions/upload-artifact@v4
93 | with:
94 | name: ${{ inputs.bundle_name }}
95 | path: ${{ inputs.bundle_path }}
96 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 |
7 | jobs:
8 | prepare:
9 |
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v4
15 | with:
16 | fetch-depth: 0
17 |
18 | - name: Determine version
19 | id: version
20 | uses: paulhatch/semantic-version@v5.4.0
21 | with:
22 | version_format: "${major}.${minor}.${patch}"
23 |
24 | outputs:
25 | version: ${{ steps.version.outputs.version}}
26 | version_suffix: "beta${{ steps.version.outputs.increment }}"
27 | package_version: "${{ steps.version.outputs.major }}.${{ steps.version.outputs.minor }}.${{ steps.version.outputs.patch }}.${{ steps.version.outputs.increment }}"
28 |
29 | dependent:
30 |
31 | strategy:
32 | matrix:
33 | configuration: [Debug, Release]
34 |
35 | runs-on: windows-latest
36 | needs: prepare
37 |
38 | steps:
39 | - name: Checkout
40 | uses: actions/checkout@v4
41 | with:
42 | fetch-depth: 0
43 |
44 | - name: Build runtime dependent binaries
45 | uses: "./.github/template/build-signed"
46 | with:
47 | configuration: ${{ matrix.configuration }}
48 | dotnet_args: "-p VersionPrefix=${{ needs.prepare.outputs.version }} -p VersionSuffix=${{ needs.prepare.outputs.version_suffix }}"
49 | package_project: dist/Dependent/Dependent.wixproj
50 | package_version: ${{ needs.prepare.outputs.package_version }}
51 | package: dist\Dependent\bin\${{ matrix.configuration }}\BrowserPicker.msi
52 | package_name: DependentSetup-${{ needs.prepare.outputs.version }}-${{ matrix.configuration }}
53 | package_path: dist/Dependent/bin/${{ matrix.configuration }}
54 | binaries: |
55 | src\BrowserPicker.App\bin\${{ matrix.configuration }}\net9.0-windows\publish\BrowserPicker*.dll src\BrowserPicker.App\bin\${{ matrix.configuration }}\net9.0-windows\publish\BrowserPicker*.exe
56 | bundle_name: Dependent-${{ needs.prepare.outputs.version }}-${{ matrix.configuration }}
57 | bundle_path: src/BrowserPicker.App/bin/${{ matrix.configuration }}/net9.0-windows/publish
58 |
59 | portable:
60 |
61 | strategy:
62 | matrix:
63 | configuration: [Debug, Release]
64 |
65 | runs-on: windows-latest
66 | needs: prepare
67 |
68 | steps:
69 | - name: Checkout
70 | uses: actions/checkout@v4
71 | with:
72 | fetch-depth: 0
73 |
74 | - name: Build runtime portable binaries
75 | uses: "./.github/template/build-signed"
76 | with:
77 | configuration: ${{ matrix.configuration }}
78 | dotnet_args: "-p VersionPrefix=${{ needs.prepare.outputs.version }} -p VersionSuffix=${{ needs.prepare.outputs.version_suffix }} -r win-x64 -p:PublishSingleFile=true"
79 | package_project: dist/Portable/Portable.wixproj
80 | package_version: ${{ needs.prepare.outputs.package_version }}
81 | package: dist\Portable\bin\${{ matrix.configuration }}\BrowserPicker-Portable.msi
82 | package_name: PortableSetup-${{ needs.prepare.outputs.version }}-${{ matrix.configuration }}
83 | package_path: dist/Portable/bin/${{ matrix.configuration }}
84 | binaries: src\BrowserPicker.App\bin\${{ matrix.configuration }}\net9.0-windows\win-x64\publish\BrowserPicker.exe
85 | bundle_name: Portable-${{ needs.prepare.outputs.version }}-${{ matrix.configuration }}
86 | bundle_path: src/BrowserPicker.App/bin/${{ matrix.configuration }}/net9.0-windows/win-x64/publish
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*.*.*"
7 |
8 | jobs:
9 |
10 | prepare:
11 |
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 | with:
18 | fetch-depth: 0
19 |
20 | - name: Determine version
21 | id: version
22 | uses: paulhatch/semantic-version@v5.4.0
23 | with:
24 | version_format: "${major}.${minor}.${patch}"
25 |
26 | - name: Upload certificate
27 | uses: actions/upload-artifact@v4
28 | with:
29 | name: dist
30 | path: dist/code_signing.cer
31 |
32 | outputs:
33 | version: ${{ steps.version.outputs.version}}
34 |
35 | dependent:
36 | runs-on: windows-latest
37 | needs: prepare
38 |
39 | steps:
40 | - name: Checkout
41 | uses: actions/checkout@v4
42 | with:
43 | fetch-depth: 0
44 |
45 | - name: Build runtime dependent binaries
46 | uses: "./.github/template/build-signed"
47 | with:
48 | dotnet_args: "-p VersionPrefix=${{ needs.prepare.outputs.version }}"
49 | package_project: dist/Dependent/Dependent.wixproj
50 | package_version: ${{ needs.prepare.outputs.version }}
51 | package: dist\Dependent\bin\Release\BrowserPicker.msi
52 | package_name: DependentSetup-${{ needs.prepare.outputs.version }}-Release
53 | package_path: dist/Dependent/bin/Release
54 | binaries: |
55 | src\BrowserPicker.App\bin\Release\net9.0-windows\publish\BrowserPicker*.dll src\BrowserPicker.App\bin\Release\net9.0-windows\publish\BrowserPicker*.exe
56 | bundle_name: Dependent-${{ needs.prepare.outputs.version }}-Release
57 | bundle_path: src/BrowserPicker.App/bin/Release/net9.0-windows/publish
58 |
59 | portable:
60 | runs-on: windows-latest
61 | needs: prepare
62 |
63 | steps:
64 | - name: Checkout
65 | uses: actions/checkout@v4
66 | with:
67 | fetch-depth: 0
68 |
69 | - name: Build runtime independent binaries
70 | uses: "./.github/template/build-signed"
71 | with:
72 | dotnet_args: "-p VersionPrefix=${{ needs.prepare.outputs.version }} -r win-x64 -p:PublishSingleFile=true"
73 | package_project: dist/Portable/Portable.wixproj
74 | package_version: ${{ needs.prepare.outputs.version }}
75 | package: dist\Portable\bin\Release\BrowserPicker-Portable.msi
76 | package_name: PortableSetup-${{ needs.prepare.outputs.version }}-Release
77 | package_path: dist/Portable/bin/Release
78 | binaries: src\BrowserPicker.App\bin\Release\net9.0-windows\win-x64\publish\BrowserPicker.exe
79 | bundle_name: Portable-${{ needs.prepare.outputs.version }}-Release
80 | bundle_path: src/BrowserPicker.App/bin/Release/net9.0-windows/win-x64/publish
81 |
82 | publish:
83 | runs-on: ubuntu-latest
84 | needs: [prepare, dependent, portable]
85 |
86 | steps:
87 | - name: Checkout
88 | uses: actions/checkout@v4
89 | with:
90 | fetch-depth: 0
91 |
92 | - name: Retrieve artifacts
93 | uses: actions/download-artifact@v4
94 |
95 | - name: Package bundles
96 | run: |
97 | rm -rf *.zip
98 | for bundle in Dependent Portable; do
99 | (cd $bundle-${{ needs.prepare.outputs.version }}-Release; zip -r ../$bundle.zip *)
100 | done
101 |
102 | - name: Release
103 | uses: softprops/action-gh-release@v2
104 | with:
105 | generate_release_notes: true
106 | draft: true
107 | prerelease: true
108 | files: |
109 | DependentSetup-${{ needs.prepare.outputs.version }}-Release/BrowserPicker.msi
110 | PortableSetup-${{ needs.prepare.outputs.version }}-Release/BrowserPicker-Portable.msi
111 | Dependent.zip
112 | Portable.zip
113 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vs
2 | bin
3 | obj
4 | *.user
5 | packages/
6 |
--------------------------------------------------------------------------------
/BrowserPicker.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 17
4 | VisualStudioVersion = 17.0.31919.166
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F46B5958-C607-4E07-9C2C-F63538149287}"
7 | ProjectSection(SolutionItems) = preProject
8 | LICENSE = LICENSE
9 | nuget.config = nuget.config
10 | readme.md = readme.md
11 | EndProjectSection
12 | EndProject
13 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BrowserPicker", "src\BrowserPicker\BrowserPicker.csproj", "{D7695535-9C0D-4983-B8F7-09B067347E7E}"
14 | EndProject
15 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BrowserPicker.App", "src\BrowserPicker.App\BrowserPicker.App.csproj", "{B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}"
16 | EndProject
17 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BrowserPicker.Windows", "src\BrowserPicker.Windows\BrowserPicker.Windows.csproj", "{C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}"
18 | EndProject
19 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A7A5C3AA-4BF6-4B3B-A515-6C1E21E0B4E1}"
20 | ProjectSection(SolutionItems) = preProject
21 | src\Directory.Build.props = src\Directory.Build.props
22 | src\Directory.Packages.props = src\Directory.Packages.props
23 | EndProjectSection
24 | EndProject
25 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dist", "dist", "{CD1CE02F-9EBF-48E0-81E4-E047F20F10C8}"
26 | ProjectSection(SolutionItems) = preProject
27 | dist\code_signing.cer = dist\code_signing.cer
28 | EndProjectSection
29 | EndProject
30 | Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "Dependent", "dist\Dependent\Dependent.wixproj", "{F9D9359C-6DA4-463E-86B9-505E04E01C3A}"
31 | EndProject
32 | Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "Portable", "dist\Portable\Portable.wixproj", "{FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}"
33 | EndProject
34 | Global
35 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
36 | Debug|ARM64 = Debug|ARM64
37 | Debug|x64 = Debug|x64
38 | Debug|x86 = Debug|x86
39 | Release|ARM64 = Release|ARM64
40 | Release|x64 = Release|x64
41 | Release|x86 = Release|x86
42 | EndGlobalSection
43 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
44 | {D7695535-9C0D-4983-B8F7-09B067347E7E}.Debug|ARM64.ActiveCfg = Debug|x64
45 | {D7695535-9C0D-4983-B8F7-09B067347E7E}.Debug|ARM64.Build.0 = Debug|x64
46 | {D7695535-9C0D-4983-B8F7-09B067347E7E}.Debug|x64.ActiveCfg = Debug|x64
47 | {D7695535-9C0D-4983-B8F7-09B067347E7E}.Debug|x64.Build.0 = Debug|x64
48 | {D7695535-9C0D-4983-B8F7-09B067347E7E}.Debug|x86.ActiveCfg = Debug|x64
49 | {D7695535-9C0D-4983-B8F7-09B067347E7E}.Debug|x86.Build.0 = Debug|x64
50 | {D7695535-9C0D-4983-B8F7-09B067347E7E}.Release|ARM64.ActiveCfg = Release|x64
51 | {D7695535-9C0D-4983-B8F7-09B067347E7E}.Release|ARM64.Build.0 = Release|x64
52 | {D7695535-9C0D-4983-B8F7-09B067347E7E}.Release|x64.ActiveCfg = Release|x64
53 | {D7695535-9C0D-4983-B8F7-09B067347E7E}.Release|x64.Build.0 = Release|x64
54 | {D7695535-9C0D-4983-B8F7-09B067347E7E}.Release|x86.ActiveCfg = Release|x64
55 | {D7695535-9C0D-4983-B8F7-09B067347E7E}.Release|x86.Build.0 = Release|x64
56 | {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Debug|ARM64.ActiveCfg = Debug|x64
57 | {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Debug|ARM64.Build.0 = Debug|x64
58 | {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Debug|x64.ActiveCfg = Debug|x64
59 | {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Debug|x64.Build.0 = Debug|x64
60 | {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Debug|x86.ActiveCfg = Debug|x64
61 | {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Debug|x86.Build.0 = Debug|x64
62 | {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Release|ARM64.ActiveCfg = Release|x64
63 | {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Release|ARM64.Build.0 = Release|x64
64 | {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Release|x64.ActiveCfg = Release|x64
65 | {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Release|x64.Build.0 = Release|x64
66 | {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Release|x86.ActiveCfg = Release|x64
67 | {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Release|x86.Build.0 = Release|x64
68 | {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Debug|ARM64.ActiveCfg = Debug|x64
69 | {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Debug|ARM64.Build.0 = Debug|x64
70 | {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Debug|x64.ActiveCfg = Debug|x64
71 | {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Debug|x64.Build.0 = Debug|x64
72 | {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Debug|x86.ActiveCfg = Debug|x64
73 | {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Debug|x86.Build.0 = Debug|x64
74 | {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Release|ARM64.ActiveCfg = Release|x64
75 | {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Release|ARM64.Build.0 = Release|x64
76 | {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Release|x64.ActiveCfg = Release|x64
77 | {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Release|x64.Build.0 = Release|x64
78 | {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Release|x86.ActiveCfg = Release|x64
79 | {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Release|x86.Build.0 = Release|x64
80 | {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Debug|ARM64.ActiveCfg = Debug|ARM64
81 | {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Debug|ARM64.Build.0 = Debug|ARM64
82 | {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Debug|x64.ActiveCfg = Debug|x64
83 | {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Debug|x86.ActiveCfg = Debug|x86
84 | {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Debug|x86.Build.0 = Debug|x86
85 | {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Release|ARM64.ActiveCfg = Release|ARM64
86 | {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Release|ARM64.Build.0 = Release|ARM64
87 | {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Release|x64.ActiveCfg = Release|x64
88 | {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Release|x86.ActiveCfg = Release|x86
89 | {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Release|x86.Build.0 = Release|x86
90 | {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Debug|ARM64.ActiveCfg = Debug|ARM64
91 | {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Debug|ARM64.Build.0 = Debug|ARM64
92 | {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Debug|x64.ActiveCfg = Debug|x64
93 | {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Debug|x86.ActiveCfg = Debug|x86
94 | {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Debug|x86.Build.0 = Debug|x86
95 | {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Release|ARM64.ActiveCfg = Release|ARM64
96 | {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Release|ARM64.Build.0 = Release|ARM64
97 | {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Release|x64.ActiveCfg = Release|x64
98 | {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Release|x86.ActiveCfg = Release|x86
99 | {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Release|x86.Build.0 = Release|x86
100 | EndGlobalSection
101 | GlobalSection(SolutionProperties) = preSolution
102 | HideSolutionNode = FALSE
103 | EndGlobalSection
104 | GlobalSection(NestedProjects) = preSolution
105 | {D7695535-9C0D-4983-B8F7-09B067347E7E} = {A7A5C3AA-4BF6-4B3B-A515-6C1E21E0B4E1}
106 | {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC} = {A7A5C3AA-4BF6-4B3B-A515-6C1E21E0B4E1}
107 | {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8} = {A7A5C3AA-4BF6-4B3B-A515-6C1E21E0B4E1}
108 | {F9D9359C-6DA4-463E-86B9-505E04E01C3A} = {CD1CE02F-9EBF-48E0-81E4-E047F20F10C8}
109 | {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA} = {CD1CE02F-9EBF-48E0-81E4-E047F20F10C8}
110 | EndGlobalSection
111 | GlobalSection(ExtensibilityGlobals) = postSolution
112 | SolutionGuid = {FA02F9A8-CC4F-4C63-A345-418FC0D10D32}
113 | EndGlobalSection
114 | EndGlobal
115 |
--------------------------------------------------------------------------------
/BrowserPicker.sln.DotSettings:
--------------------------------------------------------------------------------
1 |
2 | True
3 | URL
4 | UTF
5 | <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" />
6 | <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" />
7 | <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="aa_bb" /></Policy>
8 | <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static fields (private)"><ElementKinds><Kind Name="FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="aa_bb" /></Policy>
9 | True
10 | True
11 | True
12 | True
13 | True
14 | True
15 | True
16 | True
17 | True
18 | True
19 | True
20 | True
21 | True
22 | True
23 | True
24 | True
25 | True
26 | True
27 | True
28 | True
29 | True
30 | True
31 | True
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Morten Nilsen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/dist/Dependent/Dependent.wixproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | BrowserPicker
4 | false
5 | x64
6 | ProductVersion=$(Version)
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | BrowserPicker.App
15 | {b875ae86-5212-4f7f-bb1c-2baa1fc110bc}
16 | True
17 | True
18 | Binaries;Content;Satellites
19 | INSTALLFOLDER
20 |
21 |
22 |
23 | Debug
24 | bin\$(Platform)\$(Configuration)\
25 | obj\$(Platform)\$(Configuration)\
26 |
27 |
28 | bin\$(Platform)\$(Configuration)\
29 | obj\$(Platform)\$(Configuration)\
30 |
31 |
32 |
--------------------------------------------------------------------------------
/dist/Dependent/Product.wxs:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
23 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
--------------------------------------------------------------------------------
/dist/Portable/Portable.wixproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | BrowserPicker-Portable
4 | false
5 | x64
6 | ProductVersion=$(Version)
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | BrowserPicker.App
15 | {b875ae86-5212-4f7f-bb1c-2baa1fc110bc}
16 | True
17 | True
18 | Binaries;Content;Satellites
19 | INSTALLFOLDER
20 |
21 |
22 |
23 | Debug
24 | bin\$(Platform)\$(Configuration)\
25 | obj\$(Platform)\$(Configuration)\
26 |
27 |
28 | bin\$(Platform)\$(Configuration)\
29 | obj\$(Platform)\$(Configuration)\
30 |
31 |
32 |
--------------------------------------------------------------------------------
/dist/Portable/Product.wxs:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
--------------------------------------------------------------------------------
/dist/code_signing.cer:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIDJjCCAg6gAwIBAgIQcNd6jmhb3bJIFsoJWcMm9jANBgkqhkiG9w0BAQsFADAc
3 | MRowGAYDVQQDDBFtb3J0ZW5AcnVuc2FmZS5ubzAeFw0yNDAzMzEyMTU5NDhaFw0y
4 | NTAzMzEyMjE5NDhaMBwxGjAYBgNVBAMMEW1vcnRlbkBydW5zYWZlLm5vMIIBIjAN
5 | BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqJNT3p8bhY8qpsbjobZJoMQjS9Wo
6 | so55x2WcEYmCUej7Mb+yscclAS/Mh/o2QjtQdor5dM9+aFCt5zAe4aYRjPBQWSmw
7 | qsyzuVK5GtUE8vhdmjKFXFn+RfCQzij7+auyRh/ntXJ0lTdDHdY78kK7hw/MQiqX
8 | 6+Ff6LWSdHKpjuElfHO++mqarmVt0h9KpuEhRN8cUMxiNMgZzoPuK31TGK4C5ovY
9 | 9smn5JJwVOjAPTIL/v3z3sYuWWovIdM0e7gFV6B1Er7H9IPmWZay0L69b43nxVx4
10 | wiGeCUcV6Oxj01Gl3YqpXXfh6NFGo3XwHULr09CdJr+Wc01yF91XgPa3bQIDAQAB
11 | o2QwYjAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMwHAYDVR0R
12 | BBUwE4IRbW9ydGVuQHJ1bnNhZmUubm8wHQYDVR0OBBYEFKErAdBjYvNYE1rgfSAN
13 | bsu/RZY7MA0GCSqGSIb3DQEBCwUAA4IBAQBZnYV8ww2IKqQYssrxu8+I60Xkvqgj
14 | Mw8x35PWwlSuvQSzPzs50HBTXsOLyNQcFETjfMs58TqtwkOaILeswD/MxhVdyJ7H
15 | 7lsIjiOuozDlta/2pfIfaRtniPAOOqNr4kD/y/f4v1maLKQL4ct3RauKrPxcQHPK
16 | bzE4OuzIGVVwcllnget23kWxNwWnK4RdxqtugMwI6kzqJFDKH982Du+V6AEfxU4n
17 | yWSYIuREcgc5eRG+1uun+KSxT78TtKsPsfrgmA5X9jjMW0jDZ7GLb37m+gZ9r+/P
18 | RhYO5m0SMOjnDQIovm+GQcHfj3yQXqPIvr37FY5ZEMfqf4z56bB13jI1
19 | -----END CERTIFICATE-----
20 |
--------------------------------------------------------------------------------
/docs/config_add_browser.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/docs/config_add_browser.png
--------------------------------------------------------------------------------
/docs/config_add_browser_exe_picked.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/docs/config_add_browser_exe_picked.png
--------------------------------------------------------------------------------
/docs/config_behaviour.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/docs/config_behaviour.png
--------------------------------------------------------------------------------
/docs/config_defaults_browsers.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/docs/config_defaults_browsers.png
--------------------------------------------------------------------------------
/docs/config_defaults_empty.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/docs/config_defaults_empty.png
--------------------------------------------------------------------------------
/docs/config_defaults_match_type.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/docs/config_defaults_match_type.png
--------------------------------------------------------------------------------
/docs/config_defaults_test_no_match.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/docs/config_defaults_test_no_match.png
--------------------------------------------------------------------------------
/docs/config_disabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/docs/config_disabled.png
--------------------------------------------------------------------------------
/docs/config_list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/docs/config_list.png
--------------------------------------------------------------------------------
/docs/config_list_with_notepad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/docs/config_list_with_notepad.png
--------------------------------------------------------------------------------
/docs/selector_edit_url.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/docs/selector_edit_url.png
--------------------------------------------------------------------------------
/docs/selector_edited_url.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/docs/selector_edited_url.png
--------------------------------------------------------------------------------
/docs/selector_two_running.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/docs/selector_two_running.png
--------------------------------------------------------------------------------
/nuget.config:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Browser Picker
2 | A default browser replacement for windows to let you pick your preferred browser on the fly or in accordance with your own rules.
3 |
4 | 
5 |
6 | You can easily configure it to use Firefox for `github.com` and `slashdot.org`, but leave Edge to handle `microsoft.com`
7 | and even let Internet Explorer handle that old internal LOB app you'd rather not use but must.
8 |
9 | ## Installation
10 | You can find the latest release on [github](https://github.com/mortenn/BrowserPicker/releases).
11 |
12 | ### Default browser
13 | To enable the browser picker window, you need to set Browser Picker as your default browser.
14 |
15 | ### .NET Runtime dependent binary
16 | BrowserPicker.msi and Dependent.zip are JIT compiled and require you have the [.NET 9.0 Desktop Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) installed.
17 | Direct links: [64bit systems](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-desktop-9.0.3-windows-x64-installer), [32bit systems](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-desktop-9.0.3-windows-x86-installer).
18 |
19 | #### Native image generation
20 | As part of installation, `BrowserPicker.msi` will execute ngen to build a native image for your computer.
21 | This significantly enhances launch times for the executable.
22 | If you prefer the bundle, you may run `ngen install BrowserPicker.exe` to get the same benefit.
23 |
24 | ### Portable binary
25 | If you do not want to have the .net runtime installed on your computer, you may download the Portable version, which includes the runtime.
26 |
27 | `BrowserPicker-Portable.msi` and `Portable.zip` contain a win-x64 binary executable with embedded .NET runtime.
28 | This makes the file sizes quite significantly larger, but you do not need an additional runtime to use these.
29 |
30 | ### Signing certificate
31 | To avoid warnings about unknown publisher, you may [import](https://stackoverflow.com/questions/49039136/powershell-script-to-install-trusted-publisher-certificates) the provided certificate into your certificate store first.
32 |
33 | ### Manual steps
34 | You need to open the settings app from the start menu, navigate into Apps, select Default apps, then change the Web browser to BrowserPicker.
35 | Please ensure BrowserPicker can be started before you do this.
36 |
37 | ## Usage
38 |
39 | When you open a link outside a browser, one of these things will happen, in order:
40 |
41 | 1. If you have previously selected `Always ask`, the browser selection window is shown.
42 | 2. If you have set up a configuration rule matching the url being opened, the selected browser will be launched with the url.
43 | 3. If you only have one browser running, the link will be opened in that browser.
44 | 4. If you have configured a default browser, it will be asked to open the url.
45 | 3. Otherwise, you will be presented with a simple window asking you which browser you want to use.
46 |
47 | The url is shown at the top of the window, and if it matches a list of known url shorteners, BrowserPicker will expand this address and show you the real one after a short delay.
48 | If you do not want BrowserPicker to perform this operation (it will call the internet), you may disable this feature in the settings.
49 |
50 | ### Copy url
51 | You can click the clipboard icon at the top to copy the url without opening it
52 |
53 | ### Edit url
54 | You can click the pencil icon at the top of the window to edit or copy the url before visiting it or cancelling:
55 |
56 | 
57 | 
58 |
59 | ### Keyboard shortcuts
60 |
61 | When this window is open and has focus, you can use the following keyboard shortcuts:
62 |
63 | `[enter]` or `[1]` Pick the first browser in the list
64 |
65 | `[2]` Pick the second browser in the list
66 |
67 | ...
68 |
69 | `[9]` Pick the ninth browser in the list
70 |
71 | If you keep `[alt]` pressed while hitting one of these, the browser will be opened in privacy mode.
72 |
73 | `[esc]` Abort and close window
74 |
75 | If you click outside the window such that it loses focus, it will close without opening the url in any browser.
76 |
77 | Each browser that supports it, has a blue shield button on the right side.
78 | Browsers currently supporting privacy mode are firefox, internet explorer, chrome, and edge.
79 |
80 | Currently running browsers will have their name in bold, whilst browsers not currently running will have their names in cursive.
81 |
82 | As you use the application, it keeps count of how many times you selected each browser. This information is used to show you your browsers in your preferred order automatically.
83 |
84 | At the bottom of the window, there is a checkbox to enable "always ask" and a hyperlink to open settings.
85 |
86 | ## Settings
87 | By simply launching BrowserPicker from the start menu or double clicking the `BrowserPicker.exe` file, you will be presented with a GUI to configure the behaviour.
88 | The configuration is saved in the Windows registry: `HKEY_CURRENT_USER\Software\BrowserPicker`, if you ever need to manually edit it or make a backup.
89 |
90 | 
91 |
92 | ### Browsers
93 |
94 | The browser list shows you the browsers BrowserPicker has been configured or detected to use.
95 |
96 | #### Disabling browsers
97 | You can disable a browser by clicking `Enabled`, this will hide the browser from the selection list.
98 |
99 | 
100 |
101 | #### Removing browsers
102 | If you click the red X, you may remove a browser.
103 |
104 | Do note that if it was automatically detected, it will return to the list the next time auto configuration is performed.
105 |
106 | #### Automatic configuration
107 | The `Refresh broser list` function gets automatically executed in the background when you use BrowserPicker.
108 | This helps it discovering newly installed browsers, in case a new browser has been installed,
109 |
110 | #### Manually adding browser
111 | You may click the hyperlink `Add browser` to open a popup where you may manually add a browser that has not been detected - or some other tool that isn't a browser.
112 |
113 | You can click the buttons behind the input boxes to bring up the file picker interface of windows to select the executable or icon file you want to use.
114 |
115 | 
116 | 
117 |
118 | 
119 |
120 | If you browse for the command first, the application will assume the executable also has an icon, and prefill that box.
121 |
122 | The name of the application will be attempted to be set automatically based on information in the executable.
123 |
124 | ##### Chrome profiles
125 |
126 | Tip for Chrome Users: If you are using multiple Chrome profiles, by default if you choose Chrome it will launch in the last
127 | profile you launched Chrome with. To make it possibe for browser picker to select a profile you can create a new browser
128 | for each profile, set the program to the chrome executable, and add a command line argument to specify which profile to launch:
129 | `--profile-directory=Default` for the first profile, `--profile-directory="Profile 1"` for the second profile, and so on.
130 |
131 | Please note that arguments with spaces do require "" around them to be properly passed to chrome.
132 |
133 | ##### Firefox profiles
134 |
135 | Similar configuration should be possible for firefox.
136 |
137 | ### Behaviour
138 | This tab contains various settings that govern how BrowserPicker operates.
139 |
140 | 
141 |
142 | > [ ] Turn off transparency
143 |
144 | This will make BrowserPicker have a simple black background, to help with legibility
145 |
146 | > [ ] Always show browser selection window
147 |
148 | This option is also available on the browser selection window. When enabled, BrowserPicker will always ask the user to make a choice.
149 |
150 | > [ ] When no default is configured matching the url, use: [__v]
151 |
152 | When configured, BrowserPicker will always use this browser unless a default browser has been configured for that url.
153 |
154 | > [ ] Always ask when no default is matching url
155 |
156 | This option makes it so BrowserPicker will only pick matched default browsers and otherwise show the selection window.
157 |
158 | > [ ] Disable url resolution
159 |
160 |
161 | > [ ] Ignore defaults when browser is not running
162 |
163 | When enabled, configured default browsers only apply when they are already running.
164 |
165 | > [ ] Update order in browser list based on usage
166 |
167 | This option will make your list of browsers automatically sorted by how often you pick them.
168 |
169 | > [ ] Disallow network activity
170 |
171 | BrowserPicker may perform DNS and HTTP calls to probe the specified url in order to check if the url redirects elsewhere.
172 | This option turns this feature off, preventing BrowserPicker to call the network when you launch a url.
173 |
174 | > URL resolution timeout: [_____]
175 |
176 | You may adjust for how long BrowserPicker attempts to resolve an url here.
177 |
178 | ### Defaults
179 | The defaults tab lets you configure rules to map certain urls to certain browsers.
180 |
181 | 
182 |
183 | ##### Match types
184 | There exists four different match types, but you cannot use Default, that is reserved for use elsewhere.
185 | The option will eventually get hidden in the interface, but for now it becomes Hostname when selected.
186 |
187 | 
188 |
189 | ###### Hostname match
190 | The pattern will match the end of the hostname part of the url, ie. `hub.com` would match `https://www.github.com/mortenn/BrowserPicker`, but not `https://example.com/cgi-bin/hub.com`
191 |
192 | ###### Prefix match
193 | The pattern will match the beginning of the url, ie. `https://github.com/mortenn` would match `https://github.com/mortenn/BrowserPicker` but not `https://www.github.com/mortenn/BrowserPicker`
194 |
195 | ###### Regex match
196 | The pattern is a .NET regular expression and will be executed against the url, see [.NET regular expressions](https://learn.microsoft.com/en-us/dotnet/standard/base-types/regular-expressions) for details.
197 |
198 | ##### Browser
199 | The selected browser will be the one to launch for matched urls.
200 |
201 | 
202 |
203 | ### Test defaults
204 | There is even a handy dandy tool for verifying your settings,
205 | just paste that url into the big white text box and get instant feedback on the browser selection process:
206 |
207 | 
208 |
209 | ### Logging
210 | BrowserPicker uses ILogger with EventLog support.
211 |
212 | To get detailed logs, please either change appsettings.json or set the environment variable `Logging__EventLog__LogLevel__BrowserPicker` to either `Information` or `Debug`
213 | By default, only warnings or higher level events get logged.
214 |
215 | If you are using the archived version rather than the installer package,
216 | you will need to run this powershell command before logs will appear:
217 |
218 | ```New-EventLog -LogName Application -Source BrowserPicker```
219 | `
220 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/.editorconfig:
--------------------------------------------------------------------------------
1 | # To learn more about .editorconfig see https://aka.ms/editorconfigdocs
2 | ###############################
3 | # Core EditorConfig Options #
4 | ###############################
5 | root = true
6 | # All files
7 | [*]
8 | indent_style = tab
9 | # Code files
10 | [*.{cs,csx,vb,vbx}]
11 | # indent_size = 2
12 | insert_final_newline = true
13 | charset = utf-8-bom
14 | ###############################
15 | # .NET Coding Conventions #
16 | ###############################
17 | [*.{cs,vb}]
18 | # Organize usings
19 | dotnet_sort_system_directives_first = true
20 | # this. preferences
21 | dotnet_style_qualification_for_field = false:silent
22 | dotnet_style_qualification_for_property = false:silent
23 | dotnet_style_qualification_for_method = false:silent
24 | dotnet_style_qualification_for_event = false:silent
25 | # Language keywords vs BCL types preferences
26 | dotnet_style_predefined_type_for_locals_parameters_members = true:silent
27 | dotnet_style_predefined_type_for_member_access = true:silent
28 | # Parentheses preferences
29 | dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
30 | dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
31 | dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
32 | dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
33 | # Modifier preferences
34 | dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
35 | dotnet_style_readonly_field = true:suggestion
36 | # Expression-level preferences
37 | dotnet_style_object_initializer = true:suggestion
38 | dotnet_style_collection_initializer = true:suggestion
39 | dotnet_style_explicit_tuple_names = true:suggestion
40 | dotnet_style_null_propagation = true:suggestion
41 | dotnet_style_coalesce_expression = true:suggestion
42 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent
43 | dotnet_style_prefer_inferred_tuple_names = true:suggestion
44 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
45 | dotnet_style_prefer_auto_properties = true:silent
46 | dotnet_style_prefer_conditional_expression_over_assignment = true:silent
47 | dotnet_style_prefer_conditional_expression_over_return = true:silent
48 | ###############################
49 | # Naming Conventions #
50 | ###############################
51 | # Style Definitions
52 | dotnet_naming_style.pascal_case_style.capitalization = pascal_case
53 | # Use PascalCase for constant fields
54 | dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion
55 | dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields
56 | dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style
57 | dotnet_naming_symbols.constant_fields.applicable_kinds = field
58 | dotnet_naming_symbols.constant_fields.applicable_accessibilities = *
59 | dotnet_naming_symbols.constant_fields.required_modifiers = const
60 | ###############################
61 | # C# Coding Conventions #
62 | ###############################
63 | [*.cs]
64 | # var preferences
65 | csharp_style_var_for_built_in_types = true:silent
66 | csharp_style_var_when_type_is_apparent = true:silent
67 | csharp_style_var_elsewhere = true:silent
68 | # Expression-bodied members
69 | csharp_style_expression_bodied_methods = false:silent
70 | csharp_style_expression_bodied_constructors = false:silent
71 | csharp_style_expression_bodied_operators = false:silent
72 | csharp_style_expression_bodied_properties = true:silent
73 | csharp_style_expression_bodied_indexers = true:silent
74 | csharp_style_expression_bodied_accessors = true:silent
75 | # Pattern matching preferences
76 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
77 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
78 | # Null-checking preferences
79 | csharp_style_throw_expression = true:suggestion
80 | csharp_style_conditional_delegate_call = true:suggestion
81 | # Modifier preferences
82 | csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion
83 | # Expression-level preferences
84 | csharp_prefer_braces = true:silent
85 | csharp_style_deconstructed_variable_declaration = true:suggestion
86 | csharp_prefer_simple_default_expression = true:suggestion
87 | csharp_style_pattern_local_over_anonymous_function = true:suggestion
88 | csharp_style_inlined_variable_declaration = true:suggestion
89 | ###############################
90 | # C# Formatting Rules #
91 | ###############################
92 | # New line preferences
93 | csharp_new_line_before_open_brace = all
94 | csharp_new_line_before_else = true
95 | csharp_new_line_before_catch = true
96 | csharp_new_line_before_finally = true
97 | csharp_new_line_before_members_in_object_initializers = true
98 | csharp_new_line_before_members_in_anonymous_types = true
99 | csharp_new_line_between_query_expression_clauses = true
100 | # Indentation preferences
101 | csharp_indent_case_contents = true
102 | csharp_indent_switch_labels = true
103 | csharp_indent_labels = flush_left
104 | # Space preferences
105 | csharp_space_after_cast = false
106 | csharp_space_after_keywords_in_control_flow_statements = true
107 | csharp_space_between_method_call_parameter_list_parentheses = false
108 | csharp_space_between_method_declaration_parameter_list_parentheses = false
109 | csharp_space_between_parentheses = false
110 | csharp_space_before_colon_in_inheritance_clause = true
111 | csharp_space_after_colon_in_inheritance_clause = true
112 | csharp_space_around_binary_operators = before_and_after
113 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
114 | csharp_space_between_method_call_name_and_opening_parenthesis = false
115 | csharp_space_between_method_call_empty_parameter_list_parentheses = false
116 | # Wrapping preferences
117 | csharp_preserve_single_line_statements = true
118 | csharp_preserve_single_line_blocks = true
119 | ###############################
120 | # VB Coding Conventions #
121 | ###############################
122 | [*.vb]
123 | # Modifier preferences
124 | visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion
125 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/App.xaml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/App.xaml.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Text;
5 | using System.Threading;
6 | using System.Threading.Tasks;
7 | using System.Windows;
8 | using System.Windows.Threading;
9 | using BrowserPicker.View;
10 | using BrowserPicker.ViewModel;
11 |
12 | namespace BrowserPicker;
13 |
14 | public partial class App
15 | {
16 | private const int LoadingWindowDelayMilliseconds = 300;
17 |
18 | ///
19 | /// This CancellationToken gets cancelled when the application exits
20 | ///
21 | private static CancellationTokenSource ApplicationCancellationToken { get; } = new();
22 |
23 | public static IBrowserPickerConfiguration Settings { get; set; } = null!;
24 |
25 | private class InvalidUTF8Patch : EncodingProvider
26 | {
27 | public override Encoding? GetEncoding(int codepage)
28 | {
29 | return null;
30 | }
31 |
32 | public override Encoding? GetEncoding(string name)
33 | {
34 | return name.ToLowerInvariant().Replace("-", "") switch
35 | {
36 | "utf8" => Encoding.UTF8,
37 | _ => null
38 | };
39 | }
40 | }
41 |
42 | public App()
43 | {
44 | Encoding.RegisterProvider(new InvalidUTF8Patch());
45 | Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
46 | BackgroundTasks.Add(Settings);
47 |
48 | // Basic unhandled exception catchment
49 | AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException;
50 | DispatcherUnhandledException += OnDispatcherUnhandledException;
51 |
52 | // Get command line arguments and initialize ViewModel
53 | var arguments = Environment.GetCommandLineArgs().Skip(1).ToList();
54 | try
55 | {
56 | ViewModel = new ApplicationViewModel(arguments, Settings);
57 | if (ViewModel.Url.TargetURL != null)
58 | {
59 | BackgroundTasks.Add(ViewModel.Url);
60 | }
61 | }
62 | catch (Exception exception)
63 | {
64 | ShowExceptionReport(exception);
65 | }
66 | }
67 |
68 | protected override void OnStartup(StartupEventArgs e)
69 | {
70 | var worker = StartupBackgroundTasks();
71 | worker.ContinueWith(CheckBackgroundTasks);
72 | }
73 |
74 | ///
75 | /// This method should never be called, as the StartupBackgroundTasks has robust exception handling
76 | ///
77 | private static void CheckBackgroundTasks(Task task)
78 | {
79 | if (task.IsFaulted)
80 | {
81 | MessageBox.Show(
82 | task.Exception?.ToString() ?? string.Empty,
83 | "Error",
84 | MessageBoxButton.OK,
85 | MessageBoxImage.Error
86 | );
87 | }
88 | }
89 |
90 | private async Task StartupBackgroundTasks()
91 | {
92 | // Something failed during startup, abort.
93 | if (ViewModel == null)
94 | {
95 | return;
96 | }
97 | CancellationTokenSource? urlLookup = null;
98 | Task? loadingWindow = null;
99 | try
100 | {
101 | // Hook up shutdown on the viewmodel to shut down the application
102 | ViewModel.OnShutdown += ExitApplication;
103 |
104 | // Catch user switching to another window
105 | Deactivated += (_, _) => ViewModel.OnDeactivated();
106 |
107 | long_running_processes = RunLongRunningProcesses();
108 |
109 | // Open in configuration mode if user started BrowserPicker directly
110 | if (string.IsNullOrWhiteSpace(ViewModel.Url.TargetURL))
111 | {
112 | ShowMainWindow();
113 | return;
114 | }
115 |
116 | // Create a CancellationToken that cancels after the lookup timeout
117 | // to limit the amount of time spent looking up underlying URLs
118 | urlLookup = ViewModel.Configuration.GetUrlLookupTimeout();
119 | try
120 | {
121 | // Show LoadingWindow after a small delay
122 | // Goal is to avoid flicker for fast loading sites but to show progress for sites that take longer
123 | loadingWindow = ShowLoadingWindow(urlLookup.Token);
124 |
125 | // Wait for long-running processes in case they finish quickly
126 | await Task.Run(() => long_running_processes.Wait(ApplicationCancellationToken.Token), urlLookup.Token);
127 |
128 | // cancel the token to prevent showing LoadingWindow if it is not needed and has not been shown already
129 | await urlLookup.CancelAsync();
130 |
131 | ShowMainWindow();
132 |
133 | // close loading window if it got opened
134 | var waited = await loadingWindow;
135 | waited?.Close();
136 | }
137 | catch (TaskCanceledException)
138 | {
139 | // Open up the browser picker window
140 | ShowMainWindow();
141 | }
142 | }
143 | catch (Exception exception)
144 | {
145 | try { if (urlLookup != null) await urlLookup.CancelAsync(); } catch { /* ignored */ }
146 | try { if (loadingWindow != null) (await loadingWindow)?.Close(); } catch { /* ignored */ }
147 | try { if (ViewModel != null) ViewModel.OnShutdown -= ExitApplication; } catch { /* ignored */ }
148 | ShowExceptionReport(exception);
149 | }
150 | }
151 |
152 | private static async Task RunLongRunningProcesses()
153 | {
154 | try
155 | {
156 | var tasks = BackgroundTasks.Select(task => task.Start(ApplicationCancellationToken.Token)).ToArray();
157 | await Task.WhenAll(tasks);
158 | foreach (var task in tasks)
159 | {
160 | await task;
161 | }
162 | }
163 | catch (TaskCanceledException)
164 | {
165 | // ignored
166 | }
167 | }
168 |
169 | ///
170 | /// Tells the ViewModel it can initialize and then show the browser list window
171 | ///
172 | private void ShowMainWindow()
173 | {
174 | ViewModel?.Initialize();
175 | MainWindow = new MainWindow
176 | {
177 | DataContext = ViewModel
178 | };
179 | MainWindow.Show();
180 | MainWindow.Focus();
181 | }
182 |
183 | ///
184 | /// Shows the loading message window after a short delay, to let the user know we are in fact working on it
185 | ///
186 | /// token that will cancel when the loading is complete or timed out
187 | /// The loading message window, so it may be closed.
188 | private static async Task ShowLoadingWindow(CancellationToken cancellationToken)
189 | {
190 | try
191 | {
192 | await Task.Delay(LoadingWindowDelayMilliseconds, cancellationToken);
193 | }
194 | catch (TaskCanceledException)
195 | {
196 | return null;
197 | }
198 | var window = new LoadingWindow();
199 | window.Show();
200 | return window;
201 | }
202 |
203 | private static void ShowExceptionReport(Exception exception)
204 | {
205 | var viewModel = new ExceptionViewModel(exception);
206 | var window = new ExceptionReport();
207 | viewModel.OnWindowClosed += (_, _) => window.Close();
208 | window.DataContext = viewModel;
209 | window.Show();
210 | window.Focus();
211 | }
212 |
213 | ///
214 | /// Bare-bones exception handler
215 | ///
216 | ///
217 | ///
218 | private static void CurrentDomainOnUnhandledException(object sender, UnhandledExceptionEventArgs unhandledException)
219 | {
220 | ApplicationCancellationToken.Cancel();
221 | _ = MessageBox.Show(unhandledException.ExceptionObject.ToString());
222 | }
223 |
224 | private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
225 | {
226 | ApplicationCancellationToken.Cancel();
227 | _ = MessageBox.Show(e.Exception.ToString());
228 | }
229 |
230 | private static void ExitApplication(object? sender, EventArgs args)
231 | {
232 | ApplicationCancellationToken.Cancel();
233 | try
234 | {
235 | long_running_processes?.Wait();
236 | }
237 | catch (TaskCanceledException)
238 | {
239 | // ignore;
240 | }
241 | Current.Shutdown();
242 | }
243 |
244 | public ApplicationViewModel? ViewModel { get; }
245 | public static IServiceProvider Services { get; set; } = null!;
246 |
247 | private static readonly List BackgroundTasks = [];
248 | private static Task? long_running_processes;
249 | }
250 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/BrowserPicker.App.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | WinExe
4 | WinExe
5 | BrowserPicker
6 | BrowserPicker
7 | Dynamically pick browser on the fly
8 | true
9 | true
10 | Resources\web_icon.ico
11 | False
12 | enable
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | MSBuild:Compile
33 | Wpf
34 | Designer
35 |
36 |
37 |
38 |
39 | PreserveNewest
40 |
41 |
42 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/Converter/IconConverter.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Drawing;
4 | using System.Drawing.Imaging;
5 | using System.Globalization;
6 | using System.IO;
7 | using System.Windows;
8 | using System.Windows.Data;
9 | using System.Windows.Media.Imaging;
10 |
11 | namespace BrowserPicker.Converter;
12 |
13 | public sealed class IconFileToImageConverter : IValueConverter
14 | {
15 | public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
16 | {
17 | if (value is byte[])
18 | return value;
19 |
20 | if (value?.ToString() is not { } iconPath)
21 | return GetDefaultIcon();
22 |
23 | if (cache.TryGetValue(iconPath, out var cachedIcon))
24 | {
25 | return cachedIcon;
26 | }
27 |
28 | if (string.IsNullOrWhiteSpace(iconPath))
29 | {
30 | return GetDefaultIcon();
31 | }
32 |
33 | var realIconPath = iconPath.Trim('"', '\'', ' ', '\t', '\r', '\n');
34 | try
35 | {
36 | if (!File.Exists(realIconPath) && realIconPath.Contains('%'))
37 | realIconPath = Environment.ExpandEnvironmentVariables(realIconPath);
38 |
39 | if (!File.Exists(realIconPath))
40 | return GetDefaultIcon();
41 |
42 | Stream icon;
43 | if (realIconPath.EndsWith(".exe") || realIconPath.EndsWith(".dll"))
44 | {
45 | var iconData = Icon.ExtractAssociatedIcon(realIconPath)?.ToBitmap();
46 | if (iconData == null)
47 | return GetDefaultIcon();
48 | icon = new MemoryStream();
49 | iconData.Save(icon, ImageFormat.Png);
50 | }
51 | else
52 | {
53 | icon = File.Open(realIconPath, FileMode.Open, FileAccess.Read, FileShare.Read);
54 | }
55 |
56 | cache.Add(iconPath, BitmapFrame.Create(icon));
57 | return cache[iconPath];
58 | }
59 | catch
60 | {
61 | // ignored
62 | }
63 | return GetDefaultIcon();
64 | }
65 |
66 | public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
67 | {
68 | return null;
69 | }
70 |
71 | private static object GetDefaultIcon()
72 | {
73 | return Application.Current.TryFindResource("DefaultIcon");
74 | }
75 |
76 | private readonly Dictionary cache = [];
77 | }
78 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/Program.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Windows;
3 | using BrowserPicker.View;
4 | using BrowserPicker.Windows;
5 | using Microsoft.Extensions.DependencyInjection;
6 | using Microsoft.Extensions.Hosting;
7 | using Microsoft.Extensions.Logging;
8 |
9 | namespace BrowserPicker;
10 |
11 | internal static class Program
12 | {
13 | [STAThread]
14 | private static void Main(string[] args)
15 | {
16 | var builder = Host.CreateDefaultBuilder()
17 | .ConfigureServices(services =>
18 | {
19 | services.AddSingleton();
20 | services.AddSingleton();
21 | services.AddSingleton();
22 | })
23 | .ConfigureLogging(logging => logging.AddEventLog(
24 | settings => settings.SourceName = "BrowserPicker"
25 | ));
26 |
27 | var host = builder.Build();
28 | App.Services = host.Services;
29 | App.Settings = host.Services.GetRequiredService();
30 | var app = host.Services.GetRequiredService();
31 |
32 | var resourceDictionary = new ResourceDictionary
33 | {
34 | Source = new Uri(
35 | "pack://application:,,,/BrowserPicker;component/Resources/ResourceDictionary.xaml",
36 | UriKind.Absolute
37 | )
38 | };
39 | app.Resources.MergedDictionaries.Add(resourceDictionary);
40 | var logger = host.Services.GetRequiredService>();
41 | logger.LogApplicationLaunched(args);
42 |
43 | app.Run();
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/Properties/PublishProfiles/FolderProfile.pubxml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 | SignedRelease
8 | Any CPU
9 | bin\SignedRelease\net6.0-windows\win-x64\publish\
10 | FileSystem
11 | net6.0-windows
12 | win-x64
13 | false
14 | True
15 |
16 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/Properties/launchSettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "profiles": {
3 | "Configuration UI": {
4 | "commandName": "Project"
5 | },
6 | "rutracker": {
7 | "commandName": "Project",
8 | "commandLineArgs": "https://rutracker.org/forum/viewtopic.php?t=6350891"
9 | },
10 | "github url": {
11 | "commandName": "Project",
12 | "commandLineArgs": "https://github.com/mortenn/BrowserPicker"
13 | },
14 | "file url": {
15 | "commandName": "Project",
16 | "commandLineArgs": "file://c:/windows/win.ini"
17 | },
18 | "unc url": {
19 | "commandName": "Project",
20 | "commandLineArgs": "file://server/share/file.txt"
21 | },
22 | "long url": {
23 | "commandName": "Project",
24 | "commandLineArgs": "https://extremely-long-domain-example-for-design-time-use.some-long-domain-name.com/with-a-long-query-path/Lorem-ipsum-dolor-sit-amet,-consectetur-adipiscing-elit.-Integer-fermentum,-ipsum-quis-cursus-finibus,-turpis-lectus-tincidunt-elit,-eget-consectetur-tellus-leo-eget-neque.-Praesent.?and-a-param-for-good-measure=42"
25 | }
26 | }
27 | }
--------------------------------------------------------------------------------
/src/BrowserPicker.App/Resources/ResourceDictionary.xaml:
--------------------------------------------------------------------------------
1 |
3 | pack://application:,,,/Resources/web_icon.png
4 | pack://application:,,,/Resources/privacy.png
5 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/Resources/privacy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/src/BrowserPicker.App/Resources/privacy.png
--------------------------------------------------------------------------------
/src/BrowserPicker.App/Resources/web_icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/src/BrowserPicker.App/Resources/web_icon.ico
--------------------------------------------------------------------------------
/src/BrowserPicker.App/Resources/web_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mortenn/BrowserPicker/e409f39d885b51f0b385664a1a49cb2177c25d31/src/BrowserPicker.App/Resources/web_icon.png
--------------------------------------------------------------------------------
/src/BrowserPicker.App/View/BrowserEditor.xaml:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/View/BrowserEditor.xaml.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.ComponentModel;
3 | using System.Diagnostics;
4 | using System.Windows;
5 | using System.Windows.Controls;
6 | using System.Windows.Input;
7 | using BrowserPicker.ViewModel;
8 | using Microsoft.Win32;
9 | #if DEBUG
10 | using JetBrains.Annotations;
11 | #endif
12 |
13 | namespace BrowserPicker.View;
14 |
15 | ///
16 | /// Interaction logic for BrowserEditor.xaml
17 | ///
18 | public partial class BrowserEditor
19 | {
20 | #if DEBUG
21 | ///
22 | /// Design time constructor
23 | ///
24 | [UsedImplicitly]
25 | public BrowserEditor()
26 | {
27 | InitializeComponent();
28 | Browser = new BrowserViewModel();
29 | DataContext = Browser;
30 | }
31 | #endif
32 |
33 | public BrowserEditor(BrowserViewModel viewModel)
34 | {
35 | InitializeComponent();
36 | Browser = viewModel;
37 | DataContext = Browser;
38 | }
39 |
40 | private BrowserViewModel Browser { get; }
41 |
42 | private void Ok_OnClick(object sender, RoutedEventArgs e)
43 | {
44 | Close();
45 | }
46 |
47 | private void Cancel_OnClick(object sender, RoutedEventArgs e)
48 | {
49 | DataContext = null;
50 | Close();
51 | }
52 |
53 | private void Command_Browse(object sender, RoutedEventArgs e)
54 | {
55 | var browser = new OpenFileDialog
56 | {
57 | DefaultExt = ".exe",
58 | Filter = "Executable Files (*.exe)|*.exe|All Files|*.*"
59 | };
60 | var result = browser.ShowDialog(this);
61 | if (result != true)
62 | return;
63 |
64 | Browser.Model.Command = browser.FileName;
65 | if (string.IsNullOrEmpty(Browser.Model.Name))
66 | {
67 | try
68 | {
69 | var name = FileVersionInfo.GetVersionInfo(browser.FileName);
70 | Browser.Model.Name = name.FileDescription ?? string.Empty;
71 | }
72 | catch
73 | {
74 | // ignored
75 | }
76 | }
77 |
78 | if (string.IsNullOrEmpty(Browser.Model.IconPath))
79 | Browser.Model.IconPath = browser.FileName;
80 | }
81 |
82 | private void Icon_Browse(object sender, RoutedEventArgs e)
83 | {
84 | var browser = new OpenFileDialog
85 | {
86 | DefaultExt = ".exe",
87 | Filter = "Executable Files (*.exe)|*.exe|Icon Files (*.ico)|*.ico|JPEG Files (*.jpeg)|*.jpeg|PNG Files (*.png)|*.png|JPG Files (*.jpg)|*.jpg|GIF Files (*.gif)|*.gif|All Files|*.*"
88 | };
89 | if (browser.ShowDialog(this) == true)
90 | Browser.Model.IconPath = browser.FileName;
91 | }
92 |
93 |
94 | private void DragWindow(object sender, MouseButtonEventArgs args)
95 | {
96 | DragMove();
97 | }
98 |
99 | private void OnCustomKeyBindDown(object sender, KeyEventArgs e)
100 | {
101 | e.Handled = true;
102 | // Blacklisted keys
103 | // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
104 | switch (e.Key)
105 | {
106 | case Key.LeftAlt:
107 | case Key.LeftCtrl:
108 | case Key.LeftShift:
109 | case Key.RightAlt:
110 | case Key.RightCtrl:
111 | case Key.RightShift:
112 | case Key.System:
113 | case Key.LWin:
114 | case Key.Apps:
115 | case Key.Capital:
116 | case Key.NumLock:
117 | case Key.Scroll:
118 | case Key.OemClear:
119 | case Key.DeadCharProcessed:
120 | case Key.ImeProcessed:
121 | case Key.ImeConvert:
122 | case Key.C:
123 | case Key.Return:
124 | return;
125 | }
126 |
127 | var key = TypeDescriptor.GetConverter(typeof(Key)).ConvertToInvariantString(e.Key) ?? string.Empty;
128 | if (key == string.Empty || e.Key == Key.Escape)
129 | {
130 | ((TextBox)sender).Text = string.Empty;
131 | return;
132 | }
133 |
134 | var modifier = string.Empty;
135 | if (e.KeyboardDevice.Modifiers.HasFlag(ModifierKeys.Control))
136 | {
137 | modifier = "Ctrl+";
138 | }
139 |
140 | if (e.KeyboardDevice.Modifiers.HasFlag(ModifierKeys.Shift))
141 | {
142 | modifier += "Shift+";
143 | }
144 |
145 | ((TextBox)sender).Text = modifier + key;
146 | }
147 |
148 | private void OnCustomKeyBindUp(object sender, KeyEventArgs e)
149 | {
150 | e.Handled = true;
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/View/BrowserList.xaml:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
36 |
37 | 📌
38 |
39 |
40 |
41 |
49 |
50 | 📋
51 |
52 |
53 |
54 |
62 |
63 | ✏
64 |
65 |
66 |
67 |
68 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
94 |
95 |
96 |
97 |
98 |
99 |
104 |
105 | ✔️
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/View/BrowserList.xaml.cs:
--------------------------------------------------------------------------------
1 | using System.Windows;
2 | using BrowserPicker.ViewModel;
3 |
4 | namespace BrowserPicker.View;
5 |
6 | ///
7 | /// Interaction logic for BrowserList.xaml
8 | ///
9 | public partial class BrowserList
10 | {
11 | public BrowserList()
12 | {
13 | InitializeComponent();
14 | }
15 |
16 | private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
17 | {
18 | e.Handled = true;
19 | }
20 |
21 | private void Editor_KeyUp(object sender, System.Windows.Input.KeyEventArgs e)
22 | {
23 | if (e.Key is not (System.Windows.Input.Key.Enter))
24 | {
25 | return;
26 | }
27 |
28 | if (DataContext is ApplicationViewModel app)
29 | {
30 | app.EndEdit.Execute(null);
31 | }
32 | e.Handled = true;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/View/Configuration.xaml.cs:
--------------------------------------------------------------------------------
1 | namespace BrowserPicker.View;
2 |
3 | ///
4 | /// Interaction logic for Configuration.xaml
5 | ///
6 | public partial class Configuration
7 | {
8 | public Configuration()
9 | {
10 | InitializeComponent();
11 | }
12 |
13 | private void CheckBox_Checked(object sender, System.Windows.RoutedEventArgs e)
14 | {
15 |
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/View/ExceptionReport.xaml:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/View/ExceptionReport.xaml.cs:
--------------------------------------------------------------------------------
1 | namespace BrowserPicker.View;
2 |
3 | ///
4 | /// Interaction logic for Exception.xaml
5 | ///
6 | public partial class ExceptionReport
7 | {
8 | public ExceptionReport()
9 | {
10 | InitializeComponent();
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/View/LoadingWindow.xaml:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | Scanning URL...
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/View/LoadingWindow.xaml.cs:
--------------------------------------------------------------------------------
1 | using System.Windows;
2 |
3 | namespace BrowserPicker.View;
4 |
5 | ///
6 | /// Interaction logic for MainWindow.xaml
7 | ///
8 | public partial class LoadingWindow
9 | {
10 | public LoadingWindow()
11 | {
12 | InitializeComponent();
13 | DataContext = ((App)Application.Current).ViewModel;
14 | }
15 |
16 | private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
17 | {
18 | if (e.PreviousSize == e.NewSize)
19 | return;
20 |
21 | var w = SystemParameters.PrimaryScreenWidth;
22 | var h = SystemParameters.PrimaryScreenHeight;
23 |
24 | Left = (w - e.NewSize.Width) / 2;
25 | Top = (h - e.NewSize.Height) / 2;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/View/MainWindow.xaml:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
20 |
21 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
48 |
49 |
50 |
51 |
52 |
53 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/View/MainWindow.xaml.cs:
--------------------------------------------------------------------------------
1 | using System.ComponentModel;
2 | using System.Dynamic;
3 | using System.Windows;
4 | using System.Windows.Input;
5 | using JetBrains.Annotations;
6 | using BrowserPicker.ViewModel;
7 | using System.Linq;
8 |
9 | namespace BrowserPicker.View;
10 |
11 | ///
12 | /// Interaction logic for MainWindow.xaml
13 | ///
14 | [UsedImplicitly]
15 | public partial class MainWindow
16 | {
17 | public MainWindow()
18 | {
19 | InitializeComponent();
20 | DataContext = ((App)Application.Current).ViewModel;
21 | }
22 |
23 | private void MainWindow_OnKeyDown(object sender, KeyEventArgs e)
24 | {
25 | ViewModel.AltPressed = Keyboard.IsKeyDown(Key.LeftAlt) || Keyboard.IsKeyDown(Key.RightAlt);
26 | }
27 |
28 | private void MainWindow_OnKeyUp(object sender, KeyEventArgs e)
29 | {
30 | try
31 | {
32 | ViewModel.AltPressed = Keyboard.IsKeyDown(Key.LeftAlt) || Keyboard.IsKeyDown(Key.RightAlt);
33 | if (e.Key != Key.LeftCtrl && e.Key != Key.RightCtrl && e.Key != Key.LeftShift && e.Key != Key.RightShift)
34 | {
35 | var binding =
36 | (Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl) ? "Ctrl+" : string.Empty)
37 | + (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift) ? "Shift+" : string.Empty)
38 | + TypeDescriptor.GetConverter(typeof(Key)).ConvertToInvariantString(e.Key);
39 |
40 | var configured = ViewModel.Choices.FirstOrDefault(vm => vm.Model.CustomKeyBind == binding);
41 | if (configured != null)
42 | {
43 | if (Keyboard.IsKeyDown(Key.LeftAlt) || Keyboard.IsKeyDown(Key.RightAlt))
44 | {
45 | configured.SelectPrivacy.Execute(null);
46 | return;
47 | }
48 | configured.Select.Execute(null);
49 | return;
50 | }
51 | }
52 |
53 | if (e.Key == Key.Escape)
54 | Close();
55 |
56 | if (ViewModel.Url.TargetURL == null)
57 | return;
58 |
59 | int n;
60 | // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
61 | switch (e.Key == Key.System ? e.SystemKey : e.Key)
62 | {
63 | case Key.Enter:
64 | case Key.D1: n = 1; break;
65 | case Key.D2: n = 2; break;
66 | case Key.D3: n = 3; break;
67 | case Key.D4: n = 4; break;
68 | case Key.D5: n = 5; break;
69 | case Key.D6: n = 6; break;
70 | case Key.D7: n = 7; break;
71 | case Key.D8: n = 8; break;
72 | case Key.D9: n = 9; break;
73 | case Key.C: ViewModel.CopyUrl.Execute(null); return;
74 | default: return;
75 | }
76 |
77 | var choices = ViewModel.Choices.Where(vm => !vm.Model.Disabled).ToArray();
78 |
79 | if (choices.Length < n)
80 | return;
81 |
82 | if (ViewModel.AltPressed)
83 | {
84 | choices[n - 1].SelectPrivacy.Execute(null);
85 | }
86 | else
87 | {
88 | choices[n - 1].Select.Execute(null);
89 | }
90 | }
91 | catch
92 | {
93 | // ignored
94 | }
95 | }
96 |
97 | private ApplicationViewModel ViewModel => (ApplicationViewModel)DataContext;
98 |
99 | private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
100 | {
101 | if (e.PreviousSize == e.NewSize)
102 | return;
103 |
104 | var w = SystemParameters.PrimaryScreenWidth;
105 | var h = SystemParameters.PrimaryScreenHeight;
106 |
107 | Left = (w - e.NewSize.Width) / 2;
108 | Top = (h - e.NewSize.Height) / 2;
109 | }
110 |
111 | private void DragWindow(object sender, MouseButtonEventArgs args)
112 | {
113 | DragMove();
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/ViewModel/ApplicationViewModel.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Collections.ObjectModel;
4 | using System.Diagnostics;
5 | using System.Linq;
6 | using System.Threading;
7 | using System.Windows;
8 | using System.Windows.Input;
9 | using BrowserPicker.Framework;
10 | using Microsoft.Extensions.DependencyInjection;
11 | using Microsoft.Extensions.Logging;
12 | using Microsoft.Extensions.Logging.Abstractions;
13 | #if DEBUG
14 | using JetBrains.Annotations;
15 | #endif
16 |
17 | namespace BrowserPicker.ViewModel;
18 |
19 | ///
20 | /// Represents the main view model for the application. Manages application state,
21 | /// configuration, and browser selection behavior.
22 | ///
23 | public sealed class ApplicationViewModel : ModelBase
24 | {
25 | private static readonly ILogger Logger = App.Services.GetRequiredService>();
26 |
27 | #if DEBUG
28 | ///
29 | /// Default constructor used for WPF designer support.
30 | /// Initializes the URL handler, configuration, and sets up default browser choices.
31 | ///
32 | [UsedImplicitly]
33 | public ApplicationViewModel()
34 | {
35 | Url = new UrlHandler();
36 | force_choice = true;
37 | Configuration = new ConfigurationViewModel(App.Settings, this);
38 | Choices = new ObservableCollection(
39 | WellKnownBrowsers.List.Select(b => new BrowserViewModel(new BrowserModel(b, null, string.Empty), this))
40 | );
41 | }
42 |
43 | ///
44 | /// Alternate constructor primarily meant for WPF designer support.
45 | /// Initializes URL handler, configuration, and an empty browser choices list.
46 | ///
47 | /// The configuration view model to initialize the application state.
48 | internal ApplicationViewModel(ConfigurationViewModel config)
49 | {
50 | Url = new UrlHandler(NullLogger.Instance, "https://github.com/mortenn/BrowserPicker", config.Settings);
51 | force_choice = true;
52 | Configuration = config;
53 | Choices = [];
54 | }
55 | #endif
56 |
57 | ///
58 | /// Main constructor for initializing the view model with command-line arguments and application settings.
59 | ///
60 | /// Command-line arguments passed to the application.
61 | /// Configuration settings for the browser picker.
62 | public ApplicationViewModel(IReadOnlyCollection arguments, IBrowserPickerConfiguration settings)
63 | {
64 | var options = arguments.Where(arg => arg[0] == '/').ToList();
65 | force_choice = options.Contains("/choose");
66 | var url = arguments.Except(options).FirstOrDefault();
67 | // TODO Refactor to use IoC
68 | Url = new UrlHandler(App.Services.GetRequiredService>(), url, settings);
69 | ConfigurationMode = url == null;
70 | Configuration = new ConfigurationViewModel(settings, this)
71 | {
72 | ParentViewModel = this
73 | };
74 | var choices = settings.BrowserList.Select(m => new BrowserViewModel(m, this)).ToList();
75 | Choices = new ObservableCollection(choices);
76 | }
77 |
78 | public UrlHandler Url { get; }
79 |
80 | ///
81 | /// Initializes the application state. Handles first-time setup, configuration mode,
82 | /// and optional automatic browser launch based on URL and settings.
83 | ///
84 | public void Initialize()
85 | {
86 | if (Configuration.Settings.FirstTime)
87 | {
88 | Configuration.Welcome = true;
89 | ConfigurationMode = true;
90 | Configuration.Settings.FirstTime = false;
91 | return;
92 | }
93 |
94 | if (
95 | Url.TargetURL == null
96 | || Keyboard.Modifiers == ModifierKeys.Alt
97 | || Configuration.Settings.AlwaysPrompt
98 | || ConfigurationMode
99 | || force_choice)
100 | {
101 | return;
102 | }
103 |
104 | BrowserViewModel? start = GetBrowserToLaunch(Url.UnderlyingTargetURL ?? Url.TargetURL);
105 | Logger.LogAutomationChoice(start?.Model.Name);
106 |
107 | #if DEBUG
108 | if (Debugger.IsAttached && start != null)
109 | {
110 | Debug.WriteLine($"Skipping launch of browser {start.Model.Name} due to debugger being attached");
111 | return;
112 | }
113 | #endif
114 | start?.Select.Execute(null);
115 | }
116 |
117 | ///
118 | /// Determines and retrieves the appropriate browser to launch based on the provided URL.
119 | ///
120 | /// The URL to match against browser rules and settings.
121 | /// The browser view model to launch, or null if none is chosen.
122 | internal BrowserViewModel? GetBrowserToLaunch(string? targetUrl)
123 | {
124 | if (Configuration.Settings.AlwaysPrompt)
125 | {
126 | Logger.LogAutomationAlwaysPrompt();
127 | return null;
128 | }
129 | var urlBrowser = GetBrowserToLaunchForUrl(targetUrl);
130 | var browser = Choices.FirstOrDefault(c => c.Model.Name == urlBrowser);
131 | Logger.LogAutomationBrowserSelected(browser?.Model.Name, browser?.IsRunning);
132 | if (browser != null && (Configuration.Settings.AlwaysUseDefaults || browser.IsRunning))
133 | {
134 | return browser;
135 | }
136 | if (browser == null && Configuration.Settings.AlwaysAskWithoutDefault)
137 | {
138 | Logger.LogAutomationAlwaysPromptWithoutDefaults();
139 | return null;
140 | }
141 | var active = Choices.Where(b => b is { IsRunning: true, Model.Disabled: false }).ToList();
142 | Logger.LogAutomationRunningCount(active.Count);
143 | return active.Count == 1 ? active[0] : null;
144 | }
145 |
146 | ///
147 | /// Matches the given URL against configured rules to determine the preferred browser for the URL.
148 | ///
149 | /// The URL to evaluate against browser rules.
150 | /// The name of the preferred browser for the URL, or null if none is found.
151 | internal string? GetBrowserToLaunchForUrl(string? targetUrl)
152 | {
153 | if (Configuration.Settings.Defaults.Count <= 0 || string.IsNullOrWhiteSpace(targetUrl))
154 | {
155 | Logger.LogAutomationNoDefaultsConfigured();
156 | return null;
157 | }
158 |
159 | Uri url;
160 | try
161 | {
162 | url = new Uri(targetUrl);
163 | }
164 | catch (UriFormatException)
165 | {
166 | return null;
167 | }
168 | var auto = Configuration.Settings.Defaults
169 | .Select(rule => new { rule, matchLength = rule.MatchLength(url) })
170 | .Where(o => o.matchLength > 0)
171 | .ToList();
172 |
173 | Logger.LogAutomationMatchesFound(auto.Count);
174 |
175 | return auto.Count <= 0
176 | ? null
177 | : auto.OrderByDescending(o => o.matchLength).First().rule.Browser;
178 | }
179 |
180 | ///
181 | /// Toggles the application's configuration mode state.
182 | ///
183 | public ICommand Configure => new DelegateCommand(() => ConfigurationMode = !ConfigurationMode);
184 |
185 | ///
186 | /// Closes the application by triggering the shutdown event.
187 | ///
188 | public ICommand Exit => new DelegateCommand(() => OnShutdown?.Invoke(this, EventArgs.Empty));
189 |
190 | ///
191 | /// Copies the currently targeted URL to the system clipboard.
192 | ///
193 | public ICommand CopyUrl => new DelegateCommand(PerformCopyUrl);
194 |
195 | ///
196 | /// Opens the URL editor, allowing the user to modify the currently targeted URL.
197 | ///
198 | public ICommand Edit => new DelegateCommand(OpenURLEditor);
199 |
200 | ///
201 | /// Closes the URL editor, saving any changes made to the targeted URL.
202 | ///
203 | public ICommand EndEdit => new DelegateCommand(CloseURLEditor);
204 |
205 | ///
206 | /// Gets the view model responsible for managing application configuration settings.
207 | /// Provides access to user preferences and saved browser configurations.
208 | ///
209 | public ConfigurationViewModel Configuration { get; }
210 |
211 | ///
212 | /// Gets the list of browsers presented to the user.
213 | /// Allowing the user to select a browser based on specific criteria or preferences.
214 | ///
215 | public ObservableCollection Choices { get; }
216 |
217 | ///
218 | /// Gets or sets a value indicating whether the application is in configuration mode.
219 | /// Configuration mode displays settings and bypasses automatic browser selection during startup.
220 | ///
221 | public bool ConfigurationMode
222 | {
223 | get => configuration_mode;
224 | set
225 | {
226 | SetProperty(ref configuration_mode, value);
227 | }
228 | }
229 |
230 | ///
231 | /// Gets or sets the URL being edited by the user, backing the URL editor functionality.
232 | /// Changes take effect in the underlying URL handler.
233 | ///
234 | public string? EditURL
235 | {
236 | get => edit_url;
237 | set
238 | {
239 | SetProperty(ref edit_url, value);
240 | Url.UnderlyingTargetURL = value!;
241 | }
242 | }
243 |
244 | ///
245 | /// Gets or sets a value indicating whether the targeted URL has been successfully copied to the clipboard.
246 | /// Used to indicate the url was copied in the View.
247 | ///
248 | public bool Copied
249 | {
250 | get => copied;
251 | set => SetProperty(ref copied, value);
252 | }
253 |
254 | ///
255 | /// Gets or sets a value indicating whether the Alt key is pressed, signalling the users intent to activate privacy mode.
256 | ///
257 | public bool AltPressed
258 | {
259 | get => alt_pressed;
260 | set => SetProperty(ref alt_pressed, value);
261 | }
262 |
263 | ///
264 | /// Pins the window, keeping it around while the user does something else.
265 | ///
266 | public DelegateCommand PinWindow => new(() => Pinned = true);
267 |
268 | ///
269 | /// Gets or sets a value indicating whether the main application window is pinned.
270 | /// When pinned, the application bypasses certain automatic shutdown conditions.
271 | ///
272 | public bool Pinned
273 | {
274 | get => pinned;
275 | private set => SetProperty(ref pinned, value);
276 | }
277 |
278 | ///
279 | /// Event triggered to initiate application shutdown.
280 | /// It is wired to the method to terminate the application.
281 | ///
282 | public EventHandler? OnShutdown;
283 |
284 | ///
285 | /// Performs application shutdown when the main window becomes inactive,
286 | /// unless specific conditions like configuration mode or pinning are met.
287 | ///
288 | public void OnDeactivated()
289 | {
290 | if (!ConfigurationMode && !Debugger.IsAttached && !Pinned)
291 | {
292 | OnShutdown?.Invoke(this, EventArgs.Empty);
293 | }
294 | }
295 |
296 | ///
297 | /// Copies the underlying target URL to the clipboard in a thread-safe manner.
298 | ///
299 | private void PerformCopyUrl()
300 | {
301 | try
302 | {
303 | if (Url.UnderlyingTargetURL == null)
304 | {
305 | return;
306 | }
307 | var thread = new Thread(() => Clipboard.SetText(Url.UnderlyingTargetURL));
308 | thread.SetApartmentState(ApartmentState.STA);
309 | thread.Start();
310 | thread.Join();
311 | Copied = true;
312 | }
313 | catch
314 | {
315 | // ignored
316 | }
317 | }
318 |
319 | ///
320 | /// Opens the editor for the current URL, preparing it for user modifications.
321 | ///
322 | private void OpenURLEditor()
323 | {
324 | EditURL = Url.UnderlyingTargetURL;
325 | OnPropertyChanged(nameof(EditURL));
326 | }
327 |
328 | ///
329 | /// Closes the URL editor and clears the edit state.
330 | ///
331 | private void CloseURLEditor()
332 | {
333 | if (edit_url == null)
334 | {
335 | return;
336 | }
337 | edit_url = null;
338 | OnPropertyChanged(nameof(EditURL));
339 | }
340 |
341 | ///
342 | /// Reorders the list of browser choices based on the user's settings.
343 | ///
344 | internal void RefreshChoices()
345 | {
346 | var newOrder = Choices.OrderBy(c => c.Model, Configuration.Settings.BrowserSorter).ToArray();
347 | Choices.Clear();
348 | foreach (var choice in newOrder)
349 | {
350 | Choices.Add(choice);
351 | }
352 | }
353 |
354 | private bool configuration_mode;
355 | private string? edit_url;
356 | private bool alt_pressed;
357 | private readonly bool force_choice;
358 | private bool pinned;
359 | private bool copied;
360 | }
361 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/ViewModel/BrowserViewModel.cs:
--------------------------------------------------------------------------------
1 | using System.ComponentModel;
2 | using System.Diagnostics;
3 | using System.IO;
4 | using System.Linq;
5 | using System.Windows;
6 | using BrowserPicker.Framework;
7 | using BrowserPicker.View;
8 | #if DEBUG
9 | using JetBrains.Annotations;
10 | #endif
11 |
12 | namespace BrowserPicker.ViewModel;
13 |
14 | ///
15 | /// ViewModel class for representing and interacting with a browser in the application.
16 | /// Encapsulates logic for managing browser configurations and commands.
17 | ///
18 | [DebuggerDisplay("{" + nameof(Model) + "." + nameof(BrowserModel.Name) + "}")]
19 | public sealed class BrowserViewModel : ViewModelBase
20 | {
21 | #if DEBUG
22 | ///
23 | /// Parameterless constructor for WPF Designer support during debugging.
24 | /// Initializes the ViewModel with default values.
25 | ///
26 | [UsedImplicitly]
27 | public BrowserViewModel() : base(new BrowserModel())
28 | {
29 | parent_view_model = new ApplicationViewModel();
30 | }
31 | #endif
32 |
33 | ///
34 | /// Initializes a new instance of the class with the specified browser model and parent ViewModel.
35 | ///
36 | /// The browser model associated with this ViewModel.
37 | /// The parent application ViewModel.
38 | public BrowserViewModel(BrowserModel model, ApplicationViewModel viewModel) : base(model)
39 | {
40 | model.PropertyChanged += Model_PropertyChanged;
41 | parent_view_model = viewModel;
42 | parent_view_model.PropertyChanged += OnParentViewModelChanged;
43 | parent_view_model.Configuration.Settings.PropertyChanged += Settings_PropertyChanged;
44 | }
45 |
46 | ///
47 | /// Listens to property changed events to detect switching between automatic and manual ordering.
48 | ///
49 | private void Settings_PropertyChanged(object? sender, PropertyChangedEventArgs e)
50 | {
51 | if (e.PropertyName == nameof(IApplicationSettings.UseAutomaticOrdering))
52 | {
53 | OnPropertyChanged(nameof(IsManuallyOrdered));
54 | }
55 | }
56 |
57 | ///
58 | /// Listens to property changed events to detect the user holding down the Alt key.
59 | ///
60 | private void OnParentViewModelChanged(object? sender, PropertyChangedEventArgs e)
61 | {
62 | if (e.PropertyName == nameof(ApplicationViewModel.AltPressed))
63 | {
64 | OnPropertyChanged(nameof(AltPressed));
65 | }
66 | }
67 |
68 | ///
69 | /// Handles changes in the browser model's properties and updates command states accordingly.
70 | ///
71 | private void Model_PropertyChanged(object? sender, PropertyChangedEventArgs e)
72 | {
73 | switch (e.PropertyName)
74 | {
75 | case nameof(BrowserModel.PrivacyArgs):
76 | SelectPrivacy.RaiseCanExecuteChanged();
77 | break;
78 |
79 | case nameof(BrowserModel.Disabled):
80 | SelectPrivacy.RaiseCanExecuteChanged();
81 | Select.RaiseCanExecuteChanged();
82 | break;
83 | }
84 | }
85 |
86 | ///
87 | /// Gets a value indicating whether the browser list is ordered manually.
88 | ///
89 | public bool IsManuallyOrdered => !parent_view_model.Configuration.Settings.UseAutomaticOrdering;
90 |
91 | ///
92 | /// Gets the command to select the browser.
93 | ///
94 | public DelegateCommand Select => select ??= new DelegateCommand(() => Launch(false), () => CanLaunch(false));
95 |
96 | ///
97 | /// Gets the command to select the browser with privacy mode enabled.
98 | ///
99 | public DelegateCommand SelectPrivacy => select_privacy ??= new DelegateCommand(() => Launch(true), () => CanLaunch(true));
100 |
101 | ///
102 | /// Gets the command to enable or disable the browser.
103 | ///
104 | public DelegateCommand Disable => disable ??= new DelegateCommand(() => Model.Disabled = !Model.Disabled);
105 |
106 | ///
107 | /// Gets the command to remove the browser from the list.
108 | ///
109 | public DelegateCommand Remove => remove ??= new DelegateCommand(() => Model.Removed = true);
110 |
111 | ///
112 | /// Gets the command to open the browser editor.
113 | ///
114 | public DelegateCommand Edit => edit ??= new DelegateCommand(() => OpenEditor(Model));
115 |
116 | ///
117 | /// Gets the command to move the browser up in the list.
118 | ///
119 | public DelegateCommand MoveUp => move_up ??= new DelegateCommand(() => Swap(1), () => CanSwap(1));
120 |
121 | ///
122 | /// Gets the command to move the browser down in the list.
123 | ///
124 | public DelegateCommand MoveDown => move_down ??= new DelegateCommand(() => Swap(-1), () => CanSwap(-1));
125 |
126 | private DelegateCommand? select;
127 | private DelegateCommand? select_privacy;
128 | private DelegateCommand? disable;
129 | private DelegateCommand? remove;
130 | private DelegateCommand? edit;
131 | private DelegateCommand? move_up;
132 | private DelegateCommand? move_down;
133 |
134 | ///
135 | /// Determines if the browser can be swapped with another based on the specified offset.
136 | ///
137 | /// The offset to check for swapping (e.g., -1 for upward, +1 for downward).
138 | /// True if the browser can be swapped; otherwise, false.
139 | private bool CanSwap(int offset)
140 | {
141 | var choices = parent_view_model.Choices.Where(vm => !vm.Model.Removed).ToList();
142 | var i = choices.IndexOf(this);
143 | var ni = i - offset;
144 | return ni >= 0 && ni < choices.Count;
145 | }
146 |
147 | ///
148 | /// Swaps the browser's position in the list with another browser based on the specified offset.
149 | ///
150 | /// The offset indicating the direction and position for swapping.
151 | private void Swap(int offset)
152 | {
153 | foreach (var choice in parent_view_model.Choices)
154 | {
155 | choice.Model.ManualOrder = parent_view_model.Choices.IndexOf(choice);
156 | }
157 | var i = parent_view_model.Choices.IndexOf(this) - offset;
158 | var next = parent_view_model.Choices[i];
159 | (next.Model.ManualOrder, Model.ManualOrder) = (Model.ManualOrder, next.Model.ManualOrder);
160 | parent_view_model.RefreshChoices();
161 | }
162 |
163 | ///
164 | /// Opens the browser editor for modifying the given browser model.
165 | ///
166 | /// The browser model to be edited.
167 | private void OpenEditor(BrowserModel model)
168 | {
169 | var temp = new BrowserModel
170 | {
171 | Command = Model.Command,
172 | CommandArgs = model.CommandArgs,
173 | Executable = model.Executable,
174 | IconPath = model.IconPath,
175 | Name = model.Name,
176 | PrivacyArgs = model.PrivacyArgs,
177 | CustomKeyBind = model.CustomKeyBind,
178 | ManualOverride = model.ManualOverride,
179 | Disabled = model.Disabled,
180 | ManualOrder = model.ManualOrder,
181 | Usage = model.Usage,
182 | ExpandFileUrls = model.ExpandFileUrls
183 | };
184 | var editor = new BrowserEditor(new BrowserViewModel(temp, parent_view_model));
185 | editor.Show();
186 | editor.Closing += Editor_Closing;
187 | }
188 |
189 | ///
190 | /// Handles the closing event of the browser editor and saves changes to the model if applicable.
191 | ///
192 | /// The sender of the event (expected to be a ).
193 | /// The event arguments for the closing event.
194 | private void Editor_Closing(object? sender, CancelEventArgs e)
195 | {
196 | if ((sender as BrowserEditor)?.DataContext is not BrowserViewModel context)
197 | {
198 | return;
199 | }
200 |
201 | var save = context.Model;
202 |
203 | Model.Command = save.Command;
204 | Model.CommandArgs = save.CommandArgs;
205 | Model.IconPath = save.IconPath;
206 | Model.Name = save.Name;
207 | Model.Executable = save.Executable;
208 | Model.PrivacyArgs = save.PrivacyArgs;
209 | Model.ExpandFileUrls = save.ExpandFileUrls;
210 | Model.CustomKeyBind = save.CustomKeyBind;
211 | }
212 |
213 | public string PrivacyTooltip
214 | {
215 | get
216 | {
217 | var known = WellKnownBrowsers.Lookup(Model.Name, null);
218 | return known?.PrivacyMode ?? "Open in privacy mode";
219 | }
220 | }
221 |
222 | public bool IsRunning
223 | {
224 | get
225 | {
226 | try
227 | {
228 | var session = Process.GetCurrentProcess().SessionId;
229 |
230 | if (Model.Command == "microsoft-edge:" || Model.Command.Contains("MicrosoftEdge"))
231 | return Process.GetProcessesByName("MicrosoftEdge").Any(p => p.SessionId == session);
232 |
233 | string target;
234 | switch (Model.Executable)
235 | {
236 | case null:
237 | var cmd = Model.Command;
238 | if (cmd[0] == '"')
239 | cmd = cmd.Split('"')[1];
240 |
241 | target = cmd;
242 | break;
243 |
244 | default:
245 | target = Model.Executable;
246 | break;
247 | }
248 |
249 | return Process.GetProcessesByName(Path.GetFileNameWithoutExtension(target))
250 | .Any(p => p.SessionId == session && p.MainWindowHandle != 0 && p.MainModule?.FileName == target);
251 | }
252 | catch
253 | {
254 | // Design time exceptions
255 | return false;
256 | }
257 | }
258 | }
259 |
260 | public bool AltPressed => parent_view_model.AltPressed;
261 |
262 | ///
263 | /// Determines if the browser can be launched with the specified privacy setting.
264 | ///
265 | /// Whether the browser should be launched in privacy mode.
266 | /// True if the browser can be launched; otherwise, false.
267 | private bool CanLaunch(bool privacy)
268 | {
269 | return !string.IsNullOrWhiteSpace(parent_view_model.Url.TargetURL) && !(privacy && Model.PrivacyArgs == null);
270 | }
271 |
272 | ///
273 | /// Launches the browser with the specified privacy mode setting by executing the associated command.
274 | ///
275 | /// Whether the browser should be launched in privacy mode.
276 | private void Launch(bool privacy)
277 | {
278 | if (parent_view_model.Url.TargetURL == null)
279 | {
280 | return;
281 | }
282 | try
283 | {
284 | if (App.Settings.UseAutomaticOrdering)
285 | {
286 | Model.Usage++;
287 | }
288 |
289 | parent_view_model.Configuration.UrlOpened(parent_view_model.Url.HostName, Model.Name);
290 |
291 | var newArgs = privacy ? Model.PrivacyArgs : string.Empty;
292 | var url = parent_view_model.Url.GetTargetUrl(Model.ExpandFileUrls);
293 | var args = CombineArgs(Model.CommandArgs, $"{newArgs}\"{url}\"");
294 | var process = new ProcessStartInfo(Model.Command, args) { UseShellExecute = false };
295 | _ = Process.Start(process);
296 | }
297 | catch
298 | {
299 | // ignored
300 | }
301 | Application.Current?.Shutdown();
302 | return;
303 |
304 | string CombineArgs(string? args1, string args2)
305 | {
306 | if (string.IsNullOrEmpty(args1))
307 | {
308 | return args2;
309 | }
310 | return args1 + " " + args2;
311 | }
312 | }
313 |
314 | private readonly ApplicationViewModel parent_view_model;
315 | }
316 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/ViewModel/ConfigurationViewModel.cs:
--------------------------------------------------------------------------------
1 | using BrowserPicker.Framework;
2 | using System.ComponentModel;
3 | using System.Threading;
4 | using System.Linq;
5 | using System.Collections.ObjectModel;
6 | using System.Windows.Input;
7 | using BrowserPicker.View;
8 | using System.Windows;
9 | using System.Collections.Generic;
10 | using Microsoft.Win32;
11 | using System;
12 | using System.Diagnostics;
13 |
14 | #if DEBUG
15 | using System.Threading.Tasks;
16 | using JetBrains.Annotations;
17 | #endif
18 |
19 | namespace BrowserPicker.ViewModel;
20 |
21 | ///
22 | /// Represents the view model for configuring browser behavior and default settings
23 | /// in the BrowserPicker application.
24 | ///
25 | public sealed class ConfigurationViewModel : ModelBase
26 | {
27 | #if DEBUG
28 | ///
29 | /// Initializes a new instance of the class for the WPF designer with design-time data.
30 | ///
31 | [UsedImplicitly]
32 | public ConfigurationViewModel()
33 | {
34 | Settings = new DesignTimeSettings
35 | {
36 | Defaults = [
37 | new DefaultSetting(MatchType.Hostname, "github.com", Firefox.Instance.Name),
38 | new DefaultSetting(MatchType.Prefix, "https://gitlab.com", Edge.Instance.Name),
39 | new DefaultSetting(MatchType.Regex, @"runsafe\.no\/[0-9a-f]+$", InternetExplorer.Instance.Name),
40 | new DefaultSetting(MatchType.Hostname, "gitlab.com", OperaStable.Instance.Name),
41 | new DefaultSetting(MatchType.Hostname, "microsoft.com", MicrosoftEdge.Instance.Name),
42 | new DefaultSetting(MatchType.Default, "", Firefox.Instance.Name)
43 | ],
44 | BrowserList = [.. WellKnownBrowsers.List.Select(b => new BrowserModel(b, null, string.Empty))],
45 | DefaultBrowser = Firefox.Instance.Name
46 | };
47 | Welcome = true;
48 | foreach (var setting in Settings.Defaults.Where(d => d.Type != MatchType.Default))
49 | {
50 | Defaults.Add(setting);
51 | }
52 |
53 | ParentViewModel = new ApplicationViewModel(this)
54 | {
55 | ConfigurationMode = true
56 | };
57 | var choices = Settings.BrowserList.OrderBy(v => v, new BrowserSorter(Settings)).Select(m => new BrowserViewModel(m, ParentViewModel));
58 | foreach (var choice in choices)
59 | {
60 | ParentViewModel.Choices.Add(choice);
61 | }
62 | test_defaults_url = ParentViewModel.Url.UnderlyingTargetURL ?? ParentViewModel.Url.TargetURL;
63 | }
64 |
65 | private sealed class DesignTimeSettings : IBrowserPickerConfiguration
66 | {
67 | public bool FirstTime { get; set; } = false;
68 | public bool AlwaysPrompt { get; set; } = true;
69 | public bool AlwaysUseDefaults { get; set; } = true;
70 | public bool AlwaysAskWithoutDefault { get; set; }
71 | public int UrlLookupTimeoutMilliseconds { get; set; } = 2000;
72 | public bool UseAutomaticOrdering { get; set; } = false;
73 | public bool UseManualOrdering { get; set; } = false;
74 | public bool UseAlphabeticalOrdering { get; set; } = true;
75 | public bool DisableTransparency { get; set; } = true;
76 | public bool DisableNetworkAccess { get; set; } = false;
77 |
78 | public string[] UrlShorteners { get; set; } = [..UrlHandler.DefaultUrlShorteners, "example.com"];
79 |
80 | public List BrowserList { get; init; } = [];
81 |
82 | public List Defaults { get; init; } = [];
83 | public List KeyBindings { get; } = [];
84 |
85 | public bool UseFallbackDefault { get; set; } = true;
86 | public string? DefaultBrowser { get; set; }
87 |
88 | public event PropertyChangedEventHandler? PropertyChanged;
89 |
90 | public void AddBrowser(BrowserModel browser)
91 | {
92 | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BrowserList)));
93 | }
94 |
95 | public string BackupLog => "Backup log comes here\nWith multiple lines of text\nmaybe\nsometimes\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...\n...";
96 |
97 | public IComparer? BrowserSorter => null;
98 |
99 | public void AddDefault(MatchType matchType, string pattern, string browser)
100 | {
101 | }
102 |
103 | public void FindBrowsers()
104 | {
105 | }
106 |
107 | public Task LoadAsync(string fileName)
108 | {
109 | throw new UnreachableException();
110 | }
111 |
112 | public Task SaveAsync(string fileName)
113 | {
114 | throw new UnreachableException();
115 | }
116 |
117 | public Task Start(CancellationToken cancellationToken)
118 | {
119 | return Task.CompletedTask;
120 | }
121 | }
122 | #endif
123 |
124 | ///
125 | /// Initializes a new instance of the class with specified settings and parent view model.
126 | ///
127 | /// The browser picker configuration settings.
128 | /// The parent application view model.
129 | public ConfigurationViewModel(IBrowserPickerConfiguration settings, ApplicationViewModel parentViewModel)
130 | {
131 | ParentViewModel = parentViewModel;
132 | Defaults.CollectionChanged += Defaults_CollectionChanged;
133 | Settings = settings;
134 | foreach (var setting in Settings.Defaults.Where(d => d.Type != MatchType.Default))
135 | {
136 | Defaults.Add(setting);
137 | }
138 | settings.PropertyChanged += Configuration_PropertyChanged;
139 | }
140 |
141 | ///
142 | /// Handles changes in the Defaults collection and updates property change handlers.
143 | ///
144 | /// The collection that triggered the event.
145 | /// The event data related to the collection changes.
146 | private void Defaults_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
147 | {
148 | if (e.NewItems?.Count > 0)
149 | {
150 | foreach (var item in e.NewItems.OfType())
151 | {
152 | item.PropertyChanged += Item_PropertyChanged;
153 | }
154 | }
155 |
156 | if (!(e.OldItems?.Count > 0))
157 | {
158 | return;
159 | }
160 |
161 | foreach (var item in e.OldItems.OfType())
162 | {
163 | item.PropertyChanged -= Item_PropertyChanged;
164 | }
165 | }
166 |
167 | ///
168 | /// Handles property change events for individual items.
169 | /// Removes items from the Defaults collection if they are marked as deleted.
170 | ///
171 | /// The default setting item triggering the event.
172 | /// Details of the property that was changed.
173 | private void Item_PropertyChanged(object? sender, PropertyChangedEventArgs e)
174 | {
175 | if (e.PropertyName == nameof(DefaultSetting.Deleted) && sender is DefaultSetting { Deleted: true } item)
176 | {
177 | Defaults.Remove(item);
178 | }
179 | }
180 |
181 | ///
182 | /// Gets the configuration settings.
183 | ///
184 | public IBrowserPickerConfiguration Settings { get; }
185 |
186 | ///
187 | /// Gets the parent application ViewModel associated with this configuration.
188 | ///
189 | public ApplicationViewModel ParentViewModel { get; init; }
190 |
191 | ///
192 | /// Gets the default URL shorteners recognized by the application.
193 | ///
194 | public static string[] DefaultUrlShorteners => UrlHandler.DefaultUrlShorteners;
195 |
196 | ///
197 | /// Gets additional URL shorteners configured by the user that are not in the default list.
198 | ///
199 | public string[] AdditionalUrlShorteners => Settings.UrlShorteners.Except(DefaultUrlShorteners).ToArray();
200 |
201 | ///
202 | /// Gets or sets a value indicating whether the welcome message should be displayed to the user.
203 | ///
204 | public bool Welcome { get; internal set; }
205 |
206 | ///
207 | /// Gets or sets a value indicating whether defaults should be automatically added
208 | /// based on user behavior.
209 | ///
210 | public bool AutoAddDefault
211 | {
212 | get => auto_add_default;
213 | set => SetProperty(ref auto_add_default, value);
214 | }
215 |
216 | ///
217 | /// Gets the collection of default settings used by the browser picker.
218 | ///
219 | public ObservableCollection Defaults { get; } = [];
220 |
221 | ///
222 | /// Gets or sets the match type for defining a new default setting.
223 | ///
224 | public MatchType NewDefaultMatchType
225 | {
226 | get => new_match_type;
227 | set
228 | {
229 | // Ignore user if they select the special Default value and pretend they wanted Hostname
230 | if (value == MatchType.Default)
231 | {
232 | if (!SetProperty(ref new_match_type, MatchType.Hostname))
233 | {
234 | // Always fire in this case
235 | OnPropertyChanged();
236 | }
237 | return;
238 | }
239 | SetProperty(ref new_match_type, value);
240 | }
241 | }
242 |
243 | public string NewDefaultPattern { get => new_fragment; set => SetProperty(ref new_fragment, value); }
244 |
245 | public string NewDefaultBrowser { get => new_fragment_browser; set => SetProperty(ref new_fragment_browser, value); }
246 |
247 | public ICommand AddDefault => add_default ??= new DelegateCommand(AddDefaultSetting);
248 |
249 | public ICommand RefreshBrowsers => refresh_browsers ??= new DelegateCommand(FindBrowsers);
250 |
251 | public ICommand AddBrowser => add_browser ??= new DelegateCommand(AddBrowserManually);
252 |
253 | public ICommand Backup => backup ??= new DelegateCommand(PerformBackup);
254 |
255 | public ICommand Restore => restore ??= new DelegateCommand(PerformRestore);
256 |
257 | public ICommand AddShortener => add_shortener ??= new DelegateCommand(AddUrlShortener, CanAddShortener);
258 |
259 | public ICommand RemoveShortener => remove_shortener ??= new DelegateCommand(RemoveUrlShortener, CanRemoveShortener);
260 |
261 | ///
262 | /// Prompts the user to select a backup file location and saves the current settings.
263 | ///
264 | private void PerformBackup()
265 | {
266 | var browser = new SaveFileDialog
267 | {
268 | FileName = "BrowserPicker.json",
269 | DefaultExt = ".json",
270 | Filter = "JSON Files (*.json)|*.json|All Files|*.*",
271 | CheckPathExists = true,
272 | DefaultDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
273 | };
274 | var result = browser.ShowDialog();
275 | if (result != true)
276 | return;
277 | Settings.SaveAsync(browser.FileName);
278 | }
279 |
280 | ///
281 | /// Prompts the user to select a backup file and restores settings from it.
282 | ///
283 | private void PerformRestore()
284 | {
285 | var browser = new OpenFileDialog
286 | {
287 | FileName = "BrowserPicker.json",
288 | DefaultExt = ".json",
289 | Filter = "JSON Files (*.json)|*.json|All Files|*.*",
290 | CheckPathExists = true,
291 | DefaultDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
292 | };
293 | var result = browser.ShowDialog();
294 | if (result != true)
295 | return;
296 | Settings.LoadAsync(browser.FileName);
297 | }
298 |
299 | private void AddBrowserManually()
300 | {
301 | var editor = new BrowserEditor(new BrowserViewModel(new BrowserModel(), ParentViewModel));
302 | editor.Show();
303 | editor.Closing += Editor_Closing;
304 | }
305 |
306 | public string NewUrlShortener { get; set; } = string.Empty;
307 |
308 | private bool CanAddShortener(string? domain) => !(string.IsNullOrWhiteSpace(domain) || Settings.UrlShorteners.Contains(domain));
309 |
310 | private void AddUrlShortener(string? domain)
311 | {
312 | if (!CanAddShortener(domain))
313 | {
314 | return;
315 | }
316 | Settings.UrlShorteners = [..Settings.UrlShorteners, domain!];
317 | NewUrlShortener = string.Empty;
318 | OnPropertyChanged(nameof(NewUrlShortener));
319 | OnPropertyChanged(nameof(DefaultUrlShorteners));
320 | OnPropertyChanged(nameof(AdditionalUrlShorteners));
321 | }
322 |
323 | private bool CanRemoveShortener(string? domain) => !string.IsNullOrWhiteSpace(domain) && Settings.UrlShorteners.Contains(domain) && !UrlHandler.DefaultUrlShorteners.Contains(domain);
324 |
325 | private void RemoveUrlShortener(string? domain)
326 | {
327 | if (!CanRemoveShortener(domain))
328 | {
329 | return;
330 | }
331 |
332 | Settings.UrlShorteners = Settings.UrlShorteners.Except([domain!]).ToArray();
333 | OnPropertyChanged(nameof(DefaultUrlShorteners));
334 | OnPropertyChanged(nameof(AdditionalUrlShorteners));
335 | }
336 |
337 | private void Editor_Closing(object? sender, CancelEventArgs e)
338 | {
339 | if (sender is Window window)
340 | {
341 | window.Closing -= Editor_Closing;
342 | }
343 | if (sender is not Window { DataContext: BrowserViewModel browser })
344 | {
345 | return;
346 | }
347 | if (string.IsNullOrEmpty(browser.Model.Name) || string.IsNullOrEmpty(browser.Model.Command))
348 | {
349 | return;
350 | }
351 | ParentViewModel.Choices.Add(browser);
352 | Settings.AddBrowser(browser.Model);
353 | }
354 |
355 | internal void UrlOpened(string? hostName, string browser)
356 | {
357 | if (!AutoAddDefault || hostName == null)
358 | {
359 | return;
360 | }
361 |
362 | try
363 | {
364 | AddNewDefault(MatchType.Hostname, hostName, browser);
365 | }
366 | catch
367 | {
368 | // ignored
369 | }
370 | }
371 |
372 | private bool AddNewDefault(MatchType matchType, string pattern, string browser)
373 | {
374 | if (string.IsNullOrWhiteSpace(pattern) || string.IsNullOrWhiteSpace(browser))
375 | {
376 | return false;
377 | }
378 | Settings.AddDefault(matchType, pattern, browser);
379 | OnPropertyChanged(nameof(TestDefaultsResult));
380 | return true;
381 | }
382 |
383 | ///
384 | /// Adds a new default setting based on the currently defined properties.
385 | ///
386 | private void AddDefaultSetting()
387 | {
388 | if (AddNewDefault(NewDefaultMatchType, NewDefaultPattern, NewDefaultBrowser))
389 | {
390 | NewDefaultPattern = string.Empty;
391 | }
392 | }
393 |
394 | private void Configuration_PropertyChanged(object? sender, PropertyChangedEventArgs e)
395 | {
396 | switch (e.PropertyName)
397 | {
398 | case nameof(Settings.Defaults):
399 | UpdateDefaults();
400 | break;
401 |
402 | case nameof(Settings.BrowserList):
403 | UpdateSettings();
404 | break;
405 |
406 | case nameof(Settings.UseAutomaticOrdering) when Settings.UseAutomaticOrdering:
407 | CaptureBrowserOrder();
408 | break;
409 | }
410 | }
411 |
412 | private void CaptureBrowserOrder()
413 | {
414 | var browsers = Settings.BrowserList.Where(b => !b.Removed).ToArray();
415 | foreach (var browser in browsers)
416 | {
417 | browser.ManualOrder = Settings.BrowserList.IndexOf(browser);
418 | }
419 | }
420 |
421 | private void UpdateSettings()
422 | {
423 | BrowserViewModel[] added = [..
424 | from browser in Settings.BrowserList
425 | where ParentViewModel.Choices.All(c => c.Model.Name != browser.Name)
426 | select new BrowserViewModel(browser, ParentViewModel)
427 | ];
428 | foreach (var vm in added)
429 | {
430 | ParentViewModel.Choices.Add(vm);
431 | }
432 |
433 | BrowserViewModel[] removed = [..
434 | from choice in ParentViewModel.Choices
435 | where Settings.BrowserList.All(b => b.Name != choice.Model.Name)
436 | select choice
437 | ];
438 | foreach (var m in removed)
439 | {
440 | ParentViewModel.Choices.Remove(m);
441 | }
442 | }
443 |
444 | private void UpdateDefaults()
445 | {
446 | DefaultSetting[] added = [..
447 | from current in Settings.Defaults.Except(Defaults)
448 | where current.Type != MatchType.Default && !current.Deleted
449 | select current
450 | ];
451 | foreach (var setting in added)
452 | {
453 | Defaults.Add(setting);
454 | }
455 |
456 | DefaultSetting[] removed = [.. Defaults.Except(Settings.Defaults)];
457 | foreach (var setting in removed)
458 | {
459 | Defaults.Remove(setting);
460 | }
461 | }
462 |
463 | internal CancellationTokenSource GetUrlLookupTimeout()
464 | {
465 | return new CancellationTokenSource(Settings.UrlLookupTimeoutMilliseconds);
466 | }
467 |
468 | private void FindBrowsers()
469 | {
470 | Settings.FindBrowsers();
471 | OnPropertyChanged(nameof(TestDefaultsResult));
472 | }
473 |
474 | private MatchType new_match_type = MatchType.Hostname;
475 | private string new_fragment = string.Empty;
476 | private string new_fragment_browser = string.Empty;
477 | private bool auto_add_default;
478 | private DelegateCommand? add_default;
479 | private DelegateCommand? refresh_browsers;
480 | private DelegateCommand? add_browser;
481 | private DelegateCommand? backup;
482 | private DelegateCommand? restore;
483 | private DelegateCommand? add_shortener;
484 | private DelegateCommand? remove_shortener;
485 |
486 | private string? test_defaults_url;
487 |
488 | public string? TestDefaultsURL
489 | {
490 | get => test_defaults_url;
491 | set
492 | {
493 | SetProperty(ref test_defaults_url, value);
494 | OnPropertyChanged(nameof(TestDefaultsResult));
495 | OnPropertyChanged(nameof(TestActualResult));
496 | }
497 | }
498 |
499 | public string TestDefaultsResult
500 | {
501 | get
502 | {
503 | return ParentViewModel.GetBrowserToLaunchForUrl(test_defaults_url) ?? "User choice";
504 | }
505 | }
506 |
507 | public string TestActualResult
508 | {
509 | get
510 | {
511 | return ParentViewModel.GetBrowserToLaunch(test_defaults_url)?.Model.Name ?? "User choice";
512 | }
513 | }
514 | }
515 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/ViewModel/ExceptionViewModel.cs:
--------------------------------------------------------------------------------
1 | using BrowserPicker.Framework;
2 | using JetBrains.Annotations;
3 | using System;
4 | using System.Threading;
5 | using System.Windows;
6 |
7 | namespace BrowserPicker.ViewModel;
8 |
9 | public sealed class ExceptionViewModel : ViewModelBase
10 | {
11 | // WPF Designer
12 | [UsedImplicitly]
13 | public ExceptionViewModel() : base(new ExceptionModel(new Exception("Test", new Exception("Test 2", new Exception("Test 3")))))
14 | {
15 | }
16 |
17 | public ExceptionViewModel(Exception exception) : base (new ExceptionModel(exception))
18 | {
19 | CopyToClipboard = new DelegateCommand(CopyExceptionDetailsToClipboard);
20 | Ok = new DelegateCommand(CloseWindow);
21 | }
22 |
23 | public DelegateCommand? CopyToClipboard { get; }
24 | public DelegateCommand? Ok { get; }
25 |
26 | public EventHandler? OnWindowClosed;
27 |
28 | private void CopyExceptionDetailsToClipboard()
29 | {
30 | try
31 | {
32 | var thread = new Thread(() => Clipboard.SetText(Model.Exception.ToString()));
33 | thread.SetApartmentState(ApartmentState.STA);
34 | thread.Start();
35 | thread.Join();
36 | }
37 | catch
38 | {
39 | // ignored
40 | }
41 | }
42 |
43 | private void CloseWindow()
44 | {
45 | OnWindowClosed?.Invoke(this, EventArgs.Empty);
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/BrowserPicker.App/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "Logging": {
3 | "LogLevel": {
4 | "Default": "Debug",
5 | "System": "Information",
6 | "Microsoft": "Information"
7 | },
8 | "EventLog": {
9 | "LogLevel": {
10 | "Default": "Warning",
11 | "BrowserPicker": "Warning"
12 | }
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/src/BrowserPicker.Windows/BrowserPicker.Windows.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | 2.0.0.3
4 | enable
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/BrowserPicker.Windows/RegistryHelpers.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Win32;
2 | using System;
3 | using System.Collections.Generic;
4 | using System.IO;
5 | using System.Runtime.CompilerServices;
6 |
7 | namespace BrowserPicker.Windows;
8 |
9 | public static class RegistryHelpers
10 | {
11 | ///
12 | /// Retrieves the value associated with the specified name from the RegistryKey.
13 | ///
14 | /// The type of the value that is expected to be retrieved.
15 | /// The RegistryKey to retrieve the value from.
16 | /// The default value to return if the requested value doesn't exist or an error occurs.
17 | /// The name of the value to retrieve. Defaults to the name of the calling member.
18 | /// The retrieved value cast to type , or the if retrieval fails.
19 | public static T? Get(this RegistryKey key, T? defaultValue = default, [CallerMemberName] string? name = null)
20 | {
21 | try
22 | {
23 | if (typeof(T) == typeof(bool))
24 | return (T)(object)(((int?)key.GetValue(name) ?? 0) == 1);
25 |
26 | var value = key.GetValue(name);
27 | return value == null ? defaultValue : (T)value;
28 | }
29 | catch
30 | {
31 | return defaultValue;
32 | }
33 | }
34 |
35 | ///
36 | /// Retrieves a boolean value associated with the specified name from the RegistryKey.
37 | ///
38 | /// The RegistryKey to retrieve the value from.
39 | /// The default value to return if the requested value doesn't exist or an error occurs.
40 | /// The name of the value to retrieve. Defaults to the name of the calling member.
41 | /// The retrieved boolean value, or if retrieval fails.
42 | public static bool GetBool(this RegistryKey key, bool defaultValue = false, [CallerMemberName] string? name = null)
43 | {
44 | var value = key.GetValue(name);
45 | if (value == null)
46 | return defaultValue;
47 |
48 | return (int)value == 1;
49 | }
50 |
51 | ///
52 | /// Sets a value in the RegistryKey with the specified name and type.
53 | ///
54 | /// The type of the value to store.
55 | /// The RegistryKey to store the value in.
56 | /// The value to set in the registry. If null, the value will be deleted.
57 | /// The name of the value to set. Defaults to the name of the calling member.
58 | public static void Set(this RegistryKey key, T value, [CallerMemberName] string? name = null)
59 | {
60 | if (value == null)
61 | {
62 | if (name != null && key.GetValue(name) != null)
63 | {
64 | key.DeleteValue(name);
65 | }
66 | return;
67 | }
68 | if (typeof(T) == typeof(bool))
69 | {
70 | key.SetValue(name, (bool)(object)value ? 1 : 0, RegistryValueKind.DWord);
71 | return;
72 | }
73 | if (!TypeMap.ContainsKey(typeof(T)))
74 | {
75 | return;
76 | }
77 | key.SetValue(name, value, TypeMap[typeof(T)]);
78 | }
79 |
80 | ///
81 | /// Opens a subkey of the specified RegistryKey at the specified path.
82 | ///
83 | /// The RegistryKey to open the subkey from.
84 | /// The path to the subkey to open.
85 | /// The opened subkey, or null if it doesn't exist.
86 | public static RegistryKey? SubKey(this RegistryKey key, params string[] path)
87 | {
88 | return key.OpenSubKey(Path.Combine(path), true);
89 | }
90 |
91 | ///
92 | /// Ensures that a subkey exists at the specified path, creating it if necessary.
93 | ///
94 | /// The RegistryKey to ensure the subkey for.
95 | /// The path to the subkey to create or open.
96 | /// The created or opened subkey.
97 | public static RegistryKey EnsureSubKey(this RegistryKey key, params string[] path)
98 | {
99 | return key.CreateSubKey(Path.Combine(path), RegistryKeyPermissionCheck.ReadWriteSubTree);
100 | }
101 |
102 | ///
103 | /// Retrieves browser-related information (name, icon path, and shell command) from the specified RegistryKey.
104 | ///
105 | /// The RegistryKey containing the browser information.
106 | ///
107 | /// A tuple containing the browser name, icon path, and shell command, or null for each if retrieval fails.
108 | ///
109 | public static (string? name, string? icon, string? shell) GetBrowser(this RegistryKey key)
110 | {
111 | try
112 | {
113 | var name = (string?)key.GetValue(null);
114 |
115 | var icon = (string?)key.OpenSubKey("DefaultIcon", false)?.GetValue(null);
116 | if (icon?.Contains(',') ?? false)
117 | icon = icon.Split(',')[0];
118 | var shell = (string?)key.OpenSubKey(@"shell\open\command", false)?.GetValue(null);
119 |
120 | return (name, icon, shell);
121 | }
122 | catch
123 | {
124 | return (null, null, null);
125 | }
126 | }
127 |
128 | ///
129 | /// Opens or creates a subkey at the specified path under the specified RegistryKey.
130 | ///
131 | /// The RegistryKey to create the subkey in.
132 | /// The path to the subkey to open or create.
133 | /// The created or opened subkey.
134 | public static RegistryKey Open(this RegistryKey key, params string[] path)
135 | {
136 | return key.CreateSubKey(Path.Combine(path), true);
137 | }
138 |
139 | ///
140 | /// A mapping of .NET value types to their corresponding RegistryValueKind.
141 | ///
142 | private static readonly Dictionary TypeMap = new()
143 | {
144 | { typeof(string), RegistryValueKind.String },
145 | { typeof(int), RegistryValueKind.DWord },
146 | { typeof(long), RegistryValueKind.QWord },
147 | { typeof(string[]), RegistryValueKind.MultiString }
148 | };
149 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/BrowserModel.cs:
--------------------------------------------------------------------------------
1 | using BrowserPicker.Framework;
2 | using System.Diagnostics;
3 | using System.Text.Json.Serialization;
4 |
5 | namespace BrowserPicker;
6 |
7 | ///
8 | /// Represents a model for a browser, containing details about its name, command, executable path, icon, and other settings.
9 | ///
10 | [DebuggerDisplay("{" + nameof(Name) + "}")]
11 | public sealed class BrowserModel : ModelBase
12 | {
13 | ///
14 | /// Initializes a new instance of the class with default values.
15 | ///
16 | public BrowserModel()
17 | {
18 | name = string.Empty;
19 | command = string.Empty;
20 | }
21 |
22 | ///
23 | /// Initializes a new instance of the class using a well-known browser configuration.
24 | ///
25 | /// The known browser instance containing default browser properties.
26 | /// The path to the browser's icon.
27 | /// The shell command used to launch the browser.
28 | public BrowserModel(IWellKnownBrowser known, string? icon, string shell)
29 | {
30 | name = known.Name;
31 | command = shell;
32 | PrivacyArgs = known.PrivacyArgs;
33 | Executable = known.RealExecutable;
34 | IconPath = icon;
35 | }
36 |
37 | ///
38 | /// Initializes a new instance of the class with specified values.
39 | ///
40 | /// The name of the browser.
41 | /// The path to the browser's icon.
42 | /// The shell command used to launch the browser.
43 | public BrowserModel(string name, string? icon, string shell)
44 | {
45 | this.name = name;
46 | icon_path = icon;
47 | command = shell;
48 | }
49 |
50 | ///
51 | /// Gets or sets the name of the browser.
52 | ///
53 | public string Name
54 | {
55 | get => name;
56 | set => SetProperty(ref name, value);
57 | }
58 |
59 | ///
60 | /// Gets or sets the path to the browser's icon.
61 | ///
62 | public string? IconPath
63 | {
64 | get => icon_path;
65 | set => SetProperty(ref icon_path, value);
66 | }
67 |
68 | ///
69 | /// Gets or sets the shell command used to launch the browser.
70 | ///
71 | public string Command
72 | {
73 | get => command;
74 | set => SetProperty(ref command, value);
75 | }
76 |
77 | ///
78 | /// Gets or sets the path to the browser executable.
79 | ///
80 | public string? Executable
81 | {
82 | get => executable;
83 | set => SetProperty(ref executable, value);
84 | }
85 |
86 | ///
87 | /// Gets or sets additional arguments to provide when launching a URL.
88 | ///
89 | public string? CommandArgs
90 | {
91 | get => command_args;
92 | set => SetProperty(ref command_args, value);
93 | }
94 |
95 | ///
96 | /// Gets or sets additional arguments to launch a URL in private browsing mode.
97 | ///
98 | public string? PrivacyArgs
99 | {
100 | get => privacy_args;
101 | set => SetProperty(ref privacy_args, value);
102 | }
103 |
104 | ///
105 | /// Gets or sets the usage count of the browser.
106 | ///
107 | public int Usage { get; set; }
108 |
109 | ///
110 | /// Gets or sets a value indicating whether the browser is disabled.
111 | ///
112 | public bool Disabled
113 | {
114 | get => disabled;
115 | set => SetProperty(ref disabled, value);
116 | }
117 |
118 | ///
119 | /// Flag set when the user deletes a browser in the GUI.
120 | ///
121 | public bool Removed
122 | {
123 | get => removed;
124 | set
125 | {
126 | removed = value;
127 | Disabled = value;
128 | OnPropertyChanged();
129 | }
130 | }
131 |
132 | ///
133 | /// Gets or sets the order of the browser in manual sorting mode.
134 | ///
135 | public int ManualOrder
136 | {
137 | get => manual_order;
138 | set => SetProperty(ref manual_order, value);
139 | }
140 |
141 | ///
142 | /// Gets or sets a value determining whether "file://" URLs should be converted to
143 | /// regular UNC/local paths before launching them with this browser.
144 | ///
145 | public bool ExpandFileUrls
146 | {
147 | get => expand_file_url;
148 | set => SetProperty(ref expand_file_url, value);
149 | }
150 |
151 | ///
152 | /// Disables or enables updating the browser definition through browser detection.
153 | ///
154 | public bool ManualOverride
155 | {
156 | get => manual_override;
157 | set => SetProperty(ref manual_override, value);
158 | }
159 |
160 | ///
161 | /// Gets or sets a custom keybinding to launch the browser.
162 | ///
163 | [JsonIgnore]
164 | public string CustomKeyBind
165 | {
166 | get => custom_key;
167 | set => SetProperty(ref custom_key, value);
168 | }
169 |
170 | private bool disabled;
171 | private bool removed;
172 | private string name;
173 | private string? icon_path;
174 | private string command;
175 | private string? executable;
176 | private string? command_args;
177 | private string? privacy_args;
178 | private int manual_order;
179 | private bool expand_file_url;
180 | private bool manual_override;
181 | private string custom_key = string.Empty;
182 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/BrowserPicker.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | BrowserPicker.Common
4 | enable
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/BrowserPicker/BrowserSorter.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 |
4 | namespace BrowserPicker;
5 |
6 | public class BrowserSorter(IApplicationSettings configuration) : IComparer
7 | {
8 | public int Compare(BrowserModel? x, BrowserModel? y)
9 | {
10 | if (x == null || y == null)
11 | {
12 | return x == null && y == null ? 0 : x == null ? -1 : 1;
13 | }
14 |
15 | return configuration.UseAlphabeticalOrdering switch
16 | {
17 | true => string.Compare(x.Name, y.Name, StringComparison.Ordinal),
18 | false when configuration.UseManualOrdering => x.ManualOrder.CompareTo(y.ManualOrder),
19 | _ => y.Usage.CompareTo(x.Usage)
20 | };
21 | }
22 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/DefaultSetting.cs:
--------------------------------------------------------------------------------
1 | using BrowserPicker.Framework;
2 | using System;
3 | using System.ComponentModel;
4 | using System.Runtime.CompilerServices;
5 | using System.Text.Json.Serialization;
6 | using System.Text.RegularExpressions;
7 |
8 | namespace BrowserPicker;
9 |
10 | public sealed class DefaultSetting(MatchType initialType, string? initialPattern, string? initialBrowser) : ModelBase, INotifyPropertyChanging
11 | {
12 | private readonly Guid id = Guid.NewGuid();
13 | private MatchType type = initialType;
14 | private string? pattern = initialPattern;
15 | private string? browser = initialBrowser;
16 | private bool deleted;
17 |
18 | public static DefaultSetting? Decode(string? rule, string browser)
19 | {
20 | if (rule == null)
21 | {
22 | return new DefaultSetting(MatchType.Hostname, null, browser);
23 | }
24 | var config = rule.Split('|');
25 | return config.Length switch
26 | {
27 | // Default browser choice
28 | 1 when rule == string.Empty
29 | => new DefaultSetting(MatchType.Default, string.Empty, browser),
30 |
31 | // Original hostname match type
32 | <= 1
33 | => new DefaultSetting(MatchType.Hostname, rule, browser),
34 |
35 | // New configuration format using MatchType
36 | 3 when Enum.TryParse(rule[1..rule.IndexOf('|', 1)], true, out var matchType)
37 | => new DefaultSetting(matchType, config[2], browser),
38 |
39 | // Unsupported format, ignore
40 | _ => null
41 | };
42 | }
43 |
44 | public MatchType Type
45 | {
46 | get => type;
47 | set
48 | {
49 | if (type == value)
50 | {
51 | return;
52 | }
53 | OnPropertyChanging(nameof(SettingKey));
54 | type = value;
55 | OnPropertyChanged();
56 | OnPropertyChanged(nameof(SettingKey));
57 | }
58 | }
59 |
60 | [JsonIgnore]
61 | public string? SettingKey => ToString();
62 |
63 | [JsonIgnore]
64 | public string? SettingValue => Browser;
65 |
66 | [JsonIgnore]
67 | public bool Deleted
68 | {
69 | get => deleted;
70 | set
71 | {
72 | deleted = value;
73 | OnPropertyChanged();
74 | }
75 | }
76 |
77 | public string? Pattern
78 | {
79 | get => pattern;
80 | set
81 | {
82 | if (pattern == value)
83 | {
84 | return;
85 | }
86 | // Trigger deletion of old registry key
87 | OnPropertyChanging(nameof(SettingKey));
88 | pattern = value;
89 | OnPropertyChanged();
90 | OnPropertyChanged(nameof(IsValid));
91 | OnPropertyChanged(nameof(SettingKey));
92 | }
93 | }
94 |
95 | public string? Browser
96 | {
97 | get => browser;
98 | set
99 | {
100 | if (SetProperty(ref browser, value))
101 | {
102 | OnPropertyChanged(nameof(SettingValue));
103 | }
104 | }
105 | }
106 |
107 | [JsonIgnore]
108 | public bool IsValid => !string.IsNullOrWhiteSpace(pattern)
109 | || pattern == string.Empty && Type == MatchType.Default;
110 |
111 | [JsonIgnore]
112 | public DelegateCommand Remove => new(() => { Deleted = true; });
113 |
114 | public int MatchLength(Uri url)
115 | {
116 | if (!IsValid)
117 | {
118 | return 0;
119 | }
120 | return Type switch
121 | {
122 | MatchType.Default => 1,
123 | MatchType.Hostname when pattern is not null => url.Host.EndsWith(pattern) ? pattern.Length : 0,
124 | MatchType.Prefix when pattern is not null => url.OriginalString.StartsWith(pattern) ? pattern.Length : 0,
125 | MatchType.Regex when pattern is not null => Regex.Match(url.OriginalString, pattern).Length,
126 | MatchType.Contains when pattern is not null => url.OriginalString.Contains(pattern) ? pattern.Length : 0,
127 | _ => 0
128 | };
129 | }
130 |
131 | public override string? ToString() => Type switch
132 | {
133 | MatchType.Hostname => pattern,
134 | MatchType.Default => string.Empty,
135 | _ => $"|{Type}|{pattern}"
136 | };
137 |
138 | public override int GetHashCode() => Type.GetHashCode() ^ id.GetHashCode();
139 |
140 | private void OnPropertyChanging([CallerMemberName] string? propertyName = null)
141 | {
142 | PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(propertyName));
143 | }
144 |
145 | public event PropertyChangingEventHandler? PropertyChanging;
146 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/ExceptionModel.cs:
--------------------------------------------------------------------------------
1 | using BrowserPicker.Framework;
2 | using JetBrains.Annotations;
3 | using System;
4 |
5 | namespace BrowserPicker;
6 |
7 | public sealed class ExceptionModel(Exception exception) : ModelBase
8 | {
9 | // WPF Designer
10 | [UsedImplicitly]
11 | public ExceptionModel() : this(new Exception("Test", new Exception("Test 2", new Exception("Test 3"))))
12 | {
13 | }
14 |
15 | public Exception Exception { get; } = exception;
16 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/Framework/DelegateCommand.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Windows.Input;
3 | using JetBrains.Annotations;
4 |
5 | namespace BrowserPicker.Framework;
6 |
7 | public abstract class DelegateCommandBase : ICommand
8 | {
9 | public event EventHandler? CanExecuteChanged;
10 |
11 | public abstract bool CanExecute(object? parameter);
12 |
13 | public abstract void Execute(object? parameter);
14 |
15 | public void RaiseCanExecuteChanged()
16 | {
17 | CanExecuteChanged?.Invoke(this, EventArgs.Empty);
18 | }
19 | }
20 |
21 | [PublicAPI]
22 | public sealed class DelegateCommand(Action callback, Func? canExecute = null) : DelegateCommandBase
23 | {
24 | public override bool CanExecute(object? parameter)
25 | {
26 | return canExecute?.Invoke() ?? true;
27 | }
28 |
29 | public override void Execute(object? parameter)
30 | {
31 | callback();
32 | }
33 | }
34 |
35 | [PublicAPI]
36 | public sealed class DelegateCommand(Action callback, Func? canExecute = null) : DelegateCommandBase where T : class
37 | {
38 | public override bool CanExecute(object? parameter)
39 | {
40 | return canExecute?.Invoke(parameter as T) ?? true;
41 | }
42 |
43 | public override void Execute(object? parameter)
44 | {
45 | callback(parameter as T);
46 | }
47 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/Framework/ModelBase.cs:
--------------------------------------------------------------------------------
1 | using JetBrains.Annotations;
2 | using System.ComponentModel;
3 | using System.Runtime.CompilerServices;
4 |
5 | namespace BrowserPicker.Framework;
6 |
7 | public abstract class ModelBase : INotifyPropertyChanged
8 | {
9 | public event PropertyChangedEventHandler? PropertyChanged;
10 |
11 | [NotifyPropertyChangedInvocator]
12 | protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
13 | {
14 | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
15 | }
16 |
17 | protected bool SetProperty(ref T field, T newValue, [CallerMemberName] string? propertyName = null)
18 | {
19 | if (Equals(field, newValue))
20 | {
21 | return false;
22 | }
23 |
24 | field = newValue;
25 | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
26 | return true;
27 | }
28 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/Framework/ViewModelBase.cs:
--------------------------------------------------------------------------------
1 | namespace BrowserPicker.Framework;
2 |
3 | public abstract class ViewModelBase(T model) : ModelBase where T : ModelBase
4 | {
5 | public T Model { get; } = model;
6 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/IApplicationSettings.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 |
3 | namespace BrowserPicker;
4 |
5 | public interface IApplicationSettings
6 | {
7 | ///
8 | /// First time the user launches the application
9 | ///
10 | bool FirstTime { get; set; }
11 |
12 | ///
13 | /// When set to true, disables the automatic selection of a browser
14 | ///
15 | bool AlwaysPrompt { get; set; }
16 |
17 | ///
18 | /// When set to false, disable launching the default browser as defined by url pattern if it is not running
19 | ///
20 | bool AlwaysUseDefaults { get; set; }
21 |
22 | ///
23 | /// When set to true and there is no matching default browser, the user choice prompt will be shown
24 | ///
25 | bool AlwaysAskWithoutDefault { get; set; }
26 |
27 | ///
28 | /// Timeout for resolving underlying url for an address
29 | ///
30 | int UrlLookupTimeoutMilliseconds { get; set; }
31 |
32 | ///
33 | /// When set to true, lets user reorder the list of browsers manually
34 | ///
35 | /// Mutually exclusive with and
36 | bool UseManualOrdering { get; set; }
37 |
38 | ///
39 | /// When set to false, stops reordering the list of browsers based on popularity
40 | ///
41 | /// Mutually exclusive with and
42 | bool UseAutomaticOrdering { get; set; }
43 |
44 | ///
45 | /// When set to false, orders the list of browsers based on alphabetical order
46 | ///
47 | /// Mutually exclusive with and
48 | bool UseAlphabeticalOrdering { get; set; }
49 |
50 | ///
51 | /// Use transparency for the popup window
52 | ///
53 | bool DisableTransparency { get; set; }
54 |
55 | ///
56 | /// Disables all features that call out to the network
57 | ///
58 | bool DisableNetworkAccess { get; set; }
59 |
60 | ///
61 | /// List of host names known to be url shorteners
62 | ///
63 | string[] UrlShorteners { get; set; }
64 |
65 | ///
66 | /// The configured list of browsers
67 | ///
68 | List BrowserList { get; }
69 |
70 | ///
71 | /// Rules for per url browser defaults
72 | ///
73 | List Defaults { get; }
74 |
75 | ///
76 | /// Manual keybindings
77 | ///
78 | List KeyBindings { get; }
79 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/IBrowserPickerConfiguration.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.ComponentModel;
3 | using System.Threading.Tasks;
4 |
5 | namespace BrowserPicker;
6 |
7 | public interface IBrowserPickerConfiguration : IApplicationSettings, INotifyPropertyChanged, ILongRunningProcess
8 | {
9 | ///
10 | /// When true, only urls matching some record will give the user a choice.
11 | /// This makes BrowserPicker only seemingly apply for certain urls.
12 | ///
13 | public bool UseFallbackDefault { get; set; }
14 |
15 | ///
16 | /// The browser to use if is true and no match the url.
17 | ///
18 | public string? DefaultBrowser { get; set; }
19 |
20 | ///
21 | /// Add a new browser to the list
22 | ///
23 | void AddBrowser(BrowserModel browser);
24 |
25 | ///
26 | /// Scan the system for known browsers
27 | ///
28 | void FindBrowsers();
29 |
30 | ///
31 | /// Add a default setting rule to the configuration
32 | ///
33 | /// Type of match
34 | /// The url fragment to match
35 | /// The browser to use
36 | void AddDefault(MatchType matchType, string pattern, string browser);
37 |
38 | ///
39 | /// Exports all the configuration to a json file
40 | ///
41 | /// The full path of a json file
42 | Task SaveAsync(string fileName);
43 |
44 | ///
45 | /// Imports all the configuration from a json file
46 | ///
47 | /// The full path of a json file
48 | Task LoadAsync(string fileName);
49 |
50 | ///
51 | /// Logs from backup and restore
52 | ///
53 | public string BackupLog { get; }
54 |
55 | ///
56 | /// Sorter used to sort browsers
57 | ///
58 | IComparer? BrowserSorter { get; }
59 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/ILongRunningProcess.cs:
--------------------------------------------------------------------------------
1 | using System.Threading;
2 | using System.Threading.Tasks;
3 |
4 | namespace BrowserPicker;
5 |
6 | public interface ILongRunningProcess
7 | {
8 | Task Start(CancellationToken cancellationToken);
9 | }
10 |
--------------------------------------------------------------------------------
/src/BrowserPicker/KeyBinding.cs:
--------------------------------------------------------------------------------
1 | namespace BrowserPicker;
2 |
3 | public record KeyBinding(string Key, string Browser);
--------------------------------------------------------------------------------
/src/BrowserPicker/LoggingExtensions.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Net;
3 | using Microsoft.Extensions.Logging;
4 |
5 | namespace BrowserPicker;
6 |
7 | public static partial class LoggingExtensions
8 | {
9 | [LoggerMessage(EventId = 1001, Level = LogLevel.Debug, Message = "Application launched with arguments: {Args}")]
10 | public static partial void LogApplicationLaunched(this ILogger logger, string[] args);
11 |
12 | [LoggerMessage(EventId = 1002, Level = LogLevel.Debug, Message = "Requested URL: {URL}")]
13 | public static partial void LogRequestedUrl(this ILogger logger, string? url);
14 |
15 | [LoggerMessage(EventId = 1003, Level = LogLevel.Debug, Message = "Network access is disabled: {Flag}")]
16 | public static partial void LogNetworkAccessDisabled(this ILogger logger, bool flag);
17 |
18 | [LoggerMessage(EventId = 1004, Level = LogLevel.Information, Message = "Browser added: {BrowserName}")]
19 | public static partial void LogBrowserAdded(this ILogger logger, string browserName);
20 |
21 | [LoggerMessage(EventId = 1005, Level = LogLevel.Information, Message = "Browser removed: {BrowserName}")]
22 | public static partial void LogBrowserRemoved(this ILogger logger, string browserName);
23 |
24 | [LoggerMessage(EventId = 1006, Level = LogLevel.Information,
25 | Message = "Default setting added: MatchType - {MatchType}, Pattern - {Pattern}, Browser - {Browser}")]
26 | public static partial void LogDefaultSettingAdded(this ILogger logger, string matchType, string pattern,
27 | string? browser);
28 |
29 | [LoggerMessage(EventId = 1007, Level = LogLevel.Debug, Message = "Jump URL detected: {JumpUrl}")]
30 | public static partial void LogJumpUrl(this ILogger logger, Uri jumpUrl);
31 |
32 | [LoggerMessage(EventId = 1008, Level = LogLevel.Debug, Message = "Shortened URL detected: {ShortenedUrl}")]
33 | public static partial void LogShortenedUrl(this ILogger logger, string shortenedUrl);
34 |
35 | [LoggerMessage(EventId = 1009, Level = LogLevel.Debug, Message = "Failed to load favicon: {StatusCode}")]
36 | public static partial void LogFaviconFailed(this ILogger logger, HttpStatusCode statusCode);
37 |
38 | [LoggerMessage(EventId = 1010, Level = LogLevel.Debug, Message = "Trying default favicon")]
39 | public static partial void LogDefaultFavicon(this ILogger logger);
40 |
41 | [LoggerMessage(EventId = 1011, Level = LogLevel.Debug, Message = "Favicon could not be determined")]
42 | public static partial void LogFaviconNotFound(this ILogger logger);
43 |
44 | [LoggerMessage(EventId = 1012, Level = LogLevel.Debug, Message = "Favicon successfully loaded from URL: {Url}")]
45 | public static partial void LogFaviconLoaded(this ILogger logger, string url);
46 |
47 | [LoggerMessage(EventId = 1013, Level = LogLevel.Debug, Message = "Favicon found with URL: {Url}")]
48 | public static partial void LogFaviconFound(this ILogger logger, string url);
49 |
50 | [LoggerMessage(EventId = 1014, Level = LogLevel.Debug, Message = "Lookup of configured defaults returned browser: {Choice}")]
51 | public static partial void LogAutomationChoice(this ILogger logger, string? choice);
52 |
53 | [LoggerMessage(EventId = 1015, Level = LogLevel.Debug, Message = "Configured to always ask for browser choice")]
54 | public static partial void LogAutomationAlwaysPrompt(this ILogger logger);
55 |
56 | [LoggerMessage(EventId = 1016, Level = LogLevel.Information, Message = "Browser {BrowserName} was selected, running is: {IsRunning}")]
57 | public static partial void LogAutomationBrowserSelected(this ILogger logger, string? browserName, bool? isRunning);
58 |
59 | [LoggerMessage(EventId = 1017, Level = LogLevel.Debug, Message = "No defaults found, always prompt enabled")]
60 | public static partial void LogAutomationAlwaysPromptWithoutDefaults(this ILogger logger);
61 |
62 | [LoggerMessage(EventId = 1018, Level = LogLevel.Debug, Message = "Found {Count} running browsers")]
63 | public static partial void LogAutomationRunningCount(this ILogger logger, int count);
64 |
65 | [LoggerMessage(EventId = 1019, Level = LogLevel.Debug, Message = "No browser defaults configured")]
66 | public static partial void LogAutomationNoDefaultsConfigured(this ILogger logger);
67 |
68 | [LoggerMessage(EventId = 1020, Level = LogLevel.Information, Message = "{Count} configured defaults match the url")]
69 | public static partial void LogAutomationMatchesFound(this ILogger logger, int count);
70 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/MatchType.cs:
--------------------------------------------------------------------------------
1 | namespace BrowserPicker;
2 |
3 | public enum MatchType
4 | {
5 | Hostname,
6 | Prefix,
7 | Regex,
8 | Default,
9 | Contains
10 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/Pattern.cs:
--------------------------------------------------------------------------------
1 | using System.Text.RegularExpressions;
2 |
3 |
4 | #if DEBUG
5 | #endif
6 |
7 | namespace BrowserPicker;
8 |
9 | internal static partial class Pattern
10 | {
11 | [GeneratedRegex("]*rel=.?icon[^>]/>", RegexOptions.IgnoreCase, 100)]
12 | public static partial Regex HtmlLink();
13 |
14 | [GeneratedRegex("href=[\"']?(https?://\\S*)[\"']?", RegexOptions.IgnoreCase, 100)]
15 | public static partial Regex LinkHref();
16 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/SerializableSettings.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Linq;
3 | using System.Text.Json.Serialization;
4 |
5 | namespace BrowserPicker;
6 |
7 | public sealed class SerializableSettings : IApplicationSettings
8 | {
9 | public SerializableSettings(IApplicationSettings applicationSettings)
10 | {
11 | FirstTime = applicationSettings.FirstTime;
12 | AlwaysPrompt = applicationSettings.AlwaysPrompt;
13 | AlwaysUseDefaults = applicationSettings.AlwaysUseDefaults;
14 | AlwaysAskWithoutDefault = applicationSettings.AlwaysAskWithoutDefault;
15 | UrlLookupTimeoutMilliseconds = applicationSettings.UrlLookupTimeoutMilliseconds;
16 | UseAutomaticOrdering = applicationSettings.UseAutomaticOrdering;
17 | DisableTransparency = applicationSettings.DisableTransparency;
18 | DisableNetworkAccess = applicationSettings.DisableNetworkAccess;
19 | UrlShorteners = applicationSettings.UrlShorteners;
20 | BrowserList = [.. applicationSettings.BrowserList.Where(b => !b.Removed)];
21 | Defaults = [.. applicationSettings.Defaults.Where(d => !d.Deleted && !string.IsNullOrWhiteSpace(d.Browser))];
22 | KeyBindings = applicationSettings.KeyBindings
23 | .Where(kb => applicationSettings.BrowserList.Any(b => b.Name == kb.Browser && b.Removed == false))
24 | .ToList();
25 | }
26 |
27 | public SerializableSettings()
28 | {
29 | }
30 |
31 | public bool FirstTime { get; set; }
32 | public bool AlwaysPrompt { get; set; }
33 | public bool AlwaysUseDefaults { get; set; }
34 | public bool AlwaysAskWithoutDefault { get; set; }
35 | public int UrlLookupTimeoutMilliseconds { get; set; }
36 | public bool DisableTransparency { get; set; }
37 | public bool DisableNetworkAccess { get; set; }
38 | public string[] UrlShorteners { get; set; } = [];
39 | public List BrowserList { get; init; } = [];
40 | public List Defaults { get; init; } = [];
41 | public List KeyBindings { get; init; } = [];
42 |
43 | public SortOrder SortBy { get; set; }
44 |
45 | [JsonIgnore]
46 | public bool UseManualOrdering
47 | {
48 | get => SortBy == SortOrder.Manual;
49 | set
50 | {
51 | if (value)
52 | {
53 | SortBy = SortOrder.Manual;
54 | }
55 | }
56 | }
57 |
58 | [JsonIgnore]
59 | public bool UseAutomaticOrdering
60 | {
61 | get => SortBy == SortOrder.Automatic;
62 | set
63 | {
64 | if (value)
65 | {
66 | SortBy = SortOrder.Automatic;
67 | }
68 | }
69 | }
70 |
71 | [JsonIgnore]
72 | public bool UseAlphabeticalOrdering
73 | {
74 | get => SortBy == SortOrder.Alphabetical;
75 | set
76 | {
77 | if (value)
78 | {
79 | SortBy = SortOrder.Alphabetical;
80 | }
81 | }
82 | }
83 |
84 | public enum SortOrder
85 | {
86 | Automatic,
87 | Manual,
88 | Alphabetical
89 | }
90 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/UrlHandler.cs:
--------------------------------------------------------------------------------
1 | using BrowserPicker.Framework;
2 | using System;
3 | using System.Collections.Generic;
4 | using System.Linq;
5 | using System.Net.Http;
6 | using System.Threading;
7 | using System.Threading.Tasks;
8 | using System.Web;
9 | using System.Text.RegularExpressions;
10 | using Microsoft.Extensions.Logging;
11 | using Microsoft.Extensions.Logging.Abstractions;
12 |
13 |
14 | #if DEBUG
15 | using JetBrains.Annotations;
16 | #endif
17 |
18 | namespace BrowserPicker;
19 |
20 | public sealed class UrlHandler : ModelBase, ILongRunningProcess
21 | {
22 | private readonly ILogger logger;
23 |
24 | public UrlHandler(ILogger logger, string? requestedUrl, IApplicationSettings settings)
25 | {
26 | this.logger = logger;
27 | logger.LogRequestedUrl(requestedUrl);
28 | disallow_network = requestedUrl == null || settings.DisableNetworkAccess;
29 | logger.LogNetworkAccessDisabled(disallow_network);
30 | url_shorteners = [..settings.UrlShorteners];
31 |
32 | // Add new ones to config as requested
33 | var newShorteners = DefaultUrlShorteners.Except(url_shorteners).ToArray();
34 | if (newShorteners.Length > 0)
35 | {
36 | settings.UrlShorteners = [..settings.UrlShorteners, ..newShorteners];
37 | }
38 |
39 | TargetURL = requestedUrl;
40 | underlying_target_url = requestedUrl;
41 |
42 | if (requestedUrl == null)
43 | {
44 | return;
45 | }
46 | try
47 | {
48 | uri = new Uri(requestedUrl);
49 | host_name = uri.Host;
50 | }
51 | catch
52 | {
53 | host_name = string.Empty;
54 | // ignored
55 | }
56 | }
57 |
58 | #if DEBUG
59 | [UsedImplicitly]
60 | // Design time constructor
61 | public UrlHandler()
62 | {
63 | logger = NullLogger.Instance;
64 | disallow_network = true;
65 | url_shorteners = [..DefaultUrlShorteners];
66 | TargetURL = "https://www.github.com/mortenn/BrowserPicker";
67 | uri = new Uri(TargetURL);
68 | host_name = uri.Host;
69 | HostName = "extremely-long-domain-example-for-design-time-use.some-long-domain-name.com";
70 | }
71 | #endif
72 |
73 | ///
74 | /// Perform some tests on the Target URL to see if it is a URL shortener
75 | ///
76 | public async Task Start(CancellationToken cancellationToken)
77 | {
78 | try
79 | {
80 | if (uri == null)
81 | {
82 | return;
83 | }
84 | HostName = uri.IsFile && !uri.IsUnc ? null : uri.Host;
85 | while (true)
86 | {
87 | var jump = ResolveJumpPage(uri);
88 | if (jump != null)
89 | {
90 | logger.LogJumpUrl(uri);
91 | UnderlyingTargetURL = jump;
92 | uri = new Uri(jump);
93 | HostName = uri.IsFile && !uri.IsUnc ? null : uri.Host;
94 | continue;
95 | }
96 |
97 | if (disallow_network)
98 | break;
99 |
100 | var shortened = await ResolveShortener(uri, cancellationToken);
101 | if (shortened != null)
102 | {
103 | logger.LogShortenedUrl(shortened);
104 | IsShortenedURL = true;
105 | UnderlyingTargetURL = shortened;
106 | uri = new Uri(shortened);
107 | HostName = uri.IsFile && !uri.IsUnc ? null : uri.Host;
108 | continue;
109 | }
110 |
111 | await FindIcon(cancellationToken);
112 | break;
113 | }
114 | }
115 | catch (TaskCanceledException)
116 | {
117 | // TaskCanceledException occurs when the CancellationToken is triggered before the request completes
118 | // In this case, end the lookup to avoid poor user experience
119 | }
120 | }
121 |
122 | private async Task FindIcon(CancellationToken cancellationToken)
123 | {
124 | var timeout = new CancellationTokenSource(2000);
125 | await using var _ = cancellationToken.Register(timeout.Cancel);
126 | try
127 | {
128 | var pageUri = new Uri(underlying_target_url ?? TargetURL ?? "about:blank");
129 | if (pageUri.IsFile)
130 | {
131 | return;
132 | }
133 | var result = await Client.GetAsync(pageUri, timeout.Token);
134 | if (!result.IsSuccessStatusCode)
135 | {
136 | logger.LogFaviconFailed(result.StatusCode);
137 | return;
138 | }
139 |
140 | var content = await result.Content.ReadAsStringAsync(timeout.Token);
141 | var match = Pattern.HtmlLink().Match(content);
142 | if (!match.Success)
143 | {
144 | logger.LogDefaultFavicon();
145 | await TryLoadIcon(new Uri(pageUri, "/favicon.ico").AbsoluteUri, timeout.Token);
146 | return;
147 | }
148 | var link = Pattern.LinkHref().Match(match.Value);
149 | if (!link.Success)
150 | {
151 | logger.LogFaviconNotFound();
152 | return;
153 | }
154 |
155 | logger.LogFaviconFound(link.Groups[0].Value);
156 | await TryLoadIcon(link.Groups[0].Value, timeout.Token);
157 | }
158 | catch (HttpRequestException)
159 | {
160 | // ignored
161 | }
162 | catch (RegexMatchTimeoutException)
163 | {
164 | cancellationToken.ThrowIfCancellationRequested();
165 | }
166 | catch (TaskCanceledException)
167 | {
168 | if (cancellationToken.IsCancellationRequested)
169 | {
170 | throw;
171 | }
172 | }
173 | }
174 |
175 | private async Task TryLoadIcon(string iconUrl, CancellationToken cancellationToken)
176 | {
177 | var icon = await Client.GetAsync(iconUrl, cancellationToken);
178 | if (icon.IsSuccessStatusCode)
179 | {
180 | logger.LogFaviconLoaded(iconUrl);
181 | FavIcon = await icon.Content.ReadAsByteArrayAsync(cancellationToken);
182 | return;
183 | }
184 | logger.LogFaviconFailed(icon.StatusCode);
185 | }
186 |
187 | private static string? ResolveJumpPage(Uri uri)
188 | {
189 | return (
190 | from jumpPage in JumpPages
191 | where uri.Host.EndsWith(jumpPage.url) || uri.AbsoluteUri.StartsWith(jumpPage.url)
192 | let queryStringValues = HttpUtility.ParseQueryString(uri.Query)
193 | select queryStringValues[jumpPage.parameter]
194 | ).FirstOrDefault(underlyingUrl => underlyingUrl != null);
195 | }
196 |
197 | private async Task ResolveShortener(Uri shortenerUri, CancellationToken cancellationToken)
198 | {
199 | if (url_shorteners.All(s => !shortenerUri.Host.EndsWith(s)))
200 | {
201 | return null;
202 | }
203 | var response = await Client.GetAsync(shortenerUri, cancellationToken);
204 | var location = response.Headers.Location;
205 | return location?.OriginalString;
206 | }
207 |
208 | public string? GetTargetUrl(bool expandFileUrls)
209 | {
210 | if (uri == null)
211 | {
212 | return null;
213 | }
214 | if (uri.IsFile && expandFileUrls)
215 | {
216 | return uri.LocalPath;
217 | }
218 | return UnderlyingTargetURL ?? TargetURL;
219 | }
220 |
221 | public string? TargetURL { get; }
222 |
223 | public string? UnderlyingTargetURL
224 | {
225 | get => underlying_target_url;
226 | set
227 | {
228 | if (SetProperty(ref underlying_target_url, value))
229 | {
230 | OnPropertyChanged(nameof(DisplayURL));
231 | }
232 | }
233 | }
234 |
235 | public bool IsShortenedURL
236 | {
237 | get => is_shortened_url;
238 | set => SetProperty(ref is_shortened_url, value);
239 | }
240 |
241 | public string? HostName
242 | {
243 | get => host_name;
244 | set => SetProperty(ref host_name, value);
245 | }
246 |
247 | public byte[]? FavIcon
248 | {
249 | get => fav_icon;
250 | private set => SetProperty(ref fav_icon, value);
251 | }
252 |
253 | public string? DisplayURL => UnderlyingTargetURL ?? TargetURL;
254 |
255 | public static readonly string[] DefaultUrlShorteners =
256 | [
257 | "safelinks.protection.outlook.com",
258 | "aka.ms",
259 | "fwd.olsvc.com",
260 | "t.co",
261 | "bit.ly",
262 | "goo.gl",
263 | "tinyurl.com",
264 | "ow.ly",
265 | "is.gd",
266 | "buff.ly",
267 | "adf.ly",
268 | "bit.do",
269 | "mcaf.ee",
270 | "su.pr",
271 | "go.microsoft.com"
272 | ];
273 |
274 | private static readonly List<(string url, string parameter)> JumpPages =
275 | [
276 | ("safelinks.protection.outlook.com", "url"),
277 | ("https://staticsint.teams.cdn.office.net/evergreen-assets/safelinks/", "url"),
278 | ("https://l.facebook.com/l.php", "u")
279 | ];
280 |
281 | private Uri? uri;
282 | private string? underlying_target_url;
283 | private bool is_shortened_url;
284 | private string? host_name;
285 | private byte[]? fav_icon;
286 | private readonly List url_shorteners;
287 | private static readonly HttpClient Client = new(new HttpClientHandler { AllowAutoRedirect = false });
288 | private readonly bool disallow_network;
289 | }
--------------------------------------------------------------------------------
/src/BrowserPicker/WellKnownBrowsers.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 |
5 | namespace BrowserPicker;
6 |
7 | // Note: I am not entirely happy with the design of this part, but it was the best I can do in a jiffy
8 | public static class WellKnownBrowsers
9 | {
10 | public static IWellKnownBrowser? Lookup(string? name, string? executable)
11 | {
12 | return List.FirstOrDefault(b => b.Name == name)
13 | ?? List.FirstOrDefault(b => executable != null
14 | && executable.Contains(b.Executable, StringComparison.CurrentCultureIgnoreCase)
15 | );
16 | }
17 |
18 | public static readonly List List =
19 | [
20 | FirefoxDevEdition.Instance,
21 | Firefox.Instance,
22 | ChromeDevEdition.Instance,
23 | Chrome.Instance,
24 | MicrosoftEdge.Instance,
25 | Edge.Instance,
26 | InternetExplorer.Instance,
27 | OperaStable.Instance
28 | ];
29 | }
30 |
31 | public interface IWellKnownBrowser
32 | {
33 | string Name { get; }
34 | string Executable { get; }
35 | string? RealExecutable { get; }
36 | string? PrivacyArgs { get; }
37 | string PrivacyMode { get; }
38 | }
39 |
40 | public sealed class Firefox : IWellKnownBrowser
41 | {
42 | public static readonly Firefox Instance = new();
43 |
44 | public string Name => "Mozilla Firefox";
45 |
46 | public string Executable => "firefox.exe";
47 |
48 | public string? RealExecutable => null;
49 |
50 | public string PrivacyArgs => "-private-window ";
51 |
52 | public string PrivacyMode => "Open with private browsing";
53 | }
54 |
55 | public sealed class FirefoxDevEdition : IWellKnownBrowser
56 | {
57 | public static readonly FirefoxDevEdition Instance = new();
58 |
59 | public string Name => "Firefox Developer Edition";
60 |
61 | public string Executable => "firefox.exe";
62 |
63 | public string? RealExecutable => null;
64 |
65 | public string PrivacyArgs => "-private-window ";
66 |
67 | public string PrivacyMode => "Open with private browsing";
68 | }
69 |
70 | public sealed class Chrome : IWellKnownBrowser
71 | {
72 | public static readonly Chrome Instance = new();
73 |
74 | public string Name => "Google Chrome";
75 |
76 | public string Executable => "chrome.exe";
77 |
78 | public string? RealExecutable => null;
79 |
80 | public string PrivacyArgs => "--incognito ";
81 |
82 | public string PrivacyMode => "Open incognito";
83 | }
84 |
85 | public sealed class ChromeDevEdition : IWellKnownBrowser
86 | {
87 | public static readonly ChromeDevEdition Instance = new();
88 |
89 | public string Name => "Google Chrome Dev";
90 |
91 | public string Executable => "chrome.exe";
92 |
93 | public string? RealExecutable => null;
94 |
95 | public string PrivacyArgs => "--incognito ";
96 |
97 | public string PrivacyMode => "Open incognito";
98 | }
99 |
100 | public sealed class MicrosoftEdge : IWellKnownBrowser
101 | {
102 | public static readonly MicrosoftEdge Instance = new();
103 |
104 | public string Name => "Microsoft Edge";
105 |
106 | public string Executable => "msedge.exe";
107 |
108 | public string? RealExecutable => null;
109 |
110 | public string PrivacyArgs => "-inprivate ";
111 |
112 | public string PrivacyMode => "Open in private mode";
113 | }
114 |
115 | public sealed class Edge : IWellKnownBrowser
116 | {
117 | public static readonly Edge Instance = new();
118 |
119 | public string Name => "Edge";
120 |
121 | public string Executable => "microsoft-edge:";
122 |
123 | public string? RealExecutable => null;
124 |
125 | public string PrivacyArgs => "-private ";
126 |
127 | public string PrivacyMode => "Open in private mode";
128 | }
129 |
130 | public sealed class InternetExplorer : IWellKnownBrowser
131 | {
132 | public static readonly InternetExplorer Instance = new();
133 |
134 | public string Name => "Internet Explorer";
135 |
136 | public string Executable => "iexplore.exe";
137 |
138 | public string? RealExecutable => null;
139 |
140 | public string PrivacyArgs => "-private ";
141 |
142 | public string PrivacyMode => "Open in private mode";
143 | }
144 |
145 | public sealed class OperaStable : IWellKnownBrowser
146 | {
147 | public static readonly OperaStable Instance = new();
148 |
149 | public string Name => "Opera Stable";
150 |
151 | public string Executable => "Opera\\Launcher.exe";
152 |
153 | public string RealExecutable => "opera.exe";
154 |
155 | public string PrivacyArgs => "--private ";
156 |
157 | public string PrivacyMode => "Open in private mode";
158 | }
--------------------------------------------------------------------------------
/src/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 | net9.0-windows
4 | 2.0.0.4
5 | -beta1
6 | Runsafe
7 | Copyright © 2017-2024
8 | Debug;Release
9 | x64
10 | True
11 |
12 |
13 | bin\SignedRelease\
14 | true
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/Directory.Packages.props:
--------------------------------------------------------------------------------
1 |
2 |
3 | true
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------