diff --git a/.cm/gitstream.cm b/.cm/gitstream.cm index fe7e777c8..767982e3b 100644 --- a/.cm/gitstream.cm +++ b/.cm/gitstream.cm @@ -10,7 +10,7 @@ triggers: branch: - l10n_dev - dev - - r/(?i)(Dependabot|Renovate)/ + - r/([Dd]ependabot|[Rr]enovate)/ automations: diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d9b39eb89..da4231f74 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,7 +8,8 @@ updates: - package-ecosystem: "nuget" # See documentation for possible values directory: "/" # Location of package manifests schedule: - interval: "weekly" + interval: "daily" + open-pull-requests-limit: 3 ignore: - dependency-name: "squirrel-windows" reviewers: diff --git a/.github/update_release_pr.py b/.github/update_release_pr.py new file mode 100644 index 000000000..f90f6181d --- /dev/null +++ b/.github/update_release_pr.py @@ -0,0 +1,215 @@ +from os import getenv + +import requests + + +def get_github_prs(token: str, owner: str, repo: str, label: str = "", state: str = "all") -> list[dict]: + """ + Fetches pull requests from a GitHub repository that match a given milestone and label. + + Args: + token (str): GitHub token. + owner (str): The owner of the repository. + repo (str): The name of the repository. + label (str): The label name. + state (str): State of PR, e.g. open, closed, all + + Returns: + list: A list of dictionaries, where each dictionary represents a pull request. + Returns an empty list if no PRs are found or an error occurs. + """ + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + } + + milestone_id = None + milestone_url = f"https://api.github.com/repos/{owner}/{repo}/milestones" + params = {"state": "open"} + + try: + response = requests.get(milestone_url, headers=headers, params=params) + response.raise_for_status() + milestones = response.json() + + if len(milestones) > 2: + print("More than two milestones found, unable to determine the milestone required.") + exit(1) + + # milestones.pop() + for ms in milestones: + if ms["title"] != "Future": + milestone_id = ms["number"] + print(f"Gathering PRs with milestone {ms['title']}...") + break + + if not milestone_id: + print(f"No suitable milestone found in repository '{owner}/{repo}'.") + exit(1) + + except requests.exceptions.RequestException as e: + print(f"Error fetching milestones: {e}") + exit(1) + + # This endpoint allows filtering by milestone and label. A PR in GH's perspective is a type of issue. + prs_url = f"https://api.github.com/repos/{owner}/{repo}/issues" + params = { + "state": state, + "milestone": milestone_id, + "labels": label, + "per_page": 100, + } + + all_prs = [] + page = 1 + while True: + try: + params["page"] = page + response = requests.get(prs_url, headers=headers, params=params) + response.raise_for_status() # Raise an exception for HTTP errors + prs = response.json() + + if not prs: + break # No more PRs to fetch + + # Check for pr key since we are using issues endpoint instead. + all_prs.extend([item for item in prs if "pull_request" in item]) + page += 1 + + except requests.exceptions.RequestException as e: + print(f"Error fetching pull requests: {e}") + exit(1) + + return all_prs + + +def get_prs(pull_request_items: list[dict], label: str = "", state: str = "all") -> list[dict]: + """ + Returns a list of pull requests after applying the label and state filters. + + Args: + pull_request_items (list[dict]): List of PR items. + label (str): The label name. + state (str): State of PR, e.g. open, closed, all + + Returns: + list: A list of dictionaries, where each dictionary represents a pull request. + Returns an empty list if no PRs are found. + """ + pr_list = [] + count = 0 + for pr in pull_request_items: + if pr["state"] == state and [item for item in pr["labels"] if item["name"] == label]: + pr_list.append(pr) + count += 1 + + print(f"Found {count} PRs with {label if label else 'no'} label and state as {state}") + + return pr_list + + +def get_pr_descriptions(pull_request_items: list[dict]) -> str: + """ + Returns the concatenated string of pr title and number in the format of + '- PR title 1 #3651 + - PR title 2 #3652 + - PR title 3 #3653 + ' + + Args: + pull_request_items (list[dict]): List of PR items. + + Returns: + str: a string of PR titles and numbers + """ + description_content = "" + for pr in pull_request_items: + description_content += f"- {pr['title']} #{pr['number']}\n" + + return description_content + + +def update_pull_request_description(token: str, owner: str, repo: str, pr_number: int, new_description: str) -> None: + """ + Updates the description (body) of a GitHub Pull Request. + + Args: + token (str): Token. + owner (str): The owner of the repository. + repo (str): The name of the repository. + pr_number (int): The number of the pull request to update. + new_description (str): The new content for the PR's description. + + Returns: + dict or None: The updated PR object (as a dictionary) if successful, + None otherwise. + """ + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + "Content-Type": "application/json", + } + + url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}" + + payload = {"body": new_description} + + print(f"Attempting to update PR #{pr_number} in {owner}/{repo}...") + print(f"URL: {url}") + + try: + response = None + response = requests.patch(url, headers=headers, json=payload) + response.raise_for_status() + + print(f"Successfully updated PR #{pr_number}.") + + except requests.exceptions.RequestException as e: + print(f"Error updating pull request #{pr_number}: {e}") + if response is not None: + print(f"Response status code: {response.status_code}") + print(f"Response text: {response.text}") + exit(1) + + +if __name__ == "__main__": + github_token = getenv("GITHUB_TOKEN") + + if not github_token: + print("Error: GITHUB_TOKEN environment variable not set.") + exit(1) + + repository_owner = "flow-launcher" + repository_name = "flow.launcher" + state = "all" + + print(f"Fetching {state} PRs for {repository_owner}/{repository_name} ...") + + pull_requests = get_github_prs(github_token, repository_owner, repository_name) + + if not pull_requests: + print("No matching pull requests found") + exit(1) + + print(f"\nFound total of {len(pull_requests)} pull requests") + + release_pr = get_prs(pull_requests, "release", "open") + + if len(release_pr) != 1: + print(f"Unable to find the exact release PR. Returned result: {release_pr}") + exit(1) + + print(f"Found release PR: {release_pr[0]['title']}") + + enhancement_prs = get_prs(pull_requests, "enhancement", "closed") + bug_fix_prs = get_prs(pull_requests, "bug", "closed") + + description_content = "# Release notes\n" + description_content += f"## Features\n{get_pr_descriptions(enhancement_prs)}" if enhancement_prs else "" + description_content += f"## Bug fixes\n{get_pr_descriptions(bug_fix_prs)}" if bug_fix_prs else "" + + update_pull_request_description( + github_token, repository_owner, repository_name, release_pr[0]["number"], description_content + ) + + print(f"PR content updated to:\n{description_content}") diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 000000000..7498262de --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,91 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: Build + +on: + workflow_dispatch: + push: + branches: + - dev + - master + pull_request: + +jobs: + build: + + runs-on: windows-latest + env: + FlowVersion: 1.19.5 + NUGET_CERT_REVOCATION_MODE: offline + BUILD_NUMBER: ${{ github.run_number }} + steps: + - uses: actions/checkout@v4 + - name: Set Flow.Launcher.csproj version + id: update + uses: vers-one/dotnet-project-version-updater@v1.7 + with: + file: | + "**/SolutionAssemblyInfo.cs" + version: ${{ env.FlowVersion }}.${{ env.BUILD_NUMBER }} + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 7.0.x +# cache: true +# cache-dependency-path: | +# Flow.Launcher/packages.lock.json +# Flow.Launcher.Core/packages.lock.json +# Flow.Launcher.Infrastructure/packages.lock.json +# Flow.Launcher.Plugin/packages.lock.json + - name: Install vpk + run: dotnet tool install -g vpk + - name: Restore dependencies + run: nuget restore + - name: Build + run: dotnet build --no-restore -c Release + - name: Initialize Service + run: | + sc config WSearch start= auto # Starts Windows Search service- Needed for running ExplorerTest + net start WSearch + - name: Test + run: dotnet test --no-build --verbosity normal -c Release + - name: Perform post_build tasks + shell: powershell + run: .\Scripts\post_build.ps1 + - name: Upload Plugin Nupkg + uses: actions/upload-artifact@v4 + with: + name: Plugin nupkg + path: | + Output\Release\Flow.Launcher.Plugin.*.nupkg + compression-level: 0 + - name: Upload Setup + uses: actions/upload-artifact@v4 + with: + name: Flow Installer + path: | + Output\Packages\Flow-Launcher-*.exe + compression-level: 0 + - name: Upload Portable Version + uses: actions/upload-artifact@v4 + with: + name: Portable Version + path: | + Output\Packages\Flow-Launcher-Portable.zip + compression-level: 0 + - name: Upload Full Nupkg + uses: actions/upload-artifact@v4 + with: + name: Full nupkg + path: | + Output\Packages\FlowLauncher-*-full.nupkg + + compression-level: 0 + - name: Upload Release Information + uses: actions/upload-artifact@v4 + with: + name: RELEASES + path: | + Output\Packages\RELEASES + compression-level: 0 diff --git a/.github/workflows/pr_assignee.yml b/.github/workflows/pr_assignee.yml index af6daff02..5be603df6 100644 --- a/.github/workflows/pr_assignee.yml +++ b/.github/workflows/pr_assignee.yml @@ -1,19 +1,17 @@ name: Assign PR to creator -# Due to GitHub token limitation, only able to assign org members not authors from forks. -# https://github.com/thomaseizinger/assign-pr-creator-action/issues/3 - on: - pull_request: + pull_request_target: types: [opened] branches-ignore: - l10n_dev +permissions: + pull-requests: write + jobs: automation: runs-on: ubuntu-latest steps: - name: Assign PR to creator - uses: thomaseizinger/assign-pr-creator-action@v1.0.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} + uses: toshimaru/auto-author-assign@v2.1.1 diff --git a/.github/workflows/pr_milestone.yml b/.github/workflows/pr_milestone.yml index b343a39cc..e2365f554 100644 --- a/.github/workflows/pr_milestone.yml +++ b/.github/workflows/pr_milestone.yml @@ -3,9 +3,12 @@ name: Set Milestone # Assigns the earliest created milestone that matches the below glob pattern. on: - pull_request: + pull_request_target: types: [opened] +permissions: + pull-requests: write + jobs: automation: runs-on: ubuntu-latest diff --git a/.github/workflows/release_deploy.yml b/.github/workflows/release_deploy.yml new file mode 100644 index 000000000..9e082b95f --- /dev/null +++ b/.github/workflows/release_deploy.yml @@ -0,0 +1,34 @@ +--- + +name: New Release Deployments +on: + release: + types: [published] + workflow_dispatch: + +jobs: + deploy-website: + runs-on: ubuntu-latest + steps: + - name: Trigger dispatch event for deploying website + run: | + http_status=$(curl -L -f -s -o /dev/null -w "%{http_code}" \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.DEPLOY_FLOW_WEBSITE }}" \ + https://api.github.com/repos/Flow-Launcher/flow-launcher.github.io/dispatches \ + -d '{"event_type":"deploy"}') + if [ "$http_status" -ne 204 ]; then echo "Error: Deploy website failed, HTTP status code is $http_status"; exit 1; fi + + publish-chocolatey: + runs-on: ubuntu-latest + steps: + - name: Trigger dispatch event for publishing to Chocolatey + run: | + http_status=$(curl -L -f -s -o /dev/null -w "%{http_code}" \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.Publish_Chocolatey }}" \ + https://api.github.com/repos/Flow-Launcher/chocolatey-package/dispatches \ + -d '{"event_type":"publish"}') + if [ "$http_status" -ne 204 ]; then echo "Error: Publish Chocolatey package failed, HTTP status code is $http_status"; exit 1; fi diff --git a/.github/workflows/release_pr.yml b/.github/workflows/release_pr.yml new file mode 100644 index 000000000..451bf386c --- /dev/null +++ b/.github/workflows/release_pr.yml @@ -0,0 +1,25 @@ +name: Update release PR + +on: + pull_request: + types: [opened, reopened, synchronize] + branches: + - master + workflow_dispatch: + +jobs: + update-pr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Run release PR update + env: + GITHUB_TOKEN: ${{ secrets.PR_TOKEN }} + run: | + pip install requests -q + python3 ./.github/update_release_pr.py diff --git a/.github/workflows/spelling.yml b/.github/workflows/spelling.yml index 7aaa9296a..47bd66107 100644 --- a/.github/workflows/spelling.yml +++ b/.github/workflows/spelling.yml @@ -41,9 +41,8 @@ on: # tags-ignore: # - "**" pull_request_target: - branches: - - '**' - # - '!l10n_dev' + branches-ignore: + - master tags-ignore: - "**" types: diff --git a/Flow.Launcher.Core/Configuration/Portable.cs b/Flow.Launcher.Core/Configuration/Portable.cs index b58154dcb..7f02cef09 100644 --- a/Flow.Launcher.Core/Configuration/Portable.cs +++ b/Flow.Launcher.Core/Configuration/Portable.cs @@ -1,24 +1,29 @@ -using Microsoft.Win32; -using Squirrel; -using System; +using System; using System.IO; +using System.Linq; using System.Reflection; using System.Windows; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedCommands; -using System.Linq; +using Microsoft.Win32; +using Squirrel; namespace Flow.Launcher.Core.Configuration { public class Portable : IPortable { + private static readonly string ClassName = nameof(Portable); + + private readonly IPublicAPI API = Ioc.Default.GetRequiredService(); + /// /// As at Squirrel.Windows version 1.5.2, UpdateManager needs to be disposed after finish /// /// - private UpdateManager NewUpdateManager() + private static UpdateManager NewUpdateManager() { var applicationFolderName = Constant.ApplicationDirectory .Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.None) @@ -40,14 +45,14 @@ namespace Flow.Launcher.Core.Configuration #endif IndicateDeletion(DataLocation.PortableDataPath); - MessageBox.Show("Flow Launcher needs to restart to finish disabling portable mode, " + + API.ShowMsgBox("Flow Launcher needs to restart to finish disabling portable mode, " + "after the restart your portable data profile will be deleted and roaming data profile kept"); UpdateManager.RestartApp(Constant.ApplicationFileName); } catch (Exception e) { - Log.Exception("|Portable.DisablePortableMode|Error occurred while disabling portable mode", e); + API.LogException(ClassName, "Error occurred while disabling portable mode", e); } } @@ -64,54 +69,48 @@ namespace Flow.Launcher.Core.Configuration #endif IndicateDeletion(DataLocation.RoamingDataPath); - MessageBox.Show("Flow Launcher needs to restart to finish enabling portable mode, " + + API.ShowMsgBox("Flow Launcher needs to restart to finish enabling portable mode, " + "after the restart your roaming data profile will be deleted and portable data profile kept"); UpdateManager.RestartApp(Constant.ApplicationFileName); } catch (Exception e) { - Log.Exception("|Portable.EnablePortableMode|Error occurred while enabling portable mode", e); + API.LogException(ClassName, "Error occurred while enabling portable mode", e); } } public void RemoveShortcuts() { - using (var portabilityUpdater = NewUpdateManager()) - { - portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.StartMenu); - portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Desktop); - portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Startup); - } + using var portabilityUpdater = NewUpdateManager(); + portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.StartMenu); + portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Desktop); + portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Startup); } public void RemoveUninstallerEntry() { - using (var portabilityUpdater = NewUpdateManager()) - { - portabilityUpdater.RemoveUninstallerRegistryEntry(); - } + using var portabilityUpdater = NewUpdateManager(); + portabilityUpdater.RemoveUninstallerRegistryEntry(); } public void MoveUserDataFolder(string fromLocation, string toLocation) { - FilesFolders.CopyAll(fromLocation, toLocation); + FilesFolders.CopyAll(fromLocation, toLocation, (s) => API.ShowMsgBox(s)); VerifyUserDataAfterMove(fromLocation, toLocation); } public void VerifyUserDataAfterMove(string fromLocation, string toLocation) { - FilesFolders.VerifyBothFolderFilesEqual(fromLocation, toLocation); + FilesFolders.VerifyBothFolderFilesEqual(fromLocation, toLocation, (s) => API.ShowMsgBox(s)); } public void CreateShortcuts() { - using (var portabilityUpdater = NewUpdateManager()) - { - portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.StartMenu, false); - portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Desktop, false); - portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Startup, false); - } + using var portabilityUpdater = NewUpdateManager(); + portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.StartMenu, false); + portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Desktop, false); + portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Startup, false); } public void CreateUninstallerEntry() @@ -125,18 +124,14 @@ namespace Flow.Launcher.Core.Configuration subKey2.SetValue("DisplayIcon", Path.Combine(Constant.ApplicationDirectory, "app.ico"), RegistryValueKind.String); } - using (var portabilityUpdater = NewUpdateManager()) - { - _ = portabilityUpdater.CreateUninstallerRegistryEntry(); - } + using var portabilityUpdater = NewUpdateManager(); + _ = portabilityUpdater.CreateUninstallerRegistryEntry(); } - internal void IndicateDeletion(string filePathTodelete) + private static void IndicateDeletion(string filePathTodelete) { var deleteFilePath = Path.Combine(filePathTodelete, DataLocation.DeletionIndicatorFile); - using (var _ = File.CreateText(deleteFilePath)) - { - } + using var _ = File.CreateText(deleteFilePath); } /// @@ -157,13 +152,13 @@ namespace Flow.Launcher.Core.Configuration // delete it and prompt the user to pick the portable data location if (File.Exists(roamingDataDeleteFilePath)) { - FilesFolders.RemoveFolderIfExists(roamingDataDir); + FilesFolders.RemoveFolderIfExists(roamingDataDir, (s) => API.ShowMsgBox(s)); - if (MessageBox.Show("Flow Launcher has detected you enabled portable mode, " + + if (API.ShowMsgBox("Flow Launcher has detected you enabled portable mode, " + "would you like to move it to a different location?", string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.Yes) { - FilesFolders.OpenPath(Constant.RootDirectory); + FilesFolders.OpenPath(Constant.RootDirectory, (s) => API.ShowMsgBox(s)); Environment.Exit(0); } @@ -172,9 +167,9 @@ namespace Flow.Launcher.Core.Configuration // delete it and notify the user about it. else if (File.Exists(portableDataDeleteFilePath)) { - FilesFolders.RemoveFolderIfExists(portableDataDir); + FilesFolders.RemoveFolderIfExists(portableDataDir, (s) => API.ShowMsgBox(s)); - MessageBox.Show("Flow Launcher has detected you disabled portable mode, " + + API.ShowMsgBox("Flow Launcher has detected you disabled portable mode, " + "the relevant shortcuts and uninstaller entry have been created"); } } @@ -186,7 +181,7 @@ namespace Flow.Launcher.Core.Configuration if (roamingLocationExists && portableLocationExists) { - MessageBox.Show(string.Format("Flow Launcher detected your user data exists both in {0} and " + + API.ShowMsgBox(string.Format("Flow Launcher detected your user data exists both in {0} and " + "{1}. {2}{2}Please delete {1} in order to proceed. No changes have occurred.", DataLocation.PortableDataPath, DataLocation.RoamingDataPath, Environment.NewLine)); diff --git a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs index 68be746f2..6f3b23e11 100644 --- a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs +++ b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs @@ -1,24 +1,32 @@ -using Flow.Launcher.Infrastructure.Http; -using Flow.Launcher.Infrastructure.Logger; -using System; +using System; using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Json; +using System.Net.Sockets; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.Http; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.ExternalPlugins { public record CommunityPluginSource(string ManifestFileUrl) { + private static readonly string ClassName = nameof(CommunityPluginSource); + + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); + private string latestEtag = ""; private List plugins = new(); - private static JsonSerializerOptions PluginStoreItemSerializationOption = new JsonSerializerOptions() + private static readonly JsonSerializerOptions PluginStoreItemSerializationOption = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }; @@ -33,35 +41,49 @@ namespace Flow.Launcher.Core.ExternalPlugins /// public async Task> FetchAsync(CancellationToken token) { - Log.Info(nameof(CommunityPluginSource), $"Loading plugins from {ManifestFileUrl}"); + API.LogInfo(ClassName, $"Loading plugins from {ManifestFileUrl}"); var request = new HttpRequestMessage(HttpMethod.Get, ManifestFileUrl); request.Headers.Add("If-None-Match", latestEtag); - using var response = await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token) + try + { + using var response = await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token) .ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.OK) - { - this.plugins = await response.Content - .ReadFromJsonAsync>(PluginStoreItemSerializationOption, cancellationToken: token) - .ConfigureAwait(false); - this.latestEtag = response.Headers.ETag?.Tag; + if (response.StatusCode == HttpStatusCode.OK) + { + plugins = await response.Content + .ReadFromJsonAsync>(PluginStoreItemSerializationOption, cancellationToken: token) + .ConfigureAwait(false); + latestEtag = response.Headers.ETag?.Tag; - Log.Info(nameof(CommunityPluginSource), $"Loaded {this.plugins.Count} plugins from {ManifestFileUrl}"); - return this.plugins; + API.LogInfo(ClassName, $"Loaded {plugins.Count} plugins from {ManifestFileUrl}"); + return plugins; + } + else if (response.StatusCode == HttpStatusCode.NotModified) + { + API.LogInfo(ClassName, $"Resource {ManifestFileUrl} has not been modified."); + return plugins; + } + else + { + API.LogWarn(ClassName, $"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}"); + return null; + } } - else if (response.StatusCode == HttpStatusCode.NotModified) + catch (Exception e) { - Log.Info(nameof(CommunityPluginSource), $"Resource {ManifestFileUrl} has not been modified."); - return this.plugins; - } - else - { - Log.Warn(nameof(CommunityPluginSource), - $"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}"); - throw new Exception($"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}"); + if (e is HttpRequestException or WebException or SocketException || e.InnerException is TimeoutException) + { + API.LogException(ClassName, $"Check your connection and proxy settings to {ManifestFileUrl}.", e); + } + else + { + API.LogException(ClassName, "Error Occurred", e); + } + return null; } } } diff --git a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs index affd7c312..bdc1ad3dd 100644 --- a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs +++ b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.ExternalPlugins { @@ -39,10 +40,14 @@ namespace Flow.Launcher.Core.ExternalPlugins var completedTask = await Task.WhenAny(tasks); if (completedTask.IsCompletedSuccessfully) { - // one of the requests completed successfully; keep its results - // and cancel the remaining http requests. - pluginResults = await completedTask; - cts.Cancel(); + var result = await completedTask; + if (result != null) + { + // one of the requests completed successfully; keep its results + // and cancel the remaining http requests. + pluginResults = result; + cts.Cancel(); + } } tasks.Remove(completedTask); } diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs index 30e812c6f..14796a87a 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs @@ -1,18 +1,22 @@ -using Flow.Launcher.Infrastructure.Logger; -using Flow.Launcher.Infrastructure.UserSettings; -using Flow.Launcher.Plugin; -using Flow.Launcher.Plugin.SharedCommands; -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Windows; using System.Windows.Forms; -using Flow.Launcher.Core.Resource; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; namespace Flow.Launcher.Core.ExternalPlugins.Environments { public abstract class AbstractPluginEnvironment { + private static readonly string ClassName = nameof(AbstractPluginEnvironment); + + protected readonly IPublicAPI API = Ioc.Default.GetRequiredService(); + internal abstract string Language { get; } internal abstract string EnvName { get; } @@ -25,7 +29,7 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments internal virtual string FileDialogFilter => string.Empty; - internal abstract string PluginsSettingsFilePath { get; set; } + internal abstract string PluginsSettingsFilePath { get; set; } internal List PluginMetadataList; @@ -39,8 +43,11 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments internal IEnumerable Setup() { + // If no plugin is using the language, return empty list if (!PluginMetadataList.Any(o => o.Language.Equals(Language, StringComparison.OrdinalIgnoreCase))) + { return new List(); + } if (!string.IsNullOrEmpty(PluginsSettingsFilePath) && FilesFolders.FileExists(PluginsSettingsFilePath)) { @@ -52,24 +59,55 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments } var noRuntimeMessage = string.Format( - InternationalizationManager.Instance.GetTranslation("runtimePluginInstalledChooseRuntimePrompt"), + API.GetTranslation("runtimePluginInstalledChooseRuntimePrompt"), Language, EnvName, Environment.NewLine ); - if (MessageBox.Show(noRuntimeMessage, string.Empty, MessageBoxButtons.YesNo) == DialogResult.No) + if (API.ShowMsgBox(noRuntimeMessage, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.No) { - var msg = string.Format(InternationalizationManager.Instance.GetTranslation("runtimePluginChooseRuntimeExecutable"), EnvName); - string selectedFile; + var msg = string.Format(API.GetTranslation("runtimePluginChooseRuntimeExecutable"), EnvName); - selectedFile = GetFileFromDialog(msg, FileDialogFilter); + var selectedFile = GetFileFromDialog(msg, FileDialogFilter); if (!string.IsNullOrEmpty(selectedFile)) + { PluginsSettingsFilePath = selectedFile; - + } // Nothing selected because user pressed cancel from the file dialog window - if (string.IsNullOrEmpty(selectedFile)) - InstallEnvironment(); + else + { + var forceDownloadMessage = string.Format( + API.GetTranslation("runtimeExecutableInvalidChooseDownload"), + Language, + EnvName, + Environment.NewLine + ); + + // Let users select valid path or choose to download + while (string.IsNullOrEmpty(selectedFile)) + { + if (API.ShowMsgBox(forceDownloadMessage, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.Yes) + { + // Continue select file + selectedFile = GetFileFromDialog(msg, FileDialogFilter); + } + else + { + // User selected no, break the loop + break; + } + } + + if (!string.IsNullOrEmpty(selectedFile)) + { + PluginsSettingsFilePath = selectedFile; + } + else + { + InstallEnvironment(); + } + } } else { @@ -82,8 +120,8 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments } else { - MessageBox.Show(string.Format(InternationalizationManager.Instance.GetTranslation("runtimePluginUnableToSetExecutablePath"), Language)); - Log.Error("PluginsLoader", + API.ShowMsgBox(string.Format(API.GetTranslation("runtimePluginUnableToSetExecutablePath"), Language)); + API.LogError(ClassName, $"Not able to successfully set {EnvName} path, setting's plugin executable path variable is still an empty string.", $"{Language}Environment"); @@ -95,13 +133,11 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments private void EnsureLatestInstalled(string expectedPath, string currentPath, string installedDirPath) { - if (expectedPath == currentPath) - return; + if (expectedPath == currentPath) return; - FilesFolders.RemoveFolderIfExists(installedDirPath); + FilesFolders.RemoveFolderIfExists(installedDirPath, (s) => API.ShowMsgBox(s)); InstallEnvironment(); - } internal abstract PluginPair CreatePluginPair(string filePath, PluginMetadata metadata); @@ -113,13 +149,16 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments foreach (var metadata in PluginMetadataList) { if (metadata.Language.Equals(languageToSet, StringComparison.OrdinalIgnoreCase)) + { + metadata.AssemblyName = string.Empty; pluginPairs.Add(CreatePluginPair(filePath, metadata)); + } } return pluginPairs; } - private string GetFileFromDialog(string title, string filter = "") + private static string GetFileFromDialog(string title, string filter = "") { var dlg = new OpenFileDialog { @@ -133,7 +172,6 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments var result = dlg.ShowDialog(); return result == DialogResult.OK ? dlg.FileName : string.Empty; - } /// @@ -176,31 +214,33 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments else { if (IsUsingPortablePath(settings.PluginSettings.PythonExecutablePath, DataLocation.PythonEnvironmentName)) + { settings.PluginSettings.PythonExecutablePath = GetUpdatedEnvironmentPath(settings.PluginSettings.PythonExecutablePath); + } if (IsUsingPortablePath(settings.PluginSettings.NodeExecutablePath, DataLocation.NodeEnvironmentName)) + { settings.PluginSettings.NodeExecutablePath = GetUpdatedEnvironmentPath(settings.PluginSettings.NodeExecutablePath); + } } } private static bool IsUsingPortablePath(string filePath, string pluginEnvironmentName) { - if (string.IsNullOrEmpty(filePath)) - return false; + if (string.IsNullOrEmpty(filePath)) return false; // DataLocation.PortableDataPath returns the current portable path, this determines if an out // of date path is also a portable path. - var portableAppEnvLocation = $"UserData\\{DataLocation.PluginEnvironments}\\{pluginEnvironmentName}"; + var portableAppEnvLocation = Path.Combine("UserData", DataLocation.PluginEnvironments, pluginEnvironmentName); return filePath.Contains(portableAppEnvLocation); } private static bool IsUsingRoamingPath(string filePath) { - if (string.IsNullOrEmpty(filePath)) - return false; + if (string.IsNullOrEmpty(filePath)) return false; return filePath.StartsWith(DataLocation.RoamingDataPath); } @@ -210,8 +250,8 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments var index = filePath.IndexOf(DataLocation.PluginEnvironments); // get the substring after "Environments" because we can not determine it dynamically - var ExecutablePathSubstring = filePath.Substring(index + DataLocation.PluginEnvironments.Count()); - return $"{DataLocation.PluginEnvironmentsPath}{ExecutablePathSubstring}"; + var executablePathSubstring = filePath[(index + DataLocation.PluginEnvironments.Length)..]; + return $"{DataLocation.PluginEnvironmentsPath}{executablePathSubstring}"; } } } diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptEnvironment.cs index b67059b1b..62d2d3e91 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptEnvironment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptEnvironment.cs @@ -4,7 +4,6 @@ using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.ExternalPlugins.Environments { - internal class JavaScriptEnvironment : TypeScriptEnvironment { internal override string Language => AllowedLanguage.JavaScript; diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptV2Environment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptV2Environment.cs index 6c8c5aa57..726bc4cd4 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptV2Environment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptV2Environment.cs @@ -4,7 +4,6 @@ using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.ExternalPlugins.Environments { - internal class JavaScriptV2Environment : TypeScriptV2Environment { internal override string Language => AllowedLanguage.JavaScriptV2; diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs index 5676e12f5..455ee096d 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs @@ -1,10 +1,11 @@ -using Droplex; +using System.Collections.Generic; +using System.IO; +using Droplex; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedCommands; -using System.Collections.Generic; -using System.IO; +using Microsoft.VisualStudio.Threading; namespace Flow.Launcher.Core.ExternalPlugins.Environments { @@ -22,17 +23,23 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments internal override string FileDialogFilter => "Python|pythonw.exe"; - internal override string PluginsSettingsFilePath { get => PluginSettings.PythonExecutablePath; set => PluginSettings.PythonExecutablePath = value; } + internal override string PluginsSettingsFilePath + { + get => PluginSettings.PythonExecutablePath; + set => PluginSettings.PythonExecutablePath = value; + } internal PythonEnvironment(List pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { } + private JoinableTaskFactory JTF { get; } = new JoinableTaskFactory(new JoinableTaskContext()); + internal override void InstallEnvironment() { - FilesFolders.RemoveFolderIfExists(InstallPath); + FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s)); // Python 3.11.4 is no longer Windows 7 compatible. If user is on Win 7 and // uses Python plugin they need to custom install and use v3.8.9 - DroplexPackage.Drop(App.python_3_11_4_embeddable, InstallPath).Wait(); + JTF.Run(() => DroplexPackage.Drop(App.python_3_11_4_embeddable, InstallPath)); PluginsSettingsFilePath = ExecutablePath; } diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs index 70341f711..12965286f 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs @@ -1,10 +1,11 @@ using System.Collections.Generic; -using Droplex; -using Flow.Launcher.Infrastructure.UserSettings; -using Flow.Launcher.Plugin.SharedCommands; -using Flow.Launcher.Plugin; using System.IO; +using Droplex; using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; +using Microsoft.VisualStudio.Threading; namespace Flow.Launcher.Core.ExternalPlugins.Environments { @@ -19,15 +20,21 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments internal override string InstallPath => Path.Combine(EnvPath, "Node-v16.18.0"); internal override string ExecutablePath => Path.Combine(InstallPath, "node-v16.18.0-win-x64\\node.exe"); - internal override string PluginsSettingsFilePath { get => PluginSettings.NodeExecutablePath; set => PluginSettings.NodeExecutablePath = value; } + internal override string PluginsSettingsFilePath + { + get => PluginSettings.NodeExecutablePath; + set => PluginSettings.NodeExecutablePath = value; + } internal TypeScriptEnvironment(List pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { } + private JoinableTaskFactory JTF { get; } = new JoinableTaskFactory(new JoinableTaskContext()); + internal override void InstallEnvironment() { - FilesFolders.RemoveFolderIfExists(InstallPath); + FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s)); - DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath).Wait(); + JTF.Run(() => DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath)); PluginsSettingsFilePath = ExecutablePath; } diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs index 11ed94d3f..6960b79c9 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs @@ -1,10 +1,11 @@ using System.Collections.Generic; -using Droplex; -using Flow.Launcher.Infrastructure.UserSettings; -using Flow.Launcher.Plugin.SharedCommands; -using Flow.Launcher.Plugin; using System.IO; +using Droplex; using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; +using Microsoft.VisualStudio.Threading; namespace Flow.Launcher.Core.ExternalPlugins.Environments { @@ -19,15 +20,21 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments internal override string InstallPath => Path.Combine(EnvPath, "Node-v16.18.0"); internal override string ExecutablePath => Path.Combine(InstallPath, "node-v16.18.0-win-x64\\node.exe"); - internal override string PluginsSettingsFilePath { get => PluginSettings.NodeExecutablePath; set => PluginSettings.NodeExecutablePath = value; } + internal override string PluginsSettingsFilePath + { + get => PluginSettings.NodeExecutablePath; + set => PluginSettings.NodeExecutablePath = value; + } internal TypeScriptV2Environment(List pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { } + private JoinableTaskFactory JTF { get; } = new JoinableTaskFactory(new JoinableTaskContext()); + internal override void InstallEnvironment() { - FilesFolders.RemoveFolderIfExists(InstallPath); + FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s)); - DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath).Wait(); + JTF.Run(() => DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath)); PluginsSettingsFilePath = ExecutablePath; } diff --git a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs index 63f21c1d6..7ca91eaec 100644 --- a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs +++ b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs @@ -1,13 +1,16 @@ -using Flow.Launcher.Infrastructure.Logger; -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.ExternalPlugins { public static class PluginsManifest { + private static readonly string ClassName = nameof(PluginsManifest); + private static readonly CommunityPluginStore mainPluginStore = new("https://raw.githubusercontent.com/Flow-Launcher/Flow.Launcher.PluginsManifest/plugin_api_v2/plugins.json", "https://fastly.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json", @@ -17,11 +20,11 @@ namespace Flow.Launcher.Core.ExternalPlugins private static readonly SemaphoreSlim manifestUpdateLock = new(1); private static DateTime lastFetchedAt = DateTime.MinValue; - private static TimeSpan fetchTimeout = TimeSpan.FromMinutes(2); + private static readonly TimeSpan fetchTimeout = TimeSpan.FromMinutes(2); public static List UserPlugins { get; private set; } - public static async Task UpdateManifestAsync(CancellationToken token = default, bool usePrimaryUrlOnly = false) + public static async Task UpdateManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default) { try { @@ -31,18 +34,26 @@ namespace Flow.Launcher.Core.ExternalPlugins { var results = await mainPluginStore.FetchAsync(token, usePrimaryUrlOnly).ConfigureAwait(false); - UserPlugins = results; - lastFetchedAt = DateTime.Now; + // If the results are empty, we shouldn't update the manifest because the results are invalid. + if (results.Count != 0) + { + UserPlugins = results; + lastFetchedAt = DateTime.Now; + + return true; + } } } catch (Exception e) { - Log.Exception($"|PluginsManifest.{nameof(UpdateManifestAsync)}|Http request failed", e); + Ioc.Default.GetRequiredService().LogException(ClassName, "Http request failed", e); } finally { manifestUpdateLock.Release(); } + + return false; } } } diff --git a/Flow.Launcher.Core/ExternalPlugins/UserPlugin.cs b/Flow.Launcher.Core/ExternalPlugins/UserPlugin.cs deleted file mode 100644 index 79d6d7605..000000000 --- a/Flow.Launcher.Core/ExternalPlugins/UserPlugin.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; - -namespace Flow.Launcher.Core.ExternalPlugins -{ - public record UserPlugin - { - public string ID { get; set; } - public string Name { get; set; } - public string Description { get; set; } - public string Author { get; set; } - public string Version { get; set; } - public string Language { get; set; } - public string Website { get; set; } - public string UrlDownload { get; set; } - public string UrlSourceCode { get; set; } - public string LocalInstallPath { get; set; } - public string IcoPath { get; set; } - public DateTime? LatestReleaseDate { get; set; } - public DateTime? DateAdded { get; set; } - - public bool IsFromLocalInstallPath => !string.IsNullOrEmpty(LocalInstallPath); - } -} diff --git a/Flow.Launcher.Core/Flow.Launcher.Core.csproj b/Flow.Launcher.Core/Flow.Launcher.Core.csproj index 082d7da67..e9f199d00 100644 --- a/Flow.Launcher.Core/Flow.Launcher.Core.csproj +++ b/Flow.Launcher.Core/Flow.Launcher.Core.csproj @@ -54,11 +54,11 @@ - + - + diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs b/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs index 97c3c8981..b19bb6c79 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs @@ -1,28 +1,14 @@ -using Flow.Launcher.Core.Resource; -using Flow.Launcher.Infrastructure; -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Linq; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Flow.Launcher.Infrastructure.Logger; -using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Core.Resource; using Flow.Launcher.Plugin; using Microsoft.IO; -using System.Windows; -using System.Windows.Controls; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; -using CheckBox = System.Windows.Controls.CheckBox; -using Control = System.Windows.Controls.Control; -using Orientation = System.Windows.Controls.Orientation; -using TextBox = System.Windows.Controls.TextBox; -using UserControl = System.Windows.Controls.UserControl; -using System.Windows.Documents; namespace Flow.Launcher.Core.Plugin { @@ -32,7 +18,9 @@ namespace Flow.Launcher.Core.Plugin /// internal abstract class JsonRPCPlugin : JsonRPCPluginBase { - public const string JsonRPC = "JsonRPC"; + public new const string JsonRPC = "JsonRPC"; + + private static readonly string ClassName = nameof(JsonRPCPlugin); protected abstract Task RequestAsync(JsonRPCRequestModel rpcRequest, CancellationToken token = default); protected abstract string Request(JsonRPCRequestModel rpcRequest, CancellationToken token = default); @@ -41,9 +29,6 @@ namespace Flow.Launcher.Core.Plugin private int RequestId { get; set; } - private string SettingConfigurationPath => Path.Combine(Context.CurrentPluginMetadata.PluginDirectory, "SettingsTemplate.yaml"); - private string SettingPath => Path.Combine(DataLocation.PluginSettingsDirectory, Context.CurrentPluginMetadata.Name, "Settings.json"); - public override List LoadContextMenus(Result selectedResult) { var request = new JsonRPCRequestModel(RequestId++, @@ -69,13 +54,6 @@ namespace Flow.Launcher.Core.Plugin } }; - private static readonly JsonSerializerOptions settingSerializeOption = new() - { - WriteIndented = true - }; - - private readonly Dictionary _settingControls = new(); - private async Task> DeserializedResultAsync(Stream output) { await using (output) @@ -134,7 +112,6 @@ namespace Flow.Launcher.Core.Plugin return !result.JsonRPCAction.DontHideAfterAction; } - /// /// Execute external program and return the output /// @@ -172,11 +149,11 @@ namespace Flow.Launcher.Core.Plugin var error = standardError.ReadToEnd(); if (!string.IsNullOrEmpty(error)) { - Log.Error($"|JsonRPCPlugin.Execute|{error}"); + Context.API.LogError(ClassName, error); return string.Empty; } - Log.Error("|JsonRPCPlugin.Execute|Empty standard output and standard error."); + Context.API.LogError(ClassName, "Empty standard output and standard error."); return string.Empty; } @@ -184,8 +161,8 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - Log.Exception( - $"|JsonRPCPlugin.Execute|Exception for filename <{startInfo.FileName}> with argument <{startInfo.Arguments}>", + Context.API.LogException(ClassName, + $"Exception for filename <{startInfo.FileName}> with argument <{startInfo.Arguments}>", e); return string.Empty; } @@ -196,7 +173,7 @@ namespace Flow.Launcher.Core.Plugin using var process = Process.Start(startInfo); if (process == null) { - Log.Error("|JsonRPCPlugin.ExecuteAsync|Can't start new process"); + Context.API.LogError(ClassName, "Can't start new process"); return Stream.Null; } @@ -216,7 +193,7 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - Log.Exception("|JsonRPCPlugin.ExecuteAsync|Exception when kill process", e); + Context.API.LogException(ClassName, "Exception when kill process", e); } }); @@ -237,7 +214,7 @@ namespace Flow.Launcher.Core.Plugin { case (0, 0): const string errorMessage = "Empty JSON-RPC Response."; - Log.Warn($"|{nameof(JsonRPCPlugin)}.{nameof(ExecuteAsync)}|{errorMessage}"); + Context.API.LogWarn(ClassName, errorMessage); break; case (_, not 0): throw new InvalidDataException(Encoding.UTF8.GetString(errorBuffer.ToArray())); // The process has exited with an error message diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs index f6e5e5879..df0438409 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs @@ -1,32 +1,15 @@ -using Flow.Launcher.Core.Resource; -using Flow.Launcher.Infrastructure; -using System; +using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; -using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Flow.Launcher.Infrastructure.Logger; -using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Core.Resource; using Flow.Launcher.Plugin; -using Microsoft.IO; -using System.Windows; -using System.Windows.Controls; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; -using CheckBox = System.Windows.Controls.CheckBox; using Control = System.Windows.Controls.Control; -using Orientation = System.Windows.Controls.Orientation; -using TextBox = System.Windows.Controls.TextBox; -using UserControl = System.Windows.Controls.UserControl; -using System.Windows.Documents; -using static System.Windows.Forms.LinkLabel; -using Droplex; -using System.Windows.Forms; -using Microsoft.VisualStudio.Threading; namespace Flow.Launcher.Core.Plugin { @@ -34,18 +17,18 @@ namespace Flow.Launcher.Core.Plugin /// Represent the plugin that using JsonPRC /// every JsonRPC plugin should has its own plugin instance /// - internal abstract class JsonRPCPluginBase : IAsyncPlugin, IContextMenu, ISettingProvider, ISavable + public abstract class JsonRPCPluginBase : IAsyncPlugin, IContextMenu, ISettingProvider, ISavable { - protected PluginInitContext Context; public const string JsonRPC = "JsonRPC"; - private int RequestId { get; set; } + protected PluginInitContext Context; private string SettingConfigurationPath => Path.Combine(Context.CurrentPluginMetadata.PluginDirectory, "SettingsTemplate.yaml"); - private string SettingPath => Path.Combine(DataLocation.PluginSettingsDirectory, - Context.CurrentPluginMetadata.Name, "Settings.json"); + private string SettingDirectory => Context.CurrentPluginMetadata.PluginSettingsDirectoryPath; + + private string SettingPath => Path.Combine(SettingDirectory, "Settings.json"); public abstract List LoadContextMenus(Result selectedResult); @@ -123,7 +106,6 @@ namespace Flow.Launcher.Core.Plugin public abstract Task> QueryAsync(Query query, CancellationToken token); - private async Task InitSettingAsync() { JsonRpcConfigurationModel configuration = null; @@ -135,7 +117,6 @@ namespace Flow.Launcher.Core.Plugin await File.ReadAllTextAsync(SettingConfigurationPath)); } - Settings ??= new JsonRPCPluginSettings { Configuration = configuration, SettingPath = SettingPath, API = Context.API @@ -146,7 +127,7 @@ namespace Flow.Launcher.Core.Plugin public virtual async Task InitAsync(PluginInitContext context) { - this.Context = context; + Context = context; await InitSettingAsync(); } @@ -155,6 +136,11 @@ namespace Flow.Launcher.Core.Plugin Settings?.Save(); } + public bool NeedCreateSettingPanel() + { + return Settings.NeedCreateSettingPanel(); + } + public Control CreateSettingPanel() { return Settings.CreateSettingPanel(); diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs index 50eb30998..003e72a5d 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs @@ -1,18 +1,14 @@ using System.Collections.Concurrent; using System.Collections.Generic; +using System.Text.Json; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Documents; -using System.Windows.Forms; using Flow.Launcher.Infrastructure.Storage; using Flow.Launcher.Plugin; -using CheckBox = System.Windows.Controls.CheckBox; -using ComboBox = System.Windows.Controls.ComboBox; -using Control = System.Windows.Controls.Control; -using Orientation = System.Windows.Controls.Orientation; -using TextBox = System.Windows.Controls.TextBox; -using UserControl = System.Windows.Controls.UserControl; + +#nullable enable namespace Flow.Launcher.Core.Plugin { @@ -23,51 +19,74 @@ namespace Flow.Launcher.Core.Plugin public required string SettingPath { get; init; } public Dictionary SettingControls { get; } = new(); - public IReadOnlyDictionary Inner => Settings; - protected ConcurrentDictionary Settings { get; set; } + public IReadOnlyDictionary Inner => Settings; + protected ConcurrentDictionary Settings { get; set; } = null!; public required IPublicAPI API { get; init; } - private JsonStorage> _storage; + private static readonly string ClassName = nameof(JsonRPCPluginSettings); - // maybe move to resource? - private static readonly Thickness settingControlMargin = new(0, 9, 18, 9); - private static readonly Thickness settingCheckboxMargin = new(0, 9, 9, 9); - private static readonly Thickness settingPanelMargin = new(0, 0, 0, 0); - private static readonly Thickness settingTextBlockMargin = new(70, 9, 18, 9); - private static readonly Thickness settingLabelPanelMargin = new(70, 9, 18, 9); - private static readonly Thickness settingLabelMargin = new(0, 0, 0, 0); - private static readonly Thickness settingDescMargin = new(0, 2, 0, 0); - private static readonly Thickness settingSepMargin = new(0, 0, 0, 2); + private JsonStorage> _storage = null!; + + private static readonly Thickness SettingPanelMargin = (Thickness)Application.Current.FindResource("SettingPanelMargin"); + private static readonly Thickness SettingPanelItemLeftMargin = (Thickness)Application.Current.FindResource("SettingPanelItemLeftMargin"); + private static readonly Thickness SettingPanelItemTopBottomMargin = (Thickness)Application.Current.FindResource("SettingPanelItemTopBottomMargin"); + private static readonly Thickness SettingPanelItemLeftTopBottomMargin = (Thickness)Application.Current.FindResource("SettingPanelItemLeftTopBottomMargin"); + private static readonly double SettingPanelTextBoxMinWidth = (double)Application.Current.FindResource("SettingPanelTextBoxMinWidth"); + private static readonly double SettingPanelPathTextBoxWidth = (double)Application.Current.FindResource("SettingPanelPathTextBoxWidth"); + private static readonly double SettingPanelAreaTextBoxMinHeight = (double)Application.Current.FindResource("SettingPanelAreaTextBoxMinHeight"); public async Task InitializeAsync() { - _storage = new JsonStorage>(SettingPath); - Settings = await _storage.LoadAsync(); - - if (Configuration == null) + if (Settings == null) { - return; + _storage = new JsonStorage>(SettingPath); + Settings = await _storage.LoadAsync(); + + // Because value type of settings dictionary is object which causes them to be JsonElement when loading from json files, + // we need to convert it to the correct type + foreach (var (key, value) in Settings) + { + if (value is not JsonElement jsonElement) continue; + + Settings[key] = jsonElement.ValueKind switch + { + JsonValueKind.String => jsonElement.GetString() ?? value, + JsonValueKind.True => jsonElement.GetBoolean(), + JsonValueKind.False => jsonElement.GetBoolean(), + JsonValueKind.Null => null, + _ => value + }; + } } + if (Configuration == null) return; + foreach (var (type, attributes) in Configuration.Body) { - if (attributes.Name == null) - { - continue; - } + // Skip if the setting does not have attributes or name + if (attributes?.Name == null) continue; - if (!Settings.ContainsKey(attributes.Name)) + // Skip if the setting does not have attributes or name + if (!NeedSaveInSettings(type)) continue; + + // If need save in settings, we need to make sure the setting exists in the settings file + if (Settings.ContainsKey(attributes.Name)) continue; + + if (type == "checkbox") + { + // If can parse the default value to bool, use it, otherwise use false + Settings[attributes.Name] = bool.TryParse(attributes.DefaultValue, out var value) && value; + } + else { Settings[attributes.Name] = attributes.DefaultValue; } } } - public void UpdateSettings(IReadOnlyDictionary settings) { - if (settings == null || settings.Count == 0) - return; + if (settings == null || settings.Count == 0) return; foreach (var (key, value) in settings) { @@ -78,19 +97,23 @@ namespace Flow.Launcher.Core.Plugin switch (control) { case TextBox textBox: - textBox.Dispatcher.Invoke(() => textBox.Text = value as string ?? string.Empty); + var text = value as string ?? string.Empty; + textBox.Dispatcher.Invoke(() => textBox.Text = text); break; case PasswordBox passwordBox: - passwordBox.Dispatcher.Invoke(() => passwordBox.Password = value as string ?? string.Empty); + var password = value as string ?? string.Empty; + passwordBox.Dispatcher.Invoke(() => passwordBox.Password = password); break; case ComboBox comboBox: comboBox.Dispatcher.Invoke(() => comboBox.SelectedItem = value); break; case CheckBox checkBox: - checkBox.Dispatcher.Invoke(() => - checkBox.IsChecked = value is bool isChecked - ? isChecked - : bool.Parse(value as string ?? string.Empty)); + var isChecked = value is bool boolValue + ? boolValue + // If can parse the default value to bool, use it, otherwise use false + : value is string stringValue && bool.TryParse(stringValue, out var boolValueFromString) + && boolValueFromString; + checkBox.Dispatcher.Invoke(() => checkBox.IsChecked = isChecked); break; } } @@ -101,341 +124,380 @@ namespace Flow.Launcher.Core.Plugin public async Task SaveAsync() { - await _storage.SaveAsync(); + try + { + await _storage.SaveAsync(); + } + catch (System.Exception e) + { + API.LogException(ClassName, $"Failed to save plugin settings to path: {SettingPath}", e); + } } public void Save() { - _storage.Save(); + try + { + _storage.Save(); + } + catch (System.Exception e) + { + API.LogException(ClassName, $"Failed to save plugin settings to path: {SettingPath}", e); + } + } + + public bool NeedCreateSettingPanel() + { + // If there are no settings or the settings configuration is empty, return null + return Settings != null && Configuration != null && Configuration.Body.Count != 0; } public Control CreateSettingPanel() { - if (Settings == null || Settings.Count == 0) - return new(); + if (!NeedCreateSettingPanel()) return null!; - var settingWindow = new UserControl(); - var mainPanel = new Grid { Margin = settingPanelMargin, VerticalAlignment = VerticalAlignment.Center }; - - ColumnDefinition gridCol1 = new ColumnDefinition(); - ColumnDefinition gridCol2 = new ColumnDefinition(); - - gridCol1.Width = new GridLength(70, GridUnitType.Star); - gridCol2.Width = new GridLength(30, GridUnitType.Star); - mainPanel.ColumnDefinitions.Add(gridCol1); - mainPanel.ColumnDefinitions.Add(gridCol2); - settingWindow.Content = mainPanel; - int rowCount = 0; - - foreach (var (type, attribute) in Configuration.Body) + // Create main grid with two columns (Column 1: Auto, Column 2: *) + var mainPanel = new Grid { Margin = SettingPanelMargin, VerticalAlignment = VerticalAlignment.Center }; + mainPanel.ColumnDefinitions.Add(new ColumnDefinition() { - Separator sep = new Separator(); - sep.VerticalAlignment = VerticalAlignment.Top; - sep.Margin = settingSepMargin; - sep.SetResourceReference(Separator.BackgroundProperty, "Color03B"); /* for theme change */ - var panel = new StackPanel + Width = new GridLength(0, GridUnitType.Auto) + }); + mainPanel.ColumnDefinitions.Add(new ColumnDefinition() + { + Width = new GridLength(1, GridUnitType.Star) + }); + + // Iterate over each setting and create one row for it + var rowCount = 0; + foreach (var (type, attributes) in Configuration!.Body) + { + // Skip if the setting does not have attributes or name + if (attributes?.Name == null) continue; + + // Add a new row to the main grid + mainPanel.RowDefinitions.Add(new RowDefinition() { - Orientation = Orientation.Vertical, - VerticalAlignment = VerticalAlignment.Center, - Margin = settingLabelPanelMargin - }; - - RowDefinition gridRow = new RowDefinition(); - mainPanel.RowDefinitions.Add(gridRow); - var name = new TextBlock() - { - Text = attribute.Label, - VerticalAlignment = VerticalAlignment.Center, - Margin = settingLabelMargin, - TextWrapping = TextWrapping.WrapWithOverflow - }; - - var desc = new TextBlock() - { - Text = attribute.Description, - FontSize = 12, - VerticalAlignment = VerticalAlignment.Center, - Margin = settingDescMargin, - TextWrapping = TextWrapping.WrapWithOverflow - }; - - desc.SetResourceReference(TextBlock.ForegroundProperty, "Color04B"); - - if (attribute.Description == null) /* if no description, hide */ - desc.Visibility = Visibility.Collapsed; - - - if (type != "textBlock") /* if textBlock, hide desc */ - { - panel.Children.Add(name); - panel.Children.Add(desc); - } - - - Grid.SetColumn(panel, 0); - Grid.SetRow(panel, rowCount); + Height = new GridLength(0, GridUnitType.Auto) + }); + // State controls for column 0 and 1 + StackPanel? panel = null; FrameworkElement contentControl; + // If the type is textBlock, separator, or checkbox, we do not need to create a panel + if (type != "textBlock" && type != "separator" && type != "checkbox") + { + // Create a panel to hold the label and description + panel = new StackPanel + { + Margin = SettingPanelItemTopBottomMargin, + Orientation = Orientation.Vertical, + VerticalAlignment = VerticalAlignment.Center + }; + + // Create a text block for name + var name = new TextBlock() + { + Text = attributes.Label, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.WrapWithOverflow + }; + + // Create a text block for description + TextBlock? desc = null; + if (attributes.Description != null) + { + desc = new TextBlock() + { + Text = attributes.Description, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.WrapWithOverflow + }; + + desc.SetResourceReference(TextBlock.StyleProperty, "SettingPanelTextBlockDescriptionStyle"); // for theme change + } + + // Add the name and description to the panel + panel.Children.Add(name); + if (desc != null) panel.Children.Add(desc); + } + switch (type) { case "textBlock": - { - contentControl = new TextBlock { - Text = attribute.Description.Replace("\\r\\n", "\r\n"), - Margin = settingTextBlockMargin, - Padding = new Thickness(0, 0, 0, 0), - HorizontalAlignment = System.Windows.HorizontalAlignment.Left, - TextAlignment = TextAlignment.Left, - TextWrapping = TextWrapping.Wrap - }; + contentControl = new TextBlock + { + Text = attributes.Description?.Replace("\\r\\n", "\r\n") ?? string.Empty, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemTopBottomMargin, + TextAlignment = TextAlignment.Left, + TextWrapping = TextWrapping.Wrap + }; - Grid.SetColumn(contentControl, 0); - Grid.SetColumnSpan(contentControl, 2); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); - - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - - break; - } + break; + } case "input": - { - var textBox = new TextBox() { - Text = Settings[attribute.Name] as string ?? string.Empty, - Margin = settingControlMargin, - HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, - ToolTip = attribute.Description - }; + var textBox = new TextBox() + { + MinWidth = SettingPanelTextBoxMinWidth, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemLeftTopBottomMargin, + Text = Settings[attributes.Name] as string ?? string.Empty, + ToolTip = attributes.Description + }; - textBox.TextChanged += (_, _) => - { - Settings[attribute.Name] = textBox.Text; - }; + textBox.TextChanged += (_, _) => + { + Settings[attributes.Name] = textBox.Text; + }; - contentControl = textBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); + contentControl = textBox; - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - - break; - } + break; + } case "inputWithFileBtn": case "inputWithFolderBtn": - { - var textBox = new TextBox() { - Margin = new Thickness(10, 0, 0, 0), - Text = Settings[attribute.Name] as string ?? string.Empty, - HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, - ToolTip = attribute.Description - }; - - textBox.TextChanged += (_, _) => - { - Settings[attribute.Name] = textBox.Text; - }; - - var Btn = new System.Windows.Controls.Button() - { - Margin = new Thickness(10, 0, 0, 0), Content = "Browse" - }; - - Btn.Click += (_, _) => - { - using CommonDialog dialog = type switch + var textBox = new TextBox() { - "inputWithFolderBtn" => new FolderBrowserDialog(), - _ => new OpenFileDialog(), + Width = SettingPanelPathTextBoxWidth, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemLeftMargin, + Text = Settings[attributes.Name] as string ?? string.Empty, + ToolTip = attributes.Description }; - if (dialog.ShowDialog() != DialogResult.OK) return; - var path = dialog switch + textBox.TextChanged += (_, _) => { - FolderBrowserDialog folderDialog => folderDialog.SelectedPath, - OpenFileDialog fileDialog => fileDialog.FileName, + Settings[attributes.Name] = textBox.Text; }; - textBox.Text = path; - Settings[attribute.Name] = path; - }; - var dockPanel = new DockPanel() { Margin = settingControlMargin }; + var Btn = new Button() + { + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemLeftMargin, + Content = API.GetTranslation("select") + }; - DockPanel.SetDock(Btn, Dock.Right); - dockPanel.Children.Add(Btn); - dockPanel.Children.Add(textBox); - contentControl = dockPanel; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); + Btn.Click += (_, _) => + { + using System.Windows.Forms.CommonDialog dialog = type switch + { + "inputWithFolderBtn" => new System.Windows.Forms.FolderBrowserDialog(), + _ => new System.Windows.Forms.OpenFileDialog(), + }; - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); + if (dialog.ShowDialog() != System.Windows.Forms.DialogResult.OK) + { + return; + } - break; - } + var path = dialog switch + { + System.Windows.Forms.FolderBrowserDialog folderDialog => folderDialog.SelectedPath, + System.Windows.Forms.OpenFileDialog fileDialog => fileDialog.FileName, + _ => throw new System.NotImplementedException() + }; + + textBox.Text = path; + Settings[attributes.Name] = path; + }; + + var stackPanel = new StackPanel() + { + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemTopBottomMargin, + Orientation = Orientation.Horizontal + }; + + // Create a stack panel to wrap the button and text box + stackPanel.Children.Add(textBox); + stackPanel.Children.Add(Btn); + + contentControl = stackPanel; + + break; + } case "textarea": - { - var textBox = new TextBox() { - Height = 120, - Margin = settingControlMargin, - VerticalAlignment = VerticalAlignment.Center, - TextWrapping = TextWrapping.WrapWithOverflow, - AcceptsReturn = true, - HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, - Text = Settings[attribute.Name] as string ?? string.Empty, - ToolTip = attribute.Description - }; + var textBox = new TextBox() + { + MinHeight = SettingPanelAreaTextBoxMinHeight, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemLeftTopBottomMargin, + TextWrapping = TextWrapping.WrapWithOverflow, + AcceptsReturn = true, + Text = Settings[attributes.Name] as string ?? string.Empty, + ToolTip = attributes.Description + }; - textBox.TextChanged += (sender, _) => - { - Settings[attribute.Name] = ((TextBox)sender).Text; - }; + textBox.TextChanged += (sender, _) => + { + Settings[attributes.Name] = ((TextBox)sender).Text; + }; - contentControl = textBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); + contentControl = textBox; - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - - break; - } + break; + } case "passwordBox": - { - var passwordBox = new PasswordBox() { - Margin = settingControlMargin, - Password = Settings[attribute.Name] as string ?? string.Empty, - PasswordChar = attribute.passwordChar == default ? '*' : attribute.passwordChar, - HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, - ToolTip = attribute.Description - }; + var passwordBox = new PasswordBox() + { + MinWidth = SettingPanelTextBoxMinWidth, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemLeftTopBottomMargin, + Password = Settings[attributes.Name] as string ?? string.Empty, + PasswordChar = attributes.passwordChar == default ? '*' : attributes.passwordChar, + ToolTip = attributes.Description, + }; - passwordBox.PasswordChanged += (sender, _) => - { - Settings[attribute.Name] = ((PasswordBox)sender).Password; - }; + passwordBox.PasswordChanged += (sender, _) => + { + Settings[attributes.Name] = ((PasswordBox)sender).Password; + }; - contentControl = passwordBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); + contentControl = passwordBox; - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - - break; - } + break; + } case "dropdown": - { - var comboBox = new System.Windows.Controls.ComboBox() { - ItemsSource = attribute.Options, - SelectedItem = Settings[attribute.Name], - Margin = settingControlMargin, - HorizontalAlignment = System.Windows.HorizontalAlignment.Right, - ToolTip = attribute.Description - }; + var comboBox = new ComboBox() + { + ItemsSource = attributes.Options, + SelectedItem = Settings[attributes.Name], + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemLeftTopBottomMargin, + ToolTip = attributes.Description + }; - comboBox.SelectionChanged += (sender, _) => - { - Settings[attribute.Name] = (string)((System.Windows.Controls.ComboBox)sender).SelectedItem; - }; + comboBox.SelectionChanged += (sender, _) => + { + Settings[attributes.Name] = (string)((ComboBox)sender).SelectedItem; + }; - contentControl = comboBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); + contentControl = comboBox; - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - - break; - } + break; + } case "checkbox": - var checkBox = new CheckBox { - IsChecked = - Settings[attribute.Name] is bool isChecked + // If can parse the default value to bool, use it, otherwise use false + var defaultValue = bool.TryParse(attributes.DefaultValue, out var value) && value; + var checkBox = new CheckBox + { + IsChecked = + Settings[attributes.Name] is bool isChecked ? isChecked - : bool.Parse(attribute.DefaultValue), - Margin = settingCheckboxMargin, - HorizontalAlignment = System.Windows.HorizontalAlignment.Right, - ToolTip = attribute.Description - }; + : defaultValue, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemTopBottomMargin, + Content = attributes.Label, + ToolTip = attributes.Description + }; - checkBox.Click += (sender, _) => - { - Settings[attribute.Name] = ((CheckBox)sender).IsChecked; - }; + checkBox.Click += (sender, _) => + { + Settings[attributes.Name] = ((CheckBox)sender).IsChecked ?? defaultValue; + }; - contentControl = checkBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); + contentControl = checkBox; - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - - break; + break; + } case "hyperlink": - var hyperlink = new Hyperlink { ToolTip = attribute.Description, NavigateUri = attribute.url }; - - var linkbtn = new System.Windows.Controls.Button { - HorizontalAlignment = System.Windows.HorizontalAlignment.Right, - Margin = settingControlMargin - }; + var hyperlink = new Hyperlink + { + ToolTip = attributes.Description, + NavigateUri = attributes.url + }; - linkbtn.Content = attribute.urlLabel; + hyperlink.Inlines.Add(attributes.urlLabel); + hyperlink.RequestNavigate += (sender, e) => + { + API.OpenUrl(e.Uri); + e.Handled = true; + }; - contentControl = linkbtn; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); + var textBlock = new TextBlock() + { + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemLeftTopBottomMargin, + TextAlignment = TextAlignment.Left, + TextWrapping = TextWrapping.Wrap + }; + textBlock.Inlines.Add(hyperlink); - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); + contentControl = textBlock; - break; + break; + } + case "separator": + { + var sep = new Separator(); + + sep.SetResourceReference(Separator.StyleProperty, "SettingPanelSeparatorStyle"); + + contentControl = sep; + + break; + } default: continue; } - if (type != "textBlock") - SettingControls[attribute.Name] = contentControl; + // If type is textBlock or separator, we just add the content control to the main grid + if (panel == null) + { + // Add the content control to the column 0, row rowCount and columnSpan 2 of the main grid + mainPanel.Children.Add(contentControl); + Grid.SetColumn(contentControl, 0); + Grid.SetColumnSpan(contentControl, 2); + Grid.SetRow(contentControl, rowCount); + } + else + { + // Add the panel to the column 0 and row rowCount of the main grid + mainPanel.Children.Add(panel); + Grid.SetColumn(panel, 0); + Grid.SetRow(panel, rowCount); + + // Add the content control to the column 1 and row rowCount of the main grid + mainPanel.Children.Add(contentControl); + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + } + + // Add into SettingControls for settings storage if need + if (NeedSaveInSettings(type)) SettingControls[attributes.Name] = contentControl; - mainPanel.Children.Add(panel); - mainPanel.Children.Add(contentControl); rowCount++; } - return settingWindow; + // Wrap the main grid in a user control + return new UserControl() + { + Content = mainPanel + }; + } + + private static bool NeedSaveInSettings(string type) + { + return type != "textBlock" && type != "separator" && type != "hyperlink"; } } } diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs index 5a6633525..148fd969e 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs @@ -10,73 +10,51 @@ using Microsoft.VisualStudio.Threading; using StreamJsonRpc; using IAsyncDisposable = System.IAsyncDisposable; - namespace Flow.Launcher.Core.Plugin { internal abstract class JsonRPCPluginV2 : JsonRPCPluginBase, IAsyncDisposable, IAsyncReloadable, IResultUpdated { public const string JsonRpc = "JsonRPC"; + private static readonly string ClassName = nameof(JsonRPCPluginV2); + protected abstract IDuplexPipe ClientPipe { get; set; } protected StreamReader ErrorStream { get; set; } private JsonRpc RPC { get; set; } - protected override async Task ExecuteResultAsync(JsonRPCResult result) { - try - { - var res = await RPC.InvokeAsync(result.JsonRPCAction.Method, - argument: result.JsonRPCAction.Parameters); + var res = await RPC.InvokeAsync(result.JsonRPCAction.Method, + argument: result.JsonRPCAction.Parameters); - return res.Hide; - } - catch - { - return false; - } + return res.Hide; } private JoinableTaskFactory JTF { get; } = new JoinableTaskFactory(new JoinableTaskContext()); public override List LoadContextMenus(Result selectedResult) { - try - { - var res = JTF.Run(() => RPC.InvokeWithCancellationAsync("context_menu", - new object[] { selectedResult.ContextData })); + var res = JTF.Run(() => RPC.InvokeWithCancellationAsync("context_menu", + new object[] { selectedResult.ContextData })); - var results = ParseResults(res); + var results = ParseResults(res); - return results; - } - catch - { - return new List(); - } + return results; } public override async Task> QueryAsync(Query query, CancellationToken token) { - try - { - var res = await RPC.InvokeWithCancellationAsync("query", - new object[] { query, Settings.Inner }, - token); + var res = await RPC.InvokeWithCancellationAsync("query", + new object[] { query, Settings.Inner }, + token); - var results = ParseResults(res); + var results = ParseResults(res); - return results; - } - catch - { - return new List(); - } + return results; } - public override async Task InitAsync(PluginInitContext context) { await base.InitAsync(context); @@ -109,7 +87,6 @@ namespace Flow.Launcher.Core.Plugin protected abstract MessageHandlerType MessageHandler { get; } - private void SetupJsonRPC() { var formatter = new SystemTextJsonFormatter { JsonSerializerOptions = RequestSerializeOption }; @@ -133,10 +110,24 @@ namespace Flow.Launcher.Core.Plugin RPC.StartListening(); } - public virtual Task ReloadDataAsync() + public virtual async Task ReloadDataAsync() { - SetupJsonRPC(); - return Task.CompletedTask; + try + { + await RPC.InvokeAsync("reload_data", Context); + } + catch (RemoteMethodNotFoundException) + { + // Ignored + } + catch (ConnectionLostException) + { + // Ignored + } + catch (Exception e) + { + Context.API.LogException(ClassName, $"Failed to call reload_data for plugin {Context.CurrentPluginMetadata.Name}", e); + } } public virtual async ValueTask DisposeAsync() @@ -145,8 +136,17 @@ namespace Flow.Launcher.Core.Plugin { await RPC.InvokeAsync("close"); } - catch (RemoteMethodNotFoundException e) + catch (RemoteMethodNotFoundException) { + // Ignored + } + catch (ConnectionLostException) + { + // Ignored + } + catch (Exception e) + { + Context.API.LogException(ClassName, $"Failed to call close for plugin {Context.CurrentPluginMetadata.Name}", e); } finally { diff --git a/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs b/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs index b8bfee591..4d988b837 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Runtime.CompilerServices; @@ -13,7 +12,7 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models { public class JsonRPCPublicAPI { - private IPublicAPI _api; + private readonly IPublicAPI _api; public JsonRPCPublicAPI(IPublicAPI api) { @@ -105,7 +104,6 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models return _api.GetAllPlugins(); } - public MatchResult FuzzySearch(string query, string stringToCompare) { return _api.FuzzySearch(query, stringToCompare); @@ -121,10 +119,10 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models return _api.HttpGetStreamAsync(url, token); } - public Task HttpDownloadAsync([NotNull] string url, [NotNull] string filePath, + public Task HttpDownloadAsync([NotNull] string url, [NotNull] string filePath, Action reportProgress = null, CancellationToken token = default) { - return _api.HttpDownloadAsync(url, filePath, token); + return _api.HttpDownloadAsync(url, filePath, reportProgress, token); } public void AddActionKeyword(string pluginId, string newActionKeyword) @@ -157,21 +155,44 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models _api.LogWarn(className, message, methodName); } + public void LogError(string className, string message, [CallerMemberName] string methodName = "") + { + _api.LogError(className, message, methodName); + } + public void OpenDirectory(string DirectoryPath, string FileNameOrFilePath = null) { _api.OpenDirectory(DirectoryPath, FileNameOrFilePath); } - public void OpenUrl(string url, bool? inPrivate = null) { _api.OpenUrl(url, inPrivate); } - public void OpenAppUri(string appUri) { _api.OpenAppUri(appUri); } + + public void BackToQueryResults() + { + _api.BackToQueryResults(); + } + + public void StartLoadingBar() + { + _api.StartLoadingBar(); + } + + public void StopLoadingBar() + { + _api.StopLoadingBar(); + } + + public void SavePluginCaches() + { + _api.SavePluginCaches(); + } } } diff --git a/Flow.Launcher.Core/Plugin/PluginConfig.cs b/Flow.Launcher.Core/Plugin/PluginConfig.cs index dd6517a7f..f7457b4e1 100644 --- a/Flow.Launcher.Core/Plugin/PluginConfig.cs +++ b/Flow.Launcher.Core/Plugin/PluginConfig.cs @@ -1,17 +1,22 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.IO; using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Plugin; using System.Text.Json; +using CommunityToolkit.Mvvm.DependencyInjection; namespace Flow.Launcher.Core.Plugin { - internal abstract class PluginConfig { + private static readonly string ClassName = nameof(PluginConfig); + + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); + /// /// Parse plugin metadata in the given directories /// @@ -33,7 +38,7 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - Log.Exception($"|PluginConfig.ParsePLuginConfigs|Can't delete <{directory}>", e); + API.LogException(ClassName, $"Can't delete <{directory}>", e); } } else @@ -50,11 +55,11 @@ namespace Flow.Launcher.Core.Plugin duplicateList .ForEach( - x => Log.Warn("PluginConfig", - string.Format("Duplicate plugin name: {0}, id: {1}, version: {2} " + - "not loaded due to version not the highest of the duplicates", - x.Name, x.ID, x.Version), - "GetUniqueLatestPluginMetadata")); + x => API.LogWarn(ClassName, + string.Format("Duplicate plugin name: {0}, id: {1}, version: {2} " + + "not loaded due to version not the highest of the duplicates", + x.Name, x.ID, x.Version), + "GetUniqueLatestPluginMetadata")); return uniqueList; } @@ -102,7 +107,7 @@ namespace Flow.Launcher.Core.Plugin string configPath = Path.Combine(pluginDirectory, Constant.PluginMetadataFileName); if (!File.Exists(configPath)) { - Log.Error($"|PluginConfig.GetPluginMetadata|Didn't find config file <{configPath}>"); + API.LogError(ClassName, $"Didn't find config file <{configPath}>"); return null; } @@ -112,29 +117,29 @@ namespace Flow.Launcher.Core.Plugin metadata = JsonSerializer.Deserialize(File.ReadAllText(configPath)); metadata.PluginDirectory = pluginDirectory; // for plugins which doesn't has ActionKeywords key - metadata.ActionKeywords = metadata.ActionKeywords ?? new List { metadata.ActionKeyword }; + metadata.ActionKeywords ??= new List { metadata.ActionKeyword }; // for plugin still use old ActionKeyword metadata.ActionKeyword = metadata.ActionKeywords?[0]; } catch (Exception e) { - Log.Exception($"|PluginConfig.GetPluginMetadata|invalid json for config <{configPath}>", e); + API.LogException(ClassName, $"Invalid json for config <{configPath}>", e); return null; } if (!AllowedLanguage.IsAllowed(metadata.Language)) { - Log.Error($"|PluginConfig.GetPluginMetadata|Invalid language <{metadata.Language}> for config <{configPath}>"); + API.LogError(ClassName, $"Invalid language <{metadata.Language}> for config <{configPath}>"); return null; } if (!File.Exists(metadata.ExecuteFilePath)) { - Log.Error($"|PluginConfig.GetPluginMetadata|execute file path didn't exist <{metadata.ExecuteFilePath}> for conifg <{configPath}"); + API.LogError(ClassName, $"Execute file path didn't exist <{metadata.ExecuteFilePath}> for conifg <{configPath}"); return null; } return metadata; } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 91cb36a0e..9b525f331 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -1,19 +1,19 @@ -using Flow.Launcher.Core.ExternalPlugins; -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Core.ExternalPlugins; using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; -using ISavable = Flow.Launcher.Plugin.ISavable; using Flow.Launcher.Plugin.SharedCommands; -using System.Text.Json; -using Flow.Launcher.Core.Resource; +using IRemovable = Flow.Launcher.Core.Storage.IRemovable; +using ISavable = Flow.Launcher.Plugin.ISavable; namespace Flow.Launcher.Core.Plugin { @@ -22,17 +22,22 @@ namespace Flow.Launcher.Core.Plugin /// public static class PluginManager { + private static readonly string ClassName = nameof(PluginManager); + private static IEnumerable _contextMenuPlugins; + private static IEnumerable _homePlugins; public static List AllPlugins { get; private set; } public static readonly HashSet GlobalPlugins = new(); public static readonly Dictionary NonGlobalPlugins = new(); - public static IPublicAPI API { private set; get; } + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); private static PluginsSettings Settings; private static List _metadatas; - private static List _modifiedPlugins = new List(); + private static readonly List _modifiedPlugins = new(); /// /// Directories that will hold Flow Launcher plugin directory @@ -56,18 +61,34 @@ namespace Flow.Launcher.Core.Plugin /// public static void Save() { - foreach (var plugin in AllPlugins) + foreach (var pluginPair in AllPlugins) { - var savable = plugin.Plugin as ISavable; - savable?.Save(); + var savable = pluginPair.Plugin as ISavable; + try + { + savable?.Save(); + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to save plugin {pluginPair.Metadata.Name}", e); + } } API.SavePluginSettings(); + API.SavePluginCaches(); } public static async ValueTask DisposePluginsAsync() { foreach (var pluginPair in AllPlugins) + { + await DisposePluginAsync(pluginPair); + } + } + + private static async Task DisposePluginAsync(PluginPair pluginPair) + { + try { switch (pluginPair.Plugin) { @@ -79,6 +100,10 @@ namespace Flow.Launcher.Core.Plugin break; } } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to dispose plugin {pluginPair.Metadata.Name}", e); + } } public static async Task ReloadDataAsync() @@ -152,39 +177,80 @@ namespace Flow.Launcher.Core.Plugin Settings = settings; Settings.UpdatePluginSettings(_metadatas); AllPlugins = PluginsLoader.Plugins(_metadatas, Settings); + // Since dotnet plugins need to get assembly name first, we should update plugin directory after loading plugins + UpdatePluginDirectory(_metadatas); + } + + private static void UpdatePluginDirectory(List metadatas) + { + foreach (var metadata in metadatas) + { + if (AllowedLanguage.IsDotNet(metadata.Language)) + { + if (string.IsNullOrEmpty(metadata.AssemblyName)) + { + API.LogWarn(ClassName, $"AssemblyName is empty for plugin with metadata: {metadata.Name}"); + continue; // Skip if AssemblyName is not set, which can happen for erroneous plugins + } + metadata.PluginSettingsDirectoryPath = Path.Combine(DataLocation.PluginSettingsDirectory, metadata.AssemblyName); + metadata.PluginCacheDirectoryPath = Path.Combine(DataLocation.PluginCacheDirectory, metadata.AssemblyName); + } + else + { + if (string.IsNullOrEmpty(metadata.Name)) + { + API.LogWarn(ClassName, $"Name is empty for plugin with metadata: {metadata.Name}"); + continue; // Skip if Name is not set, which can happen for erroneous plugins + } + metadata.PluginSettingsDirectoryPath = Path.Combine(DataLocation.PluginSettingsDirectory, metadata.Name); + metadata.PluginCacheDirectoryPath = Path.Combine(DataLocation.PluginCacheDirectory, metadata.Name); + } + } } /// /// Call initialize for all plugins /// /// return the list of failed to init plugins or null for none - public static async Task InitializePluginsAsync(IPublicAPI api) + public static async Task InitializePluginsAsync() { - API = api; var failedPlugins = new ConcurrentQueue(); var InitTasks = AllPlugins.Select(pair => Task.Run(async delegate { try { - var milliseconds = await Stopwatch.DebugAsync($"|PluginManager.InitializePlugins|Init method time cost for <{pair.Metadata.Name}>", + var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Init method time cost for <{pair.Metadata.Name}>", () => pair.Plugin.InitAsync(new PluginInitContext(pair.Metadata, API))); pair.Metadata.InitTime += milliseconds; - Log.Info( - $"|PluginManager.InitializePlugins|Total init cost for <{pair.Metadata.Name}> is <{pair.Metadata.InitTime}ms>"); + API.LogInfo(ClassName, + $"Total init cost for <{pair.Metadata.Name}> is <{pair.Metadata.InitTime}ms>"); } catch (Exception e) { - Log.Exception(nameof(PluginManager), $"Fail to Init plugin: {pair.Metadata.Name}", e); - pair.Metadata.Disabled = true; - failedPlugins.Enqueue(pair); + API.LogException(ClassName, $"Fail to Init plugin: {pair.Metadata.Name}", e); + if (pair.Metadata.Disabled && pair.Metadata.HomeDisabled) + { + // If this plugin is already disabled, do not show error message again + // Or else it will be shown every time + API.LogDebug(ClassName, $"Skipped init for <{pair.Metadata.Name}> due to error"); + } + else + { + pair.Metadata.Disabled = true; + pair.Metadata.HomeDisabled = true; + failedPlugins.Enqueue(pair); + API.LogDebug(ClassName, $"Disable plugin <{pair.Metadata.Name}> because init failed"); + } } })); await Task.WhenAll(InitTasks); _contextMenuPlugins = GetPluginsForInterface(); + _homePlugins = GetPluginsForInterface(); + foreach (var plugin in AllPlugins) { // set distinct on each plugin's action keywords helps only firing global(*) and action keywords once where a plugin @@ -203,16 +269,13 @@ namespace Flow.Launcher.Core.Plugin } } - InternationalizationManager.Instance.AddPluginLanguageDirectories(GetPluginsForInterface()); - InternationalizationManager.Instance.ChangeLanguage(InternationalizationManager.Instance.Settings.Language); - if (failedPlugins.Any()) { var failed = string.Join(",", failedPlugins.Select(x => x.Metadata.Name)); API.ShowMsg( - InternationalizationManager.Instance.GetTranslation("failedToInitializePluginsTitle"), + API.GetTranslation("failedToInitializePluginsTitle"), string.Format( - InternationalizationManager.Instance.GetTranslation("failedToInitializePluginsMessage"), + API.GetTranslation("failedToInitializePluginsMessage"), failed ), "", @@ -226,17 +289,20 @@ namespace Flow.Launcher.Core.Plugin if (query is null) return Array.Empty(); - if (!NonGlobalPlugins.ContainsKey(query.ActionKeyword)) + if (!NonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugin)) return GlobalPlugins; - - var plugin = NonGlobalPlugins[query.ActionKeyword]; return new List { plugin }; } + public static ICollection ValidPluginsForHomeQuery() + { + return _homePlugins.ToList(); + } + public static async Task> QueryForPluginAsync(PluginPair pair, Query query, CancellationToken token) { var results = new List(); @@ -244,7 +310,7 @@ namespace Flow.Launcher.Core.Plugin try { - var milliseconds = await Stopwatch.DebugAsync($"|PluginManager.QueryForPlugin|Cost for {metadata.Name}", + var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}", async () => results = await pair.Plugin.QueryAsync(query, token).ConfigureAwait(false)); token.ThrowIfCancellationRequested(); @@ -268,7 +334,7 @@ namespace Flow.Launcher.Core.Plugin { Title = $"{metadata.Name}: Failed to respond!", SubTitle = "Select this result for more info", - IcoPath = Flow.Launcher.Infrastructure.Constant.ErrorIcon, + IcoPath = Constant.ErrorIcon, PluginDirectory = metadata.PluginDirectory, ActionKeywordAssigned = query.ActionKeyword, PluginID = metadata.ID, @@ -281,7 +347,37 @@ namespace Flow.Launcher.Core.Plugin return results; } - public static void UpdatePluginMetadata(List results, PluginMetadata metadata, Query query) + public static async Task> QueryHomeForPluginAsync(PluginPair pair, Query query, CancellationToken token) + { + var results = new List(); + var metadata = pair.Metadata; + + try + { + var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}", + async () => results = await ((IAsyncHomeQuery)pair.Plugin).HomeQueryAsync(token).ConfigureAwait(false)); + + token.ThrowIfCancellationRequested(); + if (results == null) + return null; + UpdatePluginMetadata(results, metadata, query); + + token.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) + { + // null will be fine since the results will only be added into queue if the token hasn't been cancelled + return null; + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to query home for plugin: {metadata.Name}", e); + return null; + } + return results; + } + + public static void UpdatePluginMetadata(IReadOnlyList results, PluginMetadata metadata, Query query) { foreach (var r in results) { @@ -332,8 +428,8 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - Log.Exception( - $"|PluginManager.GetContextMenusForPlugin|Can't load context menus for plugin <{pluginPair.Metadata.Name}>", + API.LogException(ClassName, + $"Can't load context menus for plugin <{pluginPair.Metadata.Name}>", e); } } @@ -341,12 +437,17 @@ namespace Flow.Launcher.Core.Plugin return results; } + public static bool IsHomePlugin(string id) + { + return _homePlugins.Any(p => p.Metadata.ID == id); + } + public static bool ActionKeywordRegistered(string actionKeyword) { // this method is only checking for action keywords (defined as not '*') registration // hence the actionKeyword != Query.GlobalPluginWildcardSign logic - return actionKeyword != Query.GlobalPluginWildcardSign - && NonGlobalPlugins.ContainsKey(actionKeyword); + return actionKeyword != Query.GlobalPluginWildcardSign + && NonGlobalPlugins.ContainsKey(actionKeyword); } /// @@ -365,7 +466,16 @@ namespace Flow.Launcher.Core.Plugin NonGlobalPlugins[newActionKeyword] = plugin; } + // Update action keywords and action keyword in plugin metadata plugin.Metadata.ActionKeywords.Add(newActionKeyword); + if (plugin.Metadata.ActionKeywords.Count > 0) + { + plugin.Metadata.ActionKeyword = plugin.Metadata.ActionKeywords[0]; + } + else + { + plugin.Metadata.ActionKeyword = string.Empty; + } } /// @@ -386,16 +496,15 @@ namespace Flow.Launcher.Core.Plugin if (oldActionkeyword != Query.GlobalPluginWildcardSign) NonGlobalPlugins.Remove(oldActionkeyword); - + // Update action keywords and action keyword in plugin metadata plugin.Metadata.ActionKeywords.Remove(oldActionkeyword); - } - - public static void ReplaceActionKeyword(string id, string oldActionKeyword, string newActionKeyword) - { - if (oldActionKeyword != newActionKeyword) + if (plugin.Metadata.ActionKeywords.Count > 0) { - AddActionKeyword(id, newActionKeyword); - RemoveActionKeyword(id, oldActionKeyword); + plugin.Metadata.ActionKeyword = plugin.Metadata.ActionKeywords[0]; + } + else + { + plugin.Metadata.ActionKeyword = string.Empty; } } @@ -426,37 +535,26 @@ namespace Flow.Launcher.Core.Plugin #region Public functions - public static bool PluginModified(string uuid) + public static bool PluginModified(string id) { - return _modifiedPlugins.Contains(uuid); + return _modifiedPlugins.Contains(id); } - - /// - /// Update a plugin to new version, from a zip file. By default will remove the zip file if update is via url, - /// unless it's a local path installation - /// - public static void UpdatePlugin(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath) + public static async Task UpdatePluginAsync(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath) { InstallPlugin(newVersion, zipFilePath, checkModified:false); - UninstallPlugin(existingVersion, removeSettings:false, checkModified:false); + await UninstallPluginAsync(existingVersion, removePluginFromSettings:false, removePluginSettings:false, checkModified: false); _modifiedPlugins.Add(existingVersion.ID); } - /// - /// Install a plugin. By default will remove the zip file if installation is from url, unless it's a local path installation - /// public static void InstallPlugin(UserPlugin plugin, string zipFilePath) { InstallPlugin(plugin, zipFilePath, checkModified: true); } - /// - /// Uninstall a plugin. - /// - public static void UninstallPlugin(PluginMetadata plugin, bool removeSettings = true) + public static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings = true, bool removePluginSettings = false) { - UninstallPlugin(plugin, removeSettings, true); + await UninstallPluginAsync(plugin, removePluginFromSettings, removePluginSettings, true); } #endregion @@ -497,20 +595,20 @@ namespace Flow.Launcher.Core.Plugin var folderName = string.IsNullOrEmpty(plugin.Version) ? $"{plugin.Name}-{Guid.NewGuid()}" : $"{plugin.Name}-{plugin.Version}"; var defaultPluginIDs = new List - { - "0ECADE17459B49F587BF81DC3A125110", // BrowserBookmark - "CEA0FDFC6D3B4085823D60DC76F28855", // Calculator - "572be03c74c642baae319fc283e561a8", // Explorer - "6A122269676E40EB86EB543B945932B9", // PluginIndicator - "9f8f9b14-2518-4907-b211-35ab6290dee7", // PluginsManager - "b64d0a79-329a-48b0-b53f-d658318a1bf6", // ProcessKiller - "791FC278BA414111B8D1886DFE447410", // Program - "D409510CD0D2481F853690A07E6DC426", // Shell - "CEA08895D2544B019B2E9C5009600DF4", // Sys - "0308FD86DE0A4DEE8D62B9B535370992", // URL - "565B73353DBF4806919830B9202EE3BF", // WebSearch - "5043CETYU6A748679OPA02D27D99677A" // WindowsSettings - }; + { + "0ECADE17459B49F587BF81DC3A125110", // BrowserBookmark + "CEA0FDFC6D3B4085823D60DC76F28855", // Calculator + "572be03c74c642baae319fc283e561a8", // Explorer + "6A122269676E40EB86EB543B945932B9", // PluginIndicator + "9f8f9b14-2518-4907-b211-35ab6290dee7", // PluginsManager + "b64d0a79-329a-48b0-b53f-d658318a1bf6", // ProcessKiller + "791FC278BA414111B8D1886DFE447410", // Program + "D409510CD0D2481F853690A07E6DC426", // Shell + "CEA08895D2544B019B2E9C5009600DF4", // Sys + "0308FD86DE0A4DEE8D62B9B535370992", // URL + "565B73353DBF4806919830B9202EE3BF", // WebSearch + "5043CETYU6A748679OPA02D27D99677A" // WindowsSettings + }; // Treat default plugin differently, it needs to be removable along with each flow release var installDirectory = !defaultPluginIDs.Any(x => x == plugin.ID) @@ -519,9 +617,17 @@ namespace Flow.Launcher.Core.Plugin var newPluginPath = Path.Combine(installDirectory, folderName); - FilesFolders.CopyAll(pluginFolderPath, newPluginPath); + FilesFolders.CopyAll(pluginFolderPath, newPluginPath, (s) => API.ShowMsgBox(s)); - Directory.Delete(tempFolderPluginPath, true); + try + { + if (Directory.Exists(tempFolderPluginPath)) + Directory.Delete(tempFolderPluginPath, true); + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to delete temp folder {tempFolderPluginPath}", e); + } if (checkModified) { @@ -529,16 +635,63 @@ namespace Flow.Launcher.Core.Plugin } } - internal static void UninstallPlugin(PluginMetadata plugin, bool removeSettings, bool checkModified) + internal static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified) { if (checkModified && PluginModified(plugin.ID)) { throw new ArgumentException($"Plugin {plugin.Name} has been modified"); } - if (removeSettings) + if (removePluginSettings || removePluginFromSettings) { - Settings.Plugins.Remove(plugin.ID); + // If we want to remove plugin from AllPlugins, + // we need to dispose them so that they can release file handles + // which can help FL to delete the plugin settings & cache folders successfully + var pluginPairs = AllPlugins.FindAll(p => p.Metadata.ID == plugin.ID); + foreach (var pluginPair in pluginPairs) + { + await DisposePluginAsync(pluginPair); + } + } + + if (removePluginSettings) + { + // For dotnet plugins, we need to remove their PluginJsonStorage and PluginBinaryStorage instances + if (AllowedLanguage.IsDotNet(plugin.Language) && API is IRemovable removable) + { + removable.RemovePluginSettings(plugin.AssemblyName); + removable.RemovePluginCaches(plugin.PluginCacheDirectoryPath); + } + + try + { + var pluginSettingsDirectory = plugin.PluginSettingsDirectoryPath; + if (Directory.Exists(pluginSettingsDirectory)) + Directory.Delete(pluginSettingsDirectory, true); + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to delete plugin settings folder for {plugin.Name}", e); + API.ShowMsg(API.GetTranslation("failedToRemovePluginSettingsTitle"), + string.Format(API.GetTranslation("failedToRemovePluginSettingsMessage"), plugin.Name)); + } + } + + if (removePluginFromSettings) + { + try + { + var pluginCacheDirectory = plugin.PluginCacheDirectoryPath; + if (Directory.Exists(pluginCacheDirectory)) + Directory.Delete(pluginCacheDirectory, true); + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to delete plugin cache folder for {plugin.Name}", e); + API.ShowMsg(API.GetTranslation("failedToRemovePluginCacheTitle"), + string.Format(API.GetTranslation("failedToRemovePluginCacheMessage"), plugin.Name)); + } + Settings.RemovePluginSettings(plugin.ID); AllPlugins.RemoveAll(p => p.Metadata.ID == plugin.ID); } diff --git a/Flow.Launcher.Core/Plugin/PluginsLoader.cs b/Flow.Launcher.Core/Plugin/PluginsLoader.cs index 0f2e4f996..256c36065 100644 --- a/Flow.Launcher.Core/Plugin/PluginsLoader.cs +++ b/Flow.Launcher.Core/Plugin/PluginsLoader.cs @@ -3,19 +3,25 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Threading.Tasks; -using System.Windows.Forms; +using System.Windows; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Core.ExternalPlugins.Environments; #pragma warning disable IDE0005 using Flow.Launcher.Infrastructure.Logger; #pragma warning restore IDE0005 using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; -using Stopwatch = Flow.Launcher.Infrastructure.Stopwatch; namespace Flow.Launcher.Core.Plugin { public static class PluginsLoader { + private static readonly string ClassName = nameof(PluginsLoader); + + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); + public static List Plugins(List metadatas, PluginsSettings settings) { var dotnetPlugins = DotNetPlugins(metadatas); @@ -49,7 +55,7 @@ namespace Flow.Launcher.Core.Plugin return plugins; } - public static IEnumerable DotNetPlugins(List source) + private static IEnumerable DotNetPlugins(List source) { var erroredPlugins = new List(); @@ -58,8 +64,7 @@ namespace Flow.Launcher.Core.Plugin foreach (var metadata in metadatas) { - var milliseconds = Stopwatch.Debug( - $"|PluginsLoader.DotNetPlugins|Constructor init cost for {metadata.Name}", () => + var milliseconds = API.StopwatchLogDebug(ClassName, $"Constructor init cost for {metadata.Name}", () => { Assembly assembly = null; IAsyncPlugin plugin = null; @@ -73,28 +78,30 @@ namespace Flow.Launcher.Core.Plugin typeof(IAsyncPlugin)); plugin = Activator.CreateInstance(type) as IAsyncPlugin; + + metadata.AssemblyName = assembly.GetName().Name; } #if DEBUG - catch (Exception e) + catch (Exception) { throw; } #else catch (Exception e) when (assembly == null) { - Log.Exception($"|PluginsLoader.DotNetPlugins|Couldn't load assembly for the plugin: {metadata.Name}", e); + Log.Exception(ClassName, $"Couldn't load assembly for the plugin: {metadata.Name}", e); } catch (InvalidOperationException e) { - Log.Exception($"|PluginsLoader.DotNetPlugins|Can't find the required IPlugin interface for the plugin: <{metadata.Name}>", e); + Log.Exception(ClassName, $"Can't find the required IPlugin interface for the plugin: <{metadata.Name}>", e); } catch (ReflectionTypeLoadException e) { - Log.Exception($"|PluginsLoader.DotNetPlugins|The GetTypes method was unable to load assembly types for the plugin: <{metadata.Name}>", e); + Log.Exception(ClassName, $"The GetTypes method was unable to load assembly types for the plugin: <{metadata.Name}>", e); } catch (Exception e) { - Log.Exception($"|PluginsLoader.DotNetPlugins|The following plugin has errored and can not be loaded: <{metadata.Name}>", e); + Log.Exception(ClassName, $"The following plugin has errored and can not be loaded: <{metadata.Name}>", e); } #endif @@ -111,7 +118,7 @@ namespace Flow.Launcher.Core.Plugin if (erroredPlugins.Count > 0) { - var errorPluginString = String.Join(Environment.NewLine, erroredPlugins); + var errorPluginString = string.Join(Environment.NewLine, erroredPlugins); var errorMessage = "The following " + (erroredPlugins.Count > 1 ? "plugins have " : "plugin has ") @@ -119,33 +126,41 @@ namespace Flow.Launcher.Core.Plugin _ = Task.Run(() => { - MessageBox.Show($"{errorMessage}{Environment.NewLine}{Environment.NewLine}" + + Ioc.Default.GetRequiredService().ShowMsgBox($"{errorMessage}{Environment.NewLine}{Environment.NewLine}" + $"{errorPluginString}{Environment.NewLine}{Environment.NewLine}" + $"Please refer to the logs for more information", "", - MessageBoxButtons.OK, MessageBoxIcon.Warning); + MessageBoxButton.OK, MessageBoxImage.Warning); }); } return plugins; } - public static IEnumerable ExecutablePlugins(IEnumerable source) + private static IEnumerable ExecutablePlugins(IEnumerable source) { return source .Where(o => o.Language.Equals(AllowedLanguage.Executable, StringComparison.OrdinalIgnoreCase)) - .Select(metadata => new PluginPair + .Select(metadata => { - Plugin = new ExecutablePlugin(metadata.ExecuteFilePath), Metadata = metadata + return new PluginPair + { + Plugin = new ExecutablePlugin(metadata.ExecuteFilePath), + Metadata = metadata + }; }); } - public static IEnumerable ExecutableV2Plugins(IEnumerable source) + private static IEnumerable ExecutableV2Plugins(IEnumerable source) { return source .Where(o => o.Language.Equals(AllowedLanguage.ExecutableV2, StringComparison.OrdinalIgnoreCase)) - .Select(metadata => new PluginPair + .Select(metadata => { - Plugin = new ExecutablePluginV2(metadata.ExecuteFilePath), Metadata = metadata + return new PluginPair + { + Plugin = new ExecutablePlugin(metadata.ExecuteFilePath), + Metadata = metadata + }; }); } } diff --git a/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs b/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs index bae263157..7a6bf07e2 100644 --- a/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs +++ b/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs @@ -1,21 +1,19 @@ -#nullable enable - -using System; -using System.Collections.Generic; +using System; using System.Diagnostics; using System.IO.Pipelines; using System.Threading.Tasks; using Flow.Launcher.Infrastructure; using Flow.Launcher.Plugin; using Meziantou.Framework.Win32; -using Microsoft.VisualBasic.ApplicationServices; using Nerdbank.Streams; +#nullable enable + namespace Flow.Launcher.Core.Plugin { internal abstract class ProcessStreamPluginV2 : JsonRPCPluginV2 { - private static JobObject _jobObject = new JobObject(); + private static readonly JobObject _jobObject = new(); static ProcessStreamPluginV2() { @@ -66,11 +64,10 @@ namespace Flow.Launcher.Core.Plugin ClientPipe = new DuplexPipe(reader, writer); } - public override async Task ReloadDataAsync() { var oldProcess = ClientProcess; - ClientProcess = Process.Start(StartInfo); + ClientProcess = Process.Start(StartInfo)!; ArgumentNullException.ThrowIfNull(ClientProcess); SetupPipe(ClientProcess); await base.ReloadDataAsync(); @@ -79,7 +76,6 @@ namespace Flow.Launcher.Core.Plugin oldProcess.Dispose(); } - public override async ValueTask DisposeAsync() { await base.DisposeAsync(); diff --git a/Flow.Launcher.Core/Plugin/PythonPlugin.cs b/Flow.Launcher.Core/Plugin/PythonPlugin.cs index 536e69b3d..e40b0330e 100644 --- a/Flow.Launcher.Core/Plugin/PythonPlugin.cs +++ b/Flow.Launcher.Core/Plugin/PythonPlugin.cs @@ -1,4 +1,5 @@ -using System.Diagnostics; +using System; +using System.Diagnostics; using System.IO; using System.Text.Json; using System.Threading; @@ -25,14 +26,13 @@ namespace Flow.Launcher.Core.Plugin var path = Path.Combine(Constant.ProgramDirectory, JsonRPC); _startInfo.EnvironmentVariables["PYTHONPATH"] = path; + // Prevent Python from writing .py[co] files. + // Because .pyc contains location infos which will prevent python portable. + _startInfo.EnvironmentVariables["PYTHONDONTWRITEBYTECODE"] = "1"; _startInfo.EnvironmentVariables["FLOW_VERSION"] = Constant.Version; _startInfo.EnvironmentVariables["FLOW_PROGRAM_DIRECTORY"] = Constant.ProgramDirectory; _startInfo.EnvironmentVariables["FLOW_APPLICATION_DIRECTORY"] = Constant.ApplicationDirectory; - - - //Add -B flag to tell python don't write .py[co] files. Because .pyc contains location infos which will prevent python portable - _startInfo.ArgumentList.Add("-B"); } protected override Task RequestAsync(JsonRPCRequestModel request, CancellationToken token = default) @@ -50,10 +50,53 @@ namespace Flow.Launcher.Core.Plugin // TODO: Async Action return Execute(_startInfo); } + public override async Task InitAsync(PluginInitContext context) { - _startInfo.ArgumentList.Add(context.CurrentPluginMetadata.ExecuteFilePath); - _startInfo.ArgumentList.Add(""); + // Run .py files via `-c ` + if (context.CurrentPluginMetadata.ExecuteFilePath.EndsWith(".py", StringComparison.OrdinalIgnoreCase)) + { + var rootDirectory = context.CurrentPluginMetadata.PluginDirectory; + var libDirectory = Path.Combine(rootDirectory, "lib"); + var libPyWin32Directory = Path.Combine(libDirectory, "win32"); + var libPyWin32LibDirectory = Path.Combine(libPyWin32Directory, "lib"); + var pluginDirectory = Path.Combine(rootDirectory, "plugin"); + + // This makes it easier for plugin authors to import their own modules. + // They won't have to add `.`, `./lib`, or `./plugin` to their sys.path manually. + // Instead of running the .py file directly, we pass the code we want to run as a CLI argument. + // This code sets sys.path for the plugin author and then runs the .py file via runpy. + _startInfo.ArgumentList.Add("-c"); + _startInfo.ArgumentList.Add( + $""" + import sys + sys.path.append(r'{rootDirectory}') + sys.path.append(r'{libDirectory}') + sys.path.append(r'{libPyWin32LibDirectory}') + sys.path.append(r'{libPyWin32Directory}') + sys.path.append(r'{pluginDirectory}') + + import runpy + runpy.run_path(r'{context.CurrentPluginMetadata.ExecuteFilePath}', None, '__main__') + """ + ); + // Plugins always expect the JSON data to be in the third argument + // (we're always setting it as _startInfo.ArgumentList[2] = ...). + _startInfo.ArgumentList.Add(""); + } + // Run .pyz files as is + else + { + // No need for -B flag because we're using PYTHONDONTWRITEBYTECODE env variable now, + // but the plugins still expect data to be sent as the third argument, so we're keeping + // the flag here, even though it's not necessary anymore. + _startInfo.ArgumentList.Add("-B"); + _startInfo.ArgumentList.Add(context.CurrentPluginMetadata.ExecuteFilePath); + // Plugins always expect the JSON data to be in the third argument + // (we're always setting it as _startInfo.ArgumentList[2] = ...). + _startInfo.ArgumentList.Add(""); + } + await base.InitAsync(context); _startInfo.WorkingDirectory = context.CurrentPluginMetadata.PluginDirectory; } diff --git a/Flow.Launcher.Core/Plugin/PythonPluginV2.cs b/Flow.Launcher.Core/Plugin/PythonPluginV2.cs index 5c36e0eea..8a9e1ff44 100644 --- a/Flow.Launcher.Core/Plugin/PythonPluginV2.cs +++ b/Flow.Launcher.Core/Plugin/PythonPluginV2.cs @@ -26,14 +26,45 @@ namespace Flow.Launcher.Core.Plugin var path = Path.Combine(Constant.ProgramDirectory, JsonRpc); StartInfo.EnvironmentVariables["PYTHONPATH"] = path; - - //Add -B flag to tell python don't write .py[co] files. Because .pyc contains location infos which will prevent python portable - StartInfo.ArgumentList.Add("-B"); + StartInfo.EnvironmentVariables["PYTHONDONTWRITEBYTECODE"] = "1"; } public override async Task InitAsync(PluginInitContext context) { - StartInfo.ArgumentList.Add(context.CurrentPluginMetadata.ExecuteFilePath); + // Run .py files via `-c ` + if (context.CurrentPluginMetadata.ExecuteFilePath.EndsWith(".py", StringComparison.OrdinalIgnoreCase)) + { + var rootDirectory = context.CurrentPluginMetadata.PluginDirectory; + var libDirectory = Path.Combine(rootDirectory, "lib"); + var libPyWin32Directory = Path.Combine(libDirectory, "win32"); + var libPyWin32LibDirectory = Path.Combine(libPyWin32Directory, "lib"); + var pluginDirectory = Path.Combine(rootDirectory, "plugin"); + var filePath = context.CurrentPluginMetadata.ExecuteFilePath; + + // This makes it easier for plugin authors to import their own modules. + // They won't have to add `.`, `./lib`, or `./plugin` to their sys.path manually. + // Instead of running the .py file directly, we pass the code we want to run as a CLI argument. + // This code sets sys.path for the plugin author and then runs the .py file via runpy. + StartInfo.ArgumentList.Add("-c"); + StartInfo.ArgumentList.Add( + $""" + import sys + sys.path.append(r'{rootDirectory}') + sys.path.append(r'{libDirectory}') + sys.path.append(r'{libPyWin32LibDirectory}') + sys.path.append(r'{libPyWin32Directory}') + sys.path.append(r'{pluginDirectory}') + + import runpy + runpy.run_path(r'{filePath}', None, '__main__') + """ + ); + } + // Run .pyz files as is + else + { + StartInfo.ArgumentList.Add(context.CurrentPluginMetadata.ExecuteFilePath); + } await base.InitAsync(context); } diff --git a/Flow.Launcher.Core/Plugin/QueryBuilder.cs b/Flow.Launcher.Core/Plugin/QueryBuilder.cs index 3dc7877ac..25a32a728 100644 --- a/Flow.Launcher.Core/Plugin/QueryBuilder.cs +++ b/Flow.Launcher.Core/Plugin/QueryBuilder.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Flow.Launcher.Plugin; @@ -8,10 +8,24 @@ namespace Flow.Launcher.Core.Plugin { public static Query Build(string text, Dictionary nonGlobalPlugins) { + // home query + if (string.IsNullOrEmpty(text)) + { + return new Query() + { + Search = string.Empty, + RawQuery = string.Empty, + SearchTerms = Array.Empty(), + ActionKeyword = string.Empty, + IsHomeQuery = true + }; + } + // replace multiple white spaces with one white space var terms = text.Split(Query.TermSeparator, StringSplitOptions.RemoveEmptyEntries); if (terms.Length == 0) - { // nothing was typed + { + // nothing was typed return null; } @@ -21,25 +35,28 @@ namespace Flow.Launcher.Core.Plugin string[] searchTerms; if (nonGlobalPlugins.TryGetValue(possibleActionKeyword, out var pluginPair) && !pluginPair.Metadata.Disabled) - { // use non global plugin for query + { + // use non global plugin for query actionKeyword = possibleActionKeyword; search = terms.Length > 1 ? rawQuery[(actionKeyword.Length + 1)..].TrimStart() : string.Empty; searchTerms = terms[1..]; } else - { // non action keyword + { + // non action keyword actionKeyword = string.Empty; search = rawQuery.TrimStart(); searchTerms = terms; } - return new Query () + return new Query() { Search = search, RawQuery = rawQuery, SearchTerms = searchTerms, - ActionKeyword = actionKeyword + ActionKeyword = actionKeyword, + IsHomeQuery = false }; } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Core/Resource/AvailableLanguages.cs b/Flow.Launcher.Core/Resource/AvailableLanguages.cs index 46c1ecb54..ecaecf646 100644 --- a/Flow.Launcher.Core/Resource/AvailableLanguages.cs +++ b/Flow.Launcher.Core/Resource/AvailableLanguages.cs @@ -28,7 +28,7 @@ namespace Flow.Launcher.Core.Resource public static Language Czech = new Language("cs", "čeština"); public static Language Arabic = new Language("ar", "اللغة العربية"); public static Language Vietnamese = new Language("vi-vn", "Tiếng Việt"); - + public static Language Hebrew = new Language("he", "עברית"); public static List GetAvailableLanguages() { @@ -57,9 +57,43 @@ namespace Flow.Launcher.Core.Resource Turkish, Czech, Arabic, - Vietnamese + Vietnamese, + Hebrew }; return languages; } + + public static string GetSystemTranslation(string languageCode) + { + return languageCode switch + { + "en" => "System", + "zh-cn" => "系统", + "zh-tw" => "系統", + "uk-UA" => "Система", + "ru" => "Система", + "fr" => "Système", + "ja" => "システム", + "nl" => "Systeem", + "pl" => "System", + "da" => "System", + "de" => "System", + "ko" => "시스템", + "sr" => "Систем", + "pt-pt" => "Sistema", + "pt-br" => "Sistema", + "es" => "Sistema", + "es-419" => "Sistema", + "it" => "Sistema", + "nb-NO" => "System", + "sk" => "Systém", + "tr" => "Sistem", + "cs" => "Systém", + "ar" => "النظام", + "vi-vn" => "Hệ thống", + "he" => "מערכת", + _ => "System", + }; + } } } diff --git a/Flow.Launcher.Core/Resource/Internationalization.cs b/Flow.Launcher.Core/Resource/Internationalization.cs index f6f35589d..b32b09e8f 100644 --- a/Flow.Launcher.Core/Resource/Internationalization.cs +++ b/Flow.Launcher.Core/Resource/Internationalization.cs @@ -6,39 +6,75 @@ using System.Reflection; using System.Windows; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using System.Globalization; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; namespace Flow.Launcher.Core.Resource { public class Internationalization { - public Settings Settings { get; set; } + private static readonly string ClassName = nameof(Internationalization); + + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); + private const string Folder = "Languages"; + private const string DefaultLanguageCode = "en"; private const string DefaultFile = "en.xaml"; private const string Extension = ".xaml"; - private readonly List _languageDirectories = new List(); - private readonly List _oldResources = new List(); + private readonly Settings _settings; + private readonly List _languageDirectories = new(); + private readonly List _oldResources = new(); + private readonly string SystemLanguageCode; - public Internationalization() + public Internationalization(Settings settings) { + _settings = settings; AddFlowLauncherLanguageDirectory(); + SystemLanguageCode = GetSystemLanguageCodeAtStartup(); } - private void AddFlowLauncherLanguageDirectory() { var directory = Path.Combine(Constant.ProgramDirectory, Folder); _languageDirectories.Add(directory); } - - internal void AddPluginLanguageDirectories(IEnumerable plugins) + private static string GetSystemLanguageCodeAtStartup() { - foreach (var plugin in plugins) + var availableLanguages = AvailableLanguages.GetAvailableLanguages(); + + // Retrieve the language identifiers for the current culture. + // ChangeLanguage method overrides the CultureInfo.CurrentCulture, so this needs to + // be called at startup in order to get the correct lang code of system. + var currentCulture = CultureInfo.CurrentCulture; + var twoLetterCode = currentCulture.TwoLetterISOLanguageName; + var threeLetterCode = currentCulture.ThreeLetterISOLanguageName; + var fullName = currentCulture.Name; + + // Try to find a match in the available languages list + foreach (var language in availableLanguages) + { + var languageCode = language.LanguageCode; + + if (string.Equals(languageCode, twoLetterCode, StringComparison.OrdinalIgnoreCase) || + string.Equals(languageCode, threeLetterCode, StringComparison.OrdinalIgnoreCase) || + string.Equals(languageCode, fullName, StringComparison.OrdinalIgnoreCase)) + { + return languageCode; + } + } + + return DefaultLanguageCode; + } + + private void AddPluginLanguageDirectories() + { + foreach (var plugin in PluginManager.GetPluginsForInterface()) { var location = Assembly.GetAssembly(plugin.Plugin.GetType()).Location; var dir = Path.GetDirectoryName(location); @@ -49,7 +85,7 @@ namespace Flow.Launcher.Core.Resource } else { - Log.Error($"|Internationalization.AddPluginLanguageDirectories|Can't find plugin path <{location}> for <{plugin.Metadata.Name}>"); + API.LogError(ClassName, $"Can't find plugin path <{location}> for <{plugin.Metadata.Name}>"); } } @@ -65,20 +101,61 @@ namespace Flow.Launcher.Core.Resource _oldResources.Clear(); } + /// + /// Initialize language. Will change app language and plugin language based on settings. + /// + public async Task InitializeLanguageAsync() + { + // Get actual language + var languageCode = _settings.Language; + if (languageCode == Constant.SystemLanguageCode) + { + languageCode = SystemLanguageCode; + } + + // Get language by language code and change language + var language = GetLanguageByLanguageCode(languageCode); + + // Add plugin language directories first so that we can load language files from plugins + AddPluginLanguageDirectories(); + + // Change language + await ChangeLanguageAsync(language); + } + + /// + /// Change language during runtime. Will change app language and plugin language & save settings. + /// + /// public void ChangeLanguage(string languageCode) { languageCode = languageCode.NonNull(); - Language language = GetLanguageByLanguageCode(languageCode); - ChangeLanguage(language); + + // Get actual language if language code is system + var isSystem = false; + if (languageCode == Constant.SystemLanguageCode) + { + languageCode = SystemLanguageCode; + isSystem = true; + } + + // Get language by language code and change language + var language = GetLanguageByLanguageCode(languageCode); + + // Change language + _ = ChangeLanguageAsync(language); + + // Save settings + _settings.Language = isSystem ? Constant.SystemLanguageCode : language.LanguageCode; } - private Language GetLanguageByLanguageCode(string languageCode) + private static Language GetLanguageByLanguageCode(string languageCode) { var lowercase = languageCode.ToLower(); var language = AvailableLanguages.GetAvailableLanguages().FirstOrDefault(o => o.LanguageCode.ToLower() == lowercase); if (language == null) { - Log.Error($"|Internationalization.GetLanguageByLanguageCode|Language code can't be found <{languageCode}>"); + API.LogError(ClassName, $"Language code can't be found <{languageCode}>"); return AvailableLanguages.English; } else @@ -87,34 +164,29 @@ namespace Flow.Launcher.Core.Resource } } - public void ChangeLanguage(Language language) + private async Task ChangeLanguageAsync(Language language) { - language = language.NonNull(); - - + // Remove old language files and load language RemoveOldLanguageFiles(); if (language != AvailableLanguages.English) { LoadLanguage(language); } + // Culture of main thread // Use CreateSpecificCulture to preserve possible user-override settings in Windows, if Flow's language culture is the same as Windows's CultureInfo.CurrentCulture = CultureInfo.CreateSpecificCulture(language.LanguageCode); CultureInfo.CurrentUICulture = CultureInfo.CurrentCulture; - // Raise event after culture is set - Settings.Language = language.LanguageCode; - _ = Task.Run(() => - { - UpdatePluginMetadataTranslations(); - }); + // Raise event for plugins after culture is set + await Task.Run(UpdatePluginMetadataTranslations); } public bool PromptShouldUsePinyin(string languageCodeToSet) { var languageToSet = GetLanguageByLanguageCode(languageCodeToSet); - if (Settings.ShouldUsePinyin) + if (_settings.ShouldUsePinyin) return false; if (languageToSet != AvailableLanguages.Chinese && languageToSet != AvailableLanguages.Chinese_TW) @@ -124,7 +196,7 @@ namespace Flow.Launcher.Core.Resource // "Do you want to search with pinyin?" string text = languageToSet == AvailableLanguages.Chinese ? "是否启用拼音搜索?" : "是否啓用拼音搜索?" ; - if (MessageBox.Show(text, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.No) + if (Ioc.Default.GetRequiredService().ShowMsgBox(text, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.No) return false; return true; @@ -167,10 +239,12 @@ namespace Flow.Launcher.Core.Resource public List LoadAvailableLanguages() { - return AvailableLanguages.GetAvailableLanguages(); + var list = AvailableLanguages.GetAvailableLanguages(); + list.Insert(0, new Language(Constant.SystemLanguageCode, AvailableLanguages.GetSystemTranslation(SystemLanguageCode))); + return list; } - public string GetTranslation(string key) + public static string GetTranslation(string key) { var translation = Application.Current.TryFindResource(key); if (translation is string) @@ -179,7 +253,7 @@ namespace Flow.Launcher.Core.Resource } else { - Log.Error($"|Internationalization.GetTranslation|No Translation for key {key}"); + API.LogError(ClassName, $"No Translation for key {key}"); return $"No Translation for key {key}"; } } @@ -188,8 +262,7 @@ namespace Flow.Launcher.Core.Resource { foreach (var p in PluginManager.GetPluginsForInterface()) { - var pluginI18N = p.Plugin as IPluginI18n; - if (pluginI18N == null) return; + if (p.Plugin is not IPluginI18n pluginI18N) return; try { p.Metadata.Name = pluginI18N.GetTranslatedPluginTitle(); @@ -198,31 +271,31 @@ namespace Flow.Launcher.Core.Resource } catch (Exception e) { - Log.Exception($"|Internationalization.UpdatePluginMetadataTranslations|Failed for <{p.Metadata.Name}>", e); + API.LogException(ClassName, $"Failed for <{p.Metadata.Name}>", e); } } } - public string LanguageFile(string folder, string language) + private static string LanguageFile(string folder, string language) { if (Directory.Exists(folder)) { - string path = Path.Combine(folder, language); + var path = Path.Combine(folder, language); if (File.Exists(path)) { return path; } else { - Log.Error($"|Internationalization.LanguageFile|Language path can't be found <{path}>"); - string english = Path.Combine(folder, DefaultFile); + API.LogError(ClassName, $"Language path can't be found <{path}>"); + var english = Path.Combine(folder, DefaultFile); if (File.Exists(english)) { return english; } else { - Log.Error($"|Internationalization.LanguageFile|Default English Language path can't be found <{path}>"); + API.LogError(ClassName, $"Default English Language path can't be found <{path}>"); return string.Empty; } } diff --git a/Flow.Launcher.Core/Resource/InternationalizationManager.cs b/Flow.Launcher.Core/Resource/InternationalizationManager.cs deleted file mode 100644 index 3d87626e6..000000000 --- a/Flow.Launcher.Core/Resource/InternationalizationManager.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Flow.Launcher.Core.Resource -{ - public static class InternationalizationManager - { - private static Internationalization instance; - private static object syncObject = new object(); - - public static Internationalization Instance - { - get - { - if (instance == null) - { - lock (syncObject) - { - if (instance == null) - { - instance = new Internationalization(); - } - } - } - return instance; - } - } - } -} \ No newline at end of file diff --git a/Flow.Launcher.Core/Resource/LocalizationConverter.cs b/Flow.Launcher.Core/Resource/LocalizationConverter.cs index 81600e023..fdda33926 100644 --- a/Flow.Launcher.Core/Resource/LocalizationConverter.cs +++ b/Flow.Launcher.Core/Resource/LocalizationConverter.cs @@ -6,6 +6,7 @@ using System.Windows.Data; namespace Flow.Launcher.Core.Resource { + [Obsolete("LocalizationConverter is obsolete. Use with Flow.Launcher.Localization NuGet package instead.")] public class LocalizationConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) diff --git a/Flow.Launcher.Core/Resource/LocalizedDescriptionAttribute.cs b/Flow.Launcher.Core/Resource/LocalizedDescriptionAttribute.cs index 52a232334..3e1a19a76 100644 --- a/Flow.Launcher.Core/Resource/LocalizedDescriptionAttribute.cs +++ b/Flow.Launcher.Core/Resource/LocalizedDescriptionAttribute.cs @@ -1,15 +1,19 @@ using System.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.Resource { public class LocalizedDescriptionAttribute : DescriptionAttribute { - private readonly Internationalization _translator; + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); + private readonly string _resourceKey; public LocalizedDescriptionAttribute(string resourceKey) { - _translator = InternationalizationManager.Instance; _resourceKey = resourceKey; } @@ -17,7 +21,7 @@ namespace Flow.Launcher.Core.Resource { get { - string description = _translator.GetTranslation(_resourceKey); + string description = API.GetTranslation(_resourceKey); return string.IsNullOrWhiteSpace(description) ? string.Format("[[{0}]]", _resourceKey) : description; } diff --git a/Flow.Launcher.Core/Resource/Theme.cs b/Flow.Launcher.Core/Resource/Theme.cs index c3a3e9891..059359694 100644 --- a/Flow.Launcher.Core/Resource/Theme.cs +++ b/Flow.Launcher.Core/Resource/Theme.cs @@ -3,63 +3,86 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Xml; -using System.Runtime.InteropServices; +using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; -using System.Windows.Interop; +using System.Windows.Controls.Primitives; using System.Windows.Markup; using System.Windows.Media; using System.Windows.Media.Effects; +using System.Windows.Shell; +using System.Windows.Threading; using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedModels; +using Microsoft.Win32; namespace Flow.Launcher.Core.Resource { public class Theme { + #region Properties & Fields + + private readonly string ClassName = nameof(Theme); + + public bool BlurEnabled { get; private set; } + private const string ThemeMetadataNamePrefix = "Name:"; private const string ThemeMetadataIsDarkPrefix = "IsDark:"; private const string ThemeMetadataHasBlurPrefix = "HasBlur:"; private const int ShadowExtraMargin = 32; - private readonly List _themeDirectories = new List(); + private readonly IPublicAPI _api; + private readonly Settings _settings; + private readonly List _themeDirectories = new(); private ResourceDictionary _oldResource; private string _oldTheme; - public Settings Settings { get; set; } private const string Folder = Constant.Themes; private const string Extension = ".xaml"; - private string DirectoryPath => Path.Combine(Constant.ProgramDirectory, Folder); - private string UserDirectoryPath => Path.Combine(DataLocation.DataDirectory(), Folder); + private static string DirectoryPath => Path.Combine(Constant.ProgramDirectory, Folder); + private static string UserDirectoryPath => Path.Combine(DataLocation.DataDirectory(), Folder); - public bool BlurEnabled { get; set; } + private Thickness _themeResizeBorderThickness; - private double mainWindowWidth; + #endregion - public Theme() + #region Constructor + + public Theme(IPublicAPI publicAPI, Settings settings) { + _api = publicAPI; + _settings = settings; + _themeDirectories.Add(DirectoryPath); _themeDirectories.Add(UserDirectoryPath); MakeSureThemeDirectoriesExist(); var dicts = Application.Current.Resources.MergedDictionaries; - _oldResource = dicts.First(d => + _oldResource = dicts.FirstOrDefault(d => { - if (d.Source == null) - return false; + if (d.Source == null) return false; var p = d.Source.AbsolutePath; - var dir = Path.GetDirectoryName(p).NonNull(); - var info = new DirectoryInfo(dir); - var f = info.Name; - var e = Path.GetExtension(p); - var found = f == Folder && e == Extension; - return found; + return p.Contains(Folder) && Path.GetExtension(p) == Extension; }); - _oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath); + + if (_oldResource != null) + { + _oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath); + } + else + { + _api.LogError(ClassName, "Current theme resource not found. Initializing with default theme."); + _oldTheme = Constant.DefaultTheme; + } } + #endregion + + #region Theme Resources + private void MakeSureThemeDirectoriesExist() { foreach (var dir in _themeDirectories.Where(dir => !Directory.Exists(dir))) @@ -70,73 +93,159 @@ namespace Flow.Launcher.Core.Resource } catch (Exception e) { - Log.Exception($"|Theme.MakesureThemeDirectoriesExist|Exception when create directory <{dir}>", e); + _api.LogException(ClassName, $"Exception when create directory <{dir}>", e); } } } - public bool ChangeTheme(string theme) - { - const string defaultTheme = Constant.DefaultTheme; - - string path = GetThemePath(theme); - try - { - if (string.IsNullOrEmpty(path)) - throw new DirectoryNotFoundException("Theme path can't be found <{path}>"); - - // reload all resources even if the theme itself hasn't changed in order to pickup changes - // to things like fonts - UpdateResourceDictionary(GetResourceDictionary(theme)); - - Settings.Theme = theme; - - - //always allow re-loading default theme, in case of failure of switching to a new theme from default theme - if (_oldTheme != theme || theme == defaultTheme) - { - _oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath); - } - - BlurEnabled = IsBlurTheme(); - - if (Settings.UseDropShadowEffect && !BlurEnabled) - AddDropShadowEffectToCurrentTheme(); - - SetBlurForWindow(); - } - catch (DirectoryNotFoundException) - { - Log.Error($"|Theme.ChangeTheme|Theme <{theme}> path can't be found"); - if (theme != defaultTheme) - { - MessageBox.Show(string.Format(InternationalizationManager.Instance.GetTranslation("theme_load_failure_path_not_exists"), theme)); - ChangeTheme(defaultTheme); - } - return false; - } - catch (XamlParseException) - { - Log.Error($"|Theme.ChangeTheme|Theme <{theme}> fail to parse"); - if (theme != defaultTheme) - { - MessageBox.Show(string.Format(InternationalizationManager.Instance.GetTranslation("theme_load_failure_parse_error"), theme)); - ChangeTheme(defaultTheme); - } - return false; - } - return true; - } - private void UpdateResourceDictionary(ResourceDictionary dictionaryToUpdate) { - var dicts = Application.Current.Resources.MergedDictionaries; + // Add new resources + if (!Application.Current.Resources.MergedDictionaries.Contains(dictionaryToUpdate)) + { + Application.Current.Resources.MergedDictionaries.Add(dictionaryToUpdate); + } + + // Remove old resources + if (_oldResource != null && _oldResource != dictionaryToUpdate && + Application.Current.Resources.MergedDictionaries.Contains(_oldResource)) + { + Application.Current.Resources.MergedDictionaries.Remove(_oldResource); + } - dicts.Remove(_oldResource); - dicts.Add(dictionaryToUpdate); _oldResource = dictionaryToUpdate; } + /// + /// Updates only the font settings and refreshes the UI. + /// + public void UpdateFonts() + { + try + { + // Load a ResourceDictionary for the specified theme. + var themeName = _settings.Theme; + var dict = GetThemeResourceDictionary(themeName); + + // Apply font settings to the theme resource. + ApplyFontSettings(dict); + UpdateResourceDictionary(dict); + + // Must apply blur and drop shadow effects + _ = RefreshFrameAsync(); + } + catch (Exception e) + { + _api.LogException(ClassName, "Error occurred while updating theme fonts", e); + } + } + + /// + /// Loads and applies font settings to the theme resource. + /// + private void ApplyFontSettings(ResourceDictionary dict) + { + if (dict["QueryBoxStyle"] is Style queryBoxStyle && + dict["QuerySuggestionBoxStyle"] is Style querySuggestionBoxStyle) + { + var fontFamily = new FontFamily(_settings.QueryBoxFont); + var fontStyle = FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.QueryBoxFontStyle); + var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.QueryBoxFontWeight); + var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.QueryBoxFontStretch); + + SetFontProperties(queryBoxStyle, fontFamily, fontStyle, fontWeight, fontStretch, true); + SetFontProperties(querySuggestionBoxStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); + } + + if (dict["ItemTitleStyle"] is Style resultItemStyle && + dict["ItemTitleSelectedStyle"] is Style resultItemSelectedStyle && + dict["ItemHotkeyStyle"] is Style resultHotkeyItemStyle && + dict["ItemHotkeySelectedStyle"] is Style resultHotkeyItemSelectedStyle) + { + var fontFamily = new FontFamily(_settings.ResultFont); + var fontStyle = FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.ResultFontStyle); + var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.ResultFontWeight); + var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.ResultFontStretch); + + SetFontProperties(resultItemStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); + SetFontProperties(resultItemSelectedStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); + SetFontProperties(resultHotkeyItemStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); + SetFontProperties(resultHotkeyItemSelectedStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); + } + + if (dict["ItemSubTitleStyle"] is Style resultSubItemStyle && + dict["ItemSubTitleSelectedStyle"] is Style resultSubItemSelectedStyle) + { + var fontFamily = new FontFamily(_settings.ResultSubFont); + var fontStyle = FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.ResultSubFontStyle); + var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.ResultSubFontWeight); + var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.ResultSubFontStretch); + + SetFontProperties(resultSubItemStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); + SetFontProperties(resultSubItemSelectedStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); + } + } + + /// + /// Applies font properties to a Style. + /// + private static void SetFontProperties(Style style, FontFamily fontFamily, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, bool isTextBox) + { + // Remove existing font-related setters + if (isTextBox) + { + // First, find the setters to remove and store them in a list + var settersToRemove = style.Setters + .OfType() + .Where(setter => + setter.Property == Control.FontFamilyProperty || + setter.Property == Control.FontStyleProperty || + setter.Property == Control.FontWeightProperty || + setter.Property == Control.FontStretchProperty) + .ToList(); + + // Remove each found setter one by one + foreach (var setter in settersToRemove) + { + style.Setters.Remove(setter); + } + + // Add New font setter + style.Setters.Add(new Setter(Control.FontFamilyProperty, fontFamily)); + style.Setters.Add(new Setter(Control.FontStyleProperty, fontStyle)); + style.Setters.Add(new Setter(Control.FontWeightProperty, fontWeight)); + style.Setters.Add(new Setter(Control.FontStretchProperty, fontStretch)); + + // Set caret brush (retain existing logic) + var caretBrushPropertyValue = style.Setters.OfType().Any(x => x.Property.Name == "CaretBrush"); + var foregroundPropertyValue = style.Setters.OfType().Where(x => x.Property.Name == "Foreground") + .Select(x => x.Value).FirstOrDefault(); + if (!caretBrushPropertyValue && foregroundPropertyValue != null) + style.Setters.Add(new Setter(TextBoxBase.CaretBrushProperty, foregroundPropertyValue)); + } + else + { + var settersToRemove = style.Setters + .OfType() + .Where(setter => + setter.Property == TextBlock.FontFamilyProperty || + setter.Property == TextBlock.FontStyleProperty || + setter.Property == TextBlock.FontWeightProperty || + setter.Property == TextBlock.FontStretchProperty) + .ToList(); + + foreach (var setter in settersToRemove) + { + style.Setters.Remove(setter); + } + + style.Setters.Add(new Setter(TextBlock.FontFamilyProperty, fontFamily)); + style.Setters.Add(new Setter(TextBlock.FontStyleProperty, fontStyle)); + style.Setters.Add(new Setter(TextBlock.FontWeightProperty, fontWeight)); + style.Setters.Add(new Setter(TextBlock.FontStretchProperty, fontStretch)); + } + } + private ResourceDictionary GetThemeResourceDictionary(string theme) { var uri = GetThemePath(theme); @@ -148,36 +257,34 @@ namespace Flow.Launcher.Core.Resource return dict; } - private ResourceDictionary CurrentThemeResourceDictionary() => GetThemeResourceDictionary(Settings.Theme); - - public ResourceDictionary GetResourceDictionary(string theme) + private ResourceDictionary GetResourceDictionary(string theme) { var dict = GetThemeResourceDictionary(theme); if (dict["QueryBoxStyle"] is Style queryBoxStyle && dict["QuerySuggestionBoxStyle"] is Style querySuggestionBoxStyle) { - var fontFamily = new FontFamily(Settings.QueryBoxFont); - var fontStyle = FontHelper.GetFontStyleFromInvariantStringOrNormal(Settings.QueryBoxFontStyle); - var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(Settings.QueryBoxFontWeight); - var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(Settings.QueryBoxFontStretch); + var fontFamily = new FontFamily(_settings.QueryBoxFont); + var fontStyle = FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.QueryBoxFontStyle); + var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.QueryBoxFontWeight); + var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.QueryBoxFontStretch); - queryBoxStyle.Setters.Add(new Setter(TextBox.FontFamilyProperty, fontFamily)); - queryBoxStyle.Setters.Add(new Setter(TextBox.FontStyleProperty, fontStyle)); - queryBoxStyle.Setters.Add(new Setter(TextBox.FontWeightProperty, fontWeight)); - queryBoxStyle.Setters.Add(new Setter(TextBox.FontStretchProperty, fontStretch)); + queryBoxStyle.Setters.Add(new Setter(Control.FontFamilyProperty, fontFamily)); + queryBoxStyle.Setters.Add(new Setter(Control.FontStyleProperty, fontStyle)); + queryBoxStyle.Setters.Add(new Setter(Control.FontWeightProperty, fontWeight)); + queryBoxStyle.Setters.Add(new Setter(Control.FontStretchProperty, fontStretch)); var caretBrushPropertyValue = queryBoxStyle.Setters.OfType().Any(x => x.Property.Name == "CaretBrush"); var foregroundPropertyValue = queryBoxStyle.Setters.OfType().Where(x => x.Property.Name == "Foreground") .Select(x => x.Value).FirstOrDefault(); if (!caretBrushPropertyValue && foregroundPropertyValue != null) //otherwise BaseQueryBoxStyle will handle styling - queryBoxStyle.Setters.Add(new Setter(TextBox.CaretBrushProperty, foregroundPropertyValue)); + queryBoxStyle.Setters.Add(new Setter(TextBoxBase.CaretBrushProperty, foregroundPropertyValue)); // Query suggestion box's font style is aligned with query box - querySuggestionBoxStyle.Setters.Add(new Setter(TextBox.FontFamilyProperty, fontFamily)); - querySuggestionBoxStyle.Setters.Add(new Setter(TextBox.FontStyleProperty, fontStyle)); - querySuggestionBoxStyle.Setters.Add(new Setter(TextBox.FontWeightProperty, fontWeight)); - querySuggestionBoxStyle.Setters.Add(new Setter(TextBox.FontStretchProperty, fontStretch)); + querySuggestionBoxStyle.Setters.Add(new Setter(Control.FontFamilyProperty, fontFamily)); + querySuggestionBoxStyle.Setters.Add(new Setter(Control.FontStyleProperty, fontStyle)); + querySuggestionBoxStyle.Setters.Add(new Setter(Control.FontWeightProperty, fontWeight)); + querySuggestionBoxStyle.Setters.Add(new Setter(Control.FontStretchProperty, fontStretch)); } if (dict["ItemTitleStyle"] is Style resultItemStyle && @@ -185,10 +292,10 @@ namespace Flow.Launcher.Core.Resource dict["ItemHotkeyStyle"] is Style resultHotkeyItemStyle && dict["ItemHotkeySelectedStyle"] is Style resultHotkeyItemSelectedStyle) { - Setter fontFamily = new Setter(TextBlock.FontFamilyProperty, new FontFamily(Settings.ResultFont)); - Setter fontStyle = new Setter(TextBlock.FontStyleProperty, FontHelper.GetFontStyleFromInvariantStringOrNormal(Settings.ResultFontStyle)); - Setter fontWeight = new Setter(TextBlock.FontWeightProperty, FontHelper.GetFontWeightFromInvariantStringOrNormal(Settings.ResultFontWeight)); - Setter fontStretch = new Setter(TextBlock.FontStretchProperty, FontHelper.GetFontStretchFromInvariantStringOrNormal(Settings.ResultFontStretch)); + Setter fontFamily = new Setter(TextBlock.FontFamilyProperty, new FontFamily(_settings.ResultFont)); + Setter fontStyle = new Setter(TextBlock.FontStyleProperty, FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.ResultFontStyle)); + Setter fontWeight = new Setter(TextBlock.FontWeightProperty, FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.ResultFontWeight)); + Setter fontStretch = new Setter(TextBlock.FontStretchProperty, FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.ResultFontStretch)); Setter[] setters = { fontFamily, fontStyle, fontWeight, fontStretch }; Array.ForEach( @@ -200,43 +307,27 @@ namespace Flow.Launcher.Core.Resource dict["ItemSubTitleStyle"] is Style resultSubItemStyle && dict["ItemSubTitleSelectedStyle"] is Style resultSubItemSelectedStyle) { - Setter fontFamily = new Setter(TextBlock.FontFamilyProperty, new FontFamily(Settings.ResultSubFont)); - Setter fontStyle = new Setter(TextBlock.FontStyleProperty, FontHelper.GetFontStyleFromInvariantStringOrNormal(Settings.ResultSubFontStyle)); - Setter fontWeight = new Setter(TextBlock.FontWeightProperty, FontHelper.GetFontWeightFromInvariantStringOrNormal(Settings.ResultSubFontWeight)); - Setter fontStretch = new Setter(TextBlock.FontStretchProperty, FontHelper.GetFontStretchFromInvariantStringOrNormal(Settings.ResultSubFontStretch)); + Setter fontFamily = new Setter(TextBlock.FontFamilyProperty, new FontFamily(_settings.ResultSubFont)); + Setter fontStyle = new Setter(TextBlock.FontStyleProperty, FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.ResultSubFontStyle)); + Setter fontWeight = new Setter(TextBlock.FontWeightProperty, FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.ResultSubFontWeight)); + Setter fontStretch = new Setter(TextBlock.FontStretchProperty, FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.ResultSubFontStretch)); Setter[] setters = { fontFamily, fontStyle, fontWeight, fontStretch }; Array.ForEach( - new[] { resultSubItemStyle,resultSubItemSelectedStyle}, o + new[] { resultSubItemStyle, resultSubItemSelectedStyle }, o => Array.ForEach(setters, p => o.Setters.Add(p))); } /* Ignore Theme Window Width and use setting */ var windowStyle = dict["WindowStyle"] as Style; - var width = Settings.WindowSize; - windowStyle.Setters.Add(new Setter(Window.WidthProperty, width)); - mainWindowWidth = (double)width; + var width = _settings.WindowSize; + windowStyle.Setters.Add(new Setter(FrameworkElement.WidthProperty, width)); return dict; } - private ResourceDictionary GetCurrentResourceDictionary( ) + public ResourceDictionary GetCurrentResourceDictionary() { - return GetResourceDictionary(Settings.Theme); - } - - public List LoadAvailableThemes() - { - List themes = new List(); - foreach (var themeDirectory in _themeDirectories) - { - var filePaths = Directory - .GetFiles(themeDirectory) - .Where(filePath => filePath.EndsWith(Extension) && !filePath.EndsWith("Base.xaml")) - .Select(GetThemeDataFromPath); - themes.AddRange(filePaths); - } - - return themes.OrderBy(o => o.Name).ToList(); + return GetResourceDictionary(_settings.Theme); } private ThemeData GetThemeDataFromPath(string path) @@ -258,15 +349,15 @@ namespace Flow.Launcher.Core.Resource { if (line.StartsWith(ThemeMetadataNamePrefix, StringComparison.OrdinalIgnoreCase)) { - name = line.Remove(0, ThemeMetadataNamePrefix.Length).Trim(); + name = line[ThemeMetadataNamePrefix.Length..].Trim(); } else if (line.StartsWith(ThemeMetadataIsDarkPrefix, StringComparison.OrdinalIgnoreCase)) { - isDark = bool.Parse(line.Remove(0, ThemeMetadataIsDarkPrefix.Length).Trim()); + isDark = bool.Parse(line[ThemeMetadataIsDarkPrefix.Length..].Trim()); } else if (line.StartsWith(ThemeMetadataHasBlurPrefix, StringComparison.OrdinalIgnoreCase)) { - hasBlur = bool.Parse(line.Remove(0, ThemeMetadataHasBlurPrefix.Length).Trim()); + hasBlur = bool.Parse(line[ThemeMetadataHasBlurPrefix.Length..].Trim()); } } @@ -287,6 +378,93 @@ namespace Flow.Launcher.Core.Resource return string.Empty; } + #endregion + + #region Get & Change Theme + + public ThemeData GetCurrentTheme() + { + var themes = GetAvailableThemes(); + var matchingTheme = themes.FirstOrDefault(t => t.FileNameWithoutExtension == _settings.Theme); + if (matchingTheme == null) + { + _api.LogWarn(ClassName, $"No matching theme found for '{_settings.Theme}'. Falling back to the first available theme."); + } + return matchingTheme ?? themes.FirstOrDefault(); + } + + public List GetAvailableThemes() + { + List themes = new List(); + foreach (var themeDirectory in _themeDirectories) + { + var filePaths = Directory + .GetFiles(themeDirectory) + .Where(filePath => filePath.EndsWith(Extension) && !filePath.EndsWith("Base.xaml")) + .Select(GetThemeDataFromPath); + themes.AddRange(filePaths); + } + + return themes.OrderBy(o => o.Name).ToList(); + } + + public bool ChangeTheme(string theme = null) + { + if (string.IsNullOrEmpty(theme)) + theme = _settings.Theme; + + string path = GetThemePath(theme); + try + { + if (string.IsNullOrEmpty(path)) + throw new DirectoryNotFoundException($"Theme path can't be found <{path}>"); + + // Retrieve theme resource – always use the resource with font settings applied. + var resourceDict = GetResourceDictionary(theme); + + UpdateResourceDictionary(resourceDict); + + _settings.Theme = theme; + + //always allow re-loading default theme, in case of failure of switching to a new theme from default theme + if (_oldTheme != theme || theme == Constant.DefaultTheme) + { + _oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath); + } + + BlurEnabled = IsBlurTheme(); + + // Apply blur and drop shadow effect so that we do not need to call it again + _ = RefreshFrameAsync(); + + return true; + } + catch (DirectoryNotFoundException) + { + _api.LogError(ClassName, $"Theme <{theme}> path can't be found"); + if (theme != Constant.DefaultTheme) + { + _api.ShowMsgBox(string.Format(_api.GetTranslation("theme_load_failure_path_not_exists"), theme)); + ChangeTheme(Constant.DefaultTheme); + } + return false; + } + catch (XamlParseException) + { + _api.LogError(ClassName, $"Theme <{theme}> fail to parse"); + if (theme != Constant.DefaultTheme) + { + _api.ShowMsgBox(string.Format(_api.GetTranslation("theme_load_failure_parse_error"), theme)); + ChangeTheme(Constant.DefaultTheme); + } + return false; + } + } + + #endregion + + #region Shadow Effect + public void AddDropShadowEffectToCurrentTheme() { var dict = GetCurrentResourceDictionary(); @@ -295,7 +473,7 @@ namespace Flow.Launcher.Core.Resource var effectSetter = new Setter { - Property = Border.EffectProperty, + Property = UIElement.EffectProperty, Value = new DropShadowEffect { Opacity = 0.3, @@ -305,15 +483,17 @@ namespace Flow.Launcher.Core.Resource } }; - var marginSetter = windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == Border.MarginProperty) as Setter; - if (marginSetter == null) + if (windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == FrameworkElement.MarginProperty) is not Setter marginSetter) { + var margin = new Thickness(ShadowExtraMargin, 12, ShadowExtraMargin, ShadowExtraMargin); marginSetter = new Setter() { - Property = Border.MarginProperty, - Value = new Thickness(ShadowExtraMargin, 12, ShadowExtraMargin, ShadowExtraMargin), + Property = FrameworkElement.MarginProperty, + Value = margin, }; windowBorderStyle.Setters.Add(marginSetter); + + SetResizeBoarderThickness(margin); } else { @@ -324,6 +504,8 @@ namespace Flow.Launcher.Core.Resource baseMargin.Right + ShadowExtraMargin, baseMargin.Bottom + ShadowExtraMargin); marginSetter.Value = newMargin; + + SetResizeBoarderThickness(newMargin); } windowBorderStyle.Setters.Add(effectSetter); @@ -336,14 +518,12 @@ namespace Flow.Launcher.Core.Resource var dict = GetCurrentResourceDictionary(); var windowBorderStyle = dict["WindowBorderStyle"] as Style; - var effectSetter = windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == Border.EffectProperty) as Setter; - var marginSetter = windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == Border.MarginProperty) as Setter; - - if (effectSetter != null) + if (windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == UIElement.EffectProperty) is Setter effectSetter) { windowBorderStyle.Setters.Remove(effectSetter); } - if (marginSetter != null) + + if (windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == FrameworkElement.MarginProperty) is Setter marginSetter) { var currentMargin = (Thickness)marginSetter.Value; var newMargin = new Thickness( @@ -354,101 +534,390 @@ namespace Flow.Launcher.Core.Resource marginSetter.Value = newMargin; } + SetResizeBoarderThickness(null); + UpdateResourceDictionary(dict); } + public void SetResizeBorderThickness(WindowChrome windowChrome, bool fixedWindowSize) + { + if (fixedWindowSize) + { + windowChrome.ResizeBorderThickness = new Thickness(0); + } + else + { + windowChrome.ResizeBorderThickness = _themeResizeBorderThickness; + } + } + + // because adding drop shadow effect will change the margin of the window, + // we need to update the window chrome thickness to correct set the resize border + private void SetResizeBoarderThickness(Thickness? effectMargin) + { + var window = Application.Current.MainWindow; + if (WindowChrome.GetWindowChrome(window) is WindowChrome windowChrome) + { + // Save the theme resize border thickness so that we can restore it if we change ResizeWindow setting + if (effectMargin == null) + { + _themeResizeBorderThickness = SystemParameters.WindowResizeBorderThickness; + } + else + { + _themeResizeBorderThickness = new Thickness( + effectMargin.Value.Left + SystemParameters.WindowResizeBorderThickness.Left, + effectMargin.Value.Top + SystemParameters.WindowResizeBorderThickness.Top, + effectMargin.Value.Right + SystemParameters.WindowResizeBorderThickness.Right, + effectMargin.Value.Bottom + SystemParameters.WindowResizeBorderThickness.Bottom); + } + + // Apply the resize border thickness to the window chrome + SetResizeBorderThickness(windowChrome, _settings.KeepMaxResults); + } + } + + #endregion + #region Blur Handling - /* - Found on https://github.com/riverar/sample-win10-aeroglass - */ - private enum AccentState - { - ACCENT_DISABLED = 0, - ACCENT_ENABLE_GRADIENT = 1, - ACCENT_ENABLE_TRANSPARENTGRADIENT = 2, - ACCENT_ENABLE_BLURBEHIND = 3, - ACCENT_INVALID_STATE = 4 - } - [StructLayout(LayoutKind.Sequential)] - private struct AccentPolicy + /// + /// Refreshes the frame to apply the current theme settings. + /// + public async Task RefreshFrameAsync() { - public AccentState AccentState; - public int AccentFlags; - public int GradientColor; - public int AnimationId; - } + await Application.Current.Dispatcher.InvokeAsync(() => + { + // Get the actual backdrop type and drop shadow effect settings + var (backdropType, useDropShadowEffect) = GetActualValue(); - [StructLayout(LayoutKind.Sequential)] - private struct WindowCompositionAttributeData - { - public WindowCompositionAttribute Attribute; - public IntPtr Data; - public int SizeOfData; - } + // Remove OS minimizing/maximizing animation + // Methods.SetWindowAttribute(new WindowInteropHelper(mainWindow).Handle, DWMWINDOWATTRIBUTE.DWMWA_TRANSITIONS_FORCEDISABLED, 3); - private enum WindowCompositionAttribute - { - WCA_ACCENT_POLICY = 19 + // The timing of adding the shadow effect should vary depending on whether the theme is transparent. + if (BlurEnabled) + { + AutoDropShadow(useDropShadowEffect); + } + SetBlurForWindow(_settings.Theme, backdropType); + + if (!BlurEnabled) + { + AutoDropShadow(useDropShadowEffect); + } + }, DispatcherPriority.Render); } - [DllImport("user32.dll")] - private static extern int SetWindowCompositionAttribute(IntPtr hwnd, ref WindowCompositionAttributeData data); /// /// Sets the blur for a window via SetWindowCompositionAttribute /// - public void SetBlurForWindow() + public async Task SetBlurForWindowAsync() { + await Application.Current.Dispatcher.InvokeAsync(() => + { + // Get the actual backdrop type and drop shadow effect settings + var (backdropType, _) = GetActualValue(); + + SetBlurForWindow(_settings.Theme, backdropType); + }, DispatcherPriority.Render); + } + + /// + /// Gets the actual backdrop type and drop shadow effect settings based on the current theme status. + /// + public (BackdropTypes BackdropType, bool UseDropShadowEffect) GetActualValue() + { + var backdropType = _settings.BackdropType; + var useDropShadowEffect = _settings.UseDropShadowEffect; + + // When changed non-blur theme, change to backdrop to none + if (!BlurEnabled) + { + backdropType = BackdropTypes.None; + } + + // Dropshadow on and control disabled.(user can't change dropshadow with blur theme) if (BlurEnabled) { - SetWindowAccent(Application.Current.MainWindow, AccentState.ACCENT_ENABLE_BLURBEHIND); + useDropShadowEffect = true; + } + + return (backdropType, useDropShadowEffect); + } + + private void SetBlurForWindow(string theme, BackdropTypes backdropType) + { + var dict = GetResourceDictionary(theme); + if (dict == null) return; + + var windowBorderStyle = dict.Contains("WindowBorderStyle") ? dict["WindowBorderStyle"] as Style : null; + if (windowBorderStyle == null) return; + + var mainWindow = Application.Current.MainWindow; + if (mainWindow == null) return; + + // Check if the theme supports blur + bool hasBlur = dict.Contains("ThemeBlurEnabled") && dict["ThemeBlurEnabled"] is bool b && b; + if (BlurEnabled && hasBlur && Win32Helper.IsBackdropSupported()) + { + // If the BackdropType is Mica or MicaAlt, set the windowborderstyle's background to transparent + if (backdropType == BackdropTypes.Mica || backdropType == BackdropTypes.MicaAlt) + { + windowBorderStyle.Setters.Remove(windowBorderStyle.Setters.OfType().FirstOrDefault(x => x.Property.Name == "Background")); + windowBorderStyle.Setters.Add(new Setter(Border.BackgroundProperty, new SolidColorBrush(Color.FromArgb(1, 0, 0, 0)))); + } + else if (backdropType == BackdropTypes.Acrylic) + { + windowBorderStyle.Setters.Remove(windowBorderStyle.Setters.OfType().FirstOrDefault(x => x.Property.Name == "Background")); + windowBorderStyle.Setters.Add(new Setter(Border.BackgroundProperty, new SolidColorBrush(Colors.Transparent))); + } + + // Apply the blur effect + Win32Helper.DWMSetBackdropForWindow(mainWindow, backdropType); + ColorizeWindow(theme, backdropType); } else { - SetWindowAccent(Application.Current.MainWindow, AccentState.ACCENT_DISABLED); + // Apply default style when Blur is disabled + Win32Helper.DWMSetBackdropForWindow(mainWindow, BackdropTypes.None); + ColorizeWindow(theme, backdropType); + } + + UpdateResourceDictionary(dict); + } + + private void AutoDropShadow(bool useDropShadowEffect) + { + SetWindowCornerPreference("Default"); + RemoveDropShadowEffectFromCurrentTheme(); + if (useDropShadowEffect) + { + if (BlurEnabled && Win32Helper.IsBackdropSupported()) + { + SetWindowCornerPreference("Round"); + } + else + { + SetWindowCornerPreference("Default"); + AddDropShadowEffectToCurrentTheme(); + } + } + else + { + if (BlurEnabled && Win32Helper.IsBackdropSupported()) + { + SetWindowCornerPreference("Default"); + } + else + { + RemoveDropShadowEffectFromCurrentTheme(); + } } } - private bool IsBlurTheme() + private static void SetWindowCornerPreference(string cornerType) { - if (Environment.OSVersion.Version >= new Version(6, 2)) + Window mainWindow = Application.Current.MainWindow; + if (mainWindow == null) + return; + + Win32Helper.DWMSetCornerPreferenceForWindow(mainWindow, cornerType); + } + + // Get Background Color from WindowBorderStyle when there not color for BG. + // for theme has not "LightBG" or "DarkBG" case. + private Color GetWindowBorderStyleBackground(string theme) + { + var Resources = GetThemeResourceDictionary(theme); + var windowBorderStyle = (Style)Resources["WindowBorderStyle"]; + + var backgroundSetter = windowBorderStyle.Setters + .OfType() + .FirstOrDefault(s => s.Property == Border.BackgroundProperty); + + if (backgroundSetter != null) { - var resource = Application.Current.TryFindResource("ThemeBlurEnabled"); + // Background's Value is DynamicColor Case + var backgroundValue = backgroundSetter.Value; - if (resource is bool) - return (bool)resource; + if (backgroundValue is SolidColorBrush solidColorBrush) + { + return solidColorBrush.Color; // Return SolidColorBrush's Color + } + else if (backgroundValue is DynamicResourceExtension dynamicResource) + { + // When DynamicResource Extension it is, Key is resource's name. + var resourceKey = backgroundSetter.Value.ToString(); + // find key in resource and return color. + if (Resources.Contains(resourceKey)) + { + var colorResource = Resources[resourceKey]; + if (colorResource is SolidColorBrush colorBrush) + { + return colorBrush.Color; + } + else if (colorResource is Color color) + { + return color; + } + } + } + } + + return Colors.Transparent; // Default is transparent + } + + private void ApplyPreviewBackground(Color? bgColor = null) + { + if (bgColor == null) return; + + // Create a new Style for the preview + var previewStyle = new Style(typeof(Border)); + + // Get the original WindowBorderStyle + if (Application.Current.Resources.Contains("WindowBorderStyle") && + Application.Current.Resources["WindowBorderStyle"] is Style originalStyle) + { + // Copy the original style, including the base style if it exists + CopyStyle(originalStyle, previewStyle); + } + + // Apply background color (remove transparency in color) + Color backgroundColor = Color.FromRgb(bgColor.Value.R, bgColor.Value.G, bgColor.Value.B); + previewStyle.Setters.Add(new Setter(Border.BackgroundProperty, new SolidColorBrush(backgroundColor))); + + // The blur theme keeps the corner round fixed (applying DWM code to modify it causes rendering issues). + // The non-blur theme retains the previously set WindowBorderStyle. + if (BlurEnabled) + { + previewStyle.Setters.Add(new Setter(Border.CornerRadiusProperty, new CornerRadius(5))); + previewStyle.Setters.Add(new Setter(Border.BorderThicknessProperty, new Thickness(1))); + } + + // Set the new style to the resource + Application.Current.Resources["PreviewWindowBorderStyle"] = previewStyle; + } + + private void CopyStyle(Style originalStyle, Style targetStyle) + { + // If the style is based on another style, copy the base style first + if (originalStyle.BasedOn != null) + { + CopyStyle(originalStyle.BasedOn, targetStyle); + } + + // Copy the setters from the original style + foreach (var setter in originalStyle.Setters.OfType()) + { + targetStyle.Setters.Add(new Setter(setter.Property, setter.Value)); + } + } + + private void ColorizeWindow(string theme, BackdropTypes backdropType) + { + var dict = GetThemeResourceDictionary(theme); + if (dict == null) return; + + var mainWindow = Application.Current.MainWindow; + if (mainWindow == null) return; + + // Check if the theme supports blur + bool hasBlur = dict.Contains("ThemeBlurEnabled") && dict["ThemeBlurEnabled"] is bool b && b; + + // SystemBG value check (Auto, Light, Dark) + string systemBG = dict.Contains("SystemBG") ? dict["SystemBG"] as string : "Auto"; // 기본값 Auto + + // Check the user's ColorScheme setting + string colorScheme = _settings.ColorScheme; + + // Check system dark mode setting (read AppsUseLightTheme value) + int themeValue = (int)Registry.GetValue(@"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", "AppsUseLightTheme", 1); + bool isSystemDark = themeValue == 0; + + // Final decision on whether to use dark mode + bool useDarkMode = false; + + // If systemBG is not "Auto", prioritize it over ColorScheme and set the mode based on systemBG value + if (systemBG == "Dark") + { + useDarkMode = true; // Dark + } + else if (systemBG == "Light") + { + useDarkMode = false; // Light + } + else if (systemBG == "Auto") + { + // If systemBG is "Auto", decide based on ColorScheme + if (colorScheme == "Dark") + useDarkMode = true; + else if (colorScheme == "Light") + useDarkMode = false; + else + useDarkMode = isSystemDark; // Auto (based on system setting) + } + + // Apply DWM Dark Mode + Win32Helper.DWMSetDarkModeForWindow(mainWindow, useDarkMode); + + Color LightBG; + Color DarkBG; + + // Retrieve LightBG value (fallback to WindowBorderStyle background color if not found) + try + { + LightBG = dict.Contains("LightBG") ? (Color)dict["LightBG"] : GetWindowBorderStyleBackground(theme); + } + catch (Exception) + { + LightBG = GetWindowBorderStyleBackground(theme); + } + + // Retrieve DarkBG value (fallback to LightBG if not found) + try + { + DarkBG = dict.Contains("DarkBG") ? (Color)dict["DarkBG"] : LightBG; + } + catch (Exception) + { + DarkBG = LightBG; + } + + // Select background color based on ColorScheme and SystemBG + Color selectedBG = useDarkMode ? DarkBG : LightBG; + ApplyPreviewBackground(selectedBG); + + bool isBlurAvailable = hasBlur && Win32Helper.IsBackdropSupported(); // Windows 11 미만이면 hasBlur를 강제 false + + if (!isBlurAvailable) + { + mainWindow.Background = Brushes.Transparent; + } + else + { + // Only set the background to transparent if the theme supports blur + if (backdropType == BackdropTypes.Mica || backdropType == BackdropTypes.MicaAlt) + { + mainWindow.Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0)); + } + else + { + mainWindow.Background = new SolidColorBrush(selectedBG); + } + } + } + + private static bool IsBlurTheme() + { + if (!Win32Helper.IsBackdropSupported()) // Windows 11 미만이면 무조건 false return false; - } - return false; + var resource = Application.Current.TryFindResource("ThemeBlurEnabled"); + + return resource is bool b && b; } - private void SetWindowAccent(Window w, AccentState state) - { - var windowHelper = new WindowInteropHelper(w); - - windowHelper.EnsureHandle(); - - var accent = new AccentPolicy { AccentState = state }; - var accentStructSize = Marshal.SizeOf(accent); - - var accentPtr = Marshal.AllocHGlobal(accentStructSize); - Marshal.StructureToPtr(accent, accentPtr, false); - - var data = new WindowCompositionAttributeData - { - Attribute = WindowCompositionAttribute.WCA_ACCENT_POLICY, - SizeOfData = accentStructSize, - Data = accentPtr - }; - - SetWindowCompositionAttribute(windowHelper.Handle, ref data); - - Marshal.FreeHGlobal(accentPtr); - } #endregion - - public record ThemeData(string FileNameWithoutExtension, string Name, bool? IsDark = null, bool? HasBlur = null); } } diff --git a/Flow.Launcher.Core/Resource/ThemeManager.cs b/Flow.Launcher.Core/Resource/ThemeManager.cs deleted file mode 100644 index 71f9acaa5..000000000 --- a/Flow.Launcher.Core/Resource/ThemeManager.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Flow.Launcher.Core.Resource -{ - public class ThemeManager - { - private static Theme instance; - private static object syncObject = new object(); - - public static Theme Instance - { - get - { - if (instance == null) - { - lock (syncObject) - { - if (instance == null) - { - instance = new Theme(); - } - } - } - return instance; - } - } - } -} diff --git a/Flow.Launcher.Core/Resource/TranslationConverter.cs b/Flow.Launcher.Core/Resource/TranslationConverter.cs index ebab99e5b..eb0032758 100644 --- a/Flow.Launcher.Core/Resource/TranslationConverter.cs +++ b/Flow.Launcher.Core/Resource/TranslationConverter.cs @@ -1,19 +1,25 @@ using System; using System.Globalization; using System.Windows.Data; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.Resource { public class TranslationConverter : IValueConverter { + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { var key = value.ToString(); - if (String.IsNullOrEmpty(key)) - return key; - return InternationalizationManager.Instance.GetTranslation(key); + if (string.IsNullOrEmpty(key)) return key; + return API.GetTranslation(key); } - public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) => throw new System.InvalidOperationException(); + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => + throw new InvalidOperationException(); } } diff --git a/Flow.Launcher.Core/Storage/IRemovable.cs b/Flow.Launcher.Core/Storage/IRemovable.cs new file mode 100644 index 000000000..bcf1cdd5e --- /dev/null +++ b/Flow.Launcher.Core/Storage/IRemovable.cs @@ -0,0 +1,19 @@ +namespace Flow.Launcher.Core.Storage; + +/// +/// Remove storage instances from instance +/// +public interface IRemovable +{ + /// + /// Remove all instances of one plugin + /// + /// + public void RemovePluginSettings(string assemblyName); + + /// + /// Remove all instances of one plugin + /// + /// + public void RemovePluginCaches(string cacheDirectory); +} diff --git a/Flow.Launcher.Core/Updater.cs b/Flow.Launcher.Core/Updater.cs index 3f64b273e..bc3655f69 100644 --- a/Flow.Launcher.Core/Updater.cs +++ b/Flow.Launcher.Core/Updater.cs @@ -4,41 +4,45 @@ using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; using System.Threading.Tasks; using System.Windows; -using JetBrains.Annotations; -using Squirrel; -using Flow.Launcher.Core.Resource; using Flow.Launcher.Plugin.SharedCommands; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Http; -using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; -using System.Text.Json.Serialization; -using System.Threading; +using JetBrains.Annotations; +using Squirrel; namespace Flow.Launcher.Core { public class Updater { - public string GitHubRepository { get; } + public string GitHubRepository { get; init; } - public Updater(string gitHubRepository) + private static readonly string ClassName = nameof(Updater); + + private readonly IPublicAPI _api; + + public Updater(IPublicAPI publicAPI, string gitHubRepository) { + _api = publicAPI; GitHubRepository = gitHubRepository; } private SemaphoreSlim UpdateLock { get; } = new SemaphoreSlim(1); - public async Task UpdateAppAsync(IPublicAPI api, bool silentUpdate = true) + public async Task UpdateAppAsync(bool silentUpdate = true) { await UpdateLock.WaitAsync().ConfigureAwait(false); try { if (!silentUpdate) - api.ShowMsg(api.GetTranslation("pleaseWait"), - api.GetTranslation("update_flowlauncher_update_check")); + _api.ShowMsg(_api.GetTranslation("pleaseWait"), + _api.GetTranslation("update_flowlauncher_update_check")); using var updateManager = await GitHubUpdateManagerAsync(GitHubRepository).ConfigureAwait(false); @@ -48,18 +52,18 @@ namespace Flow.Launcher.Core var newReleaseVersion = Version.Parse(newUpdateInfo.FutureReleaseEntry.Version.ToString()); var currentVersion = Version.Parse(Constant.Version); - Log.Info($"|Updater.UpdateApp|Future Release <{newUpdateInfo.FutureReleaseEntry.Formatted()}>"); + _api.LogInfo(ClassName, $"Future Release <{Formatted(newUpdateInfo.FutureReleaseEntry)}>"); if (newReleaseVersion <= currentVersion) { if (!silentUpdate) - MessageBox.Show(api.GetTranslation("update_flowlauncher_already_on_latest")); + _api.ShowMsgBox(_api.GetTranslation("update_flowlauncher_already_on_latest")); return; } if (!silentUpdate) - api.ShowMsg(api.GetTranslation("update_flowlauncher_update_found"), - api.GetTranslation("update_flowlauncher_updating")); + _api.ShowMsg(_api.GetTranslation("update_flowlauncher_update_found"), + _api.GetTranslation("update_flowlauncher_updating")); await updateManager.DownloadReleases(newUpdateInfo.ReleasesToApply).ConfigureAwait(false); @@ -67,10 +71,10 @@ namespace Flow.Launcher.Core if (DataLocation.PortableDataLocationInUse()) { - var targetDestination = updateManager.RootAppDirectory + $"\\app-{newReleaseVersion.ToString()}\\{DataLocation.PortableFolderName}"; - FilesFolders.CopyAll(DataLocation.PortableDataPath, targetDestination); - if (!FilesFolders.VerifyBothFolderFilesEqual(DataLocation.PortableDataPath, targetDestination)) - MessageBox.Show(string.Format(api.GetTranslation("update_flowlauncher_fail_moving_portable_user_profile_data"), + var targetDestination = updateManager.RootAppDirectory + $"\\app-{newReleaseVersion}\\{DataLocation.PortableFolderName}"; + FilesFolders.CopyAll(DataLocation.PortableDataPath, targetDestination, (s) => _api.ShowMsgBox(s)); + if (!FilesFolders.VerifyBothFolderFilesEqual(DataLocation.PortableDataPath, targetDestination, (s) => _api.ShowMsgBox(s))) + _api.ShowMsgBox(string.Format(_api.GetTranslation("update_flowlauncher_fail_moving_portable_user_profile_data"), DataLocation.PortableDataPath, targetDestination)); } @@ -81,23 +85,27 @@ namespace Flow.Launcher.Core var newVersionTips = NewVersionTips(newReleaseVersion.ToString()); - Log.Info($"|Updater.UpdateApp|Update success:{newVersionTips}"); + _api.LogInfo(ClassName, $"Update success:{newVersionTips}"); - if (MessageBox.Show(newVersionTips, api.GetTranslation("update_flowlauncher_new_update"), MessageBoxButton.YesNo) == MessageBoxResult.Yes) + if (_api.ShowMsgBox(newVersionTips, _api.GetTranslation("update_flowlauncher_new_update"), MessageBoxButton.YesNo) == MessageBoxResult.Yes) { UpdateManager.RestartApp(Constant.ApplicationFileName); } } catch (Exception e) { - if ((e is HttpRequestException or WebException or SocketException || e.InnerException is TimeoutException)) - Log.Exception($"|Updater.UpdateApp|Check your connection and proxy settings to github-cloud.s3.amazonaws.com.", e); + if (e is HttpRequestException or WebException or SocketException || e.InnerException is TimeoutException) + { + _api.LogException(ClassName, $"Check your connection and proxy settings to github-cloud.s3.amazonaws.com.", e); + } else - Log.Exception($"|Updater.UpdateApp|Error Occurred", e); + { + _api.LogException(ClassName, $"Error Occurred", e); + } if (!silentUpdate) - api.ShowMsg(api.GetTranslation("update_flowlauncher_fail"), - api.GetTranslation("update_flowlauncher_check_connection")); + _api.ShowMsg(_api.GetTranslation("update_flowlauncher_fail"), + _api.GetTranslation("update_flowlauncher_check_connection")); } finally { @@ -119,14 +127,14 @@ namespace Flow.Launcher.Core } // https://github.com/Squirrel/Squirrel.Windows/blob/master/src/Squirrel/UpdateManager.Factory.cs - private async Task GitHubUpdateManagerAsync(string repository) + private static async Task GitHubUpdateManagerAsync(string repository) { var uri = new Uri(repository); var api = $"https://api.github.com/repos{uri.AbsolutePath}/releases"; await using var jsonStream = await Http.GetStreamAsync(api).ConfigureAwait(false); - var releases = await System.Text.Json.JsonSerializer.DeserializeAsync>(jsonStream).ConfigureAwait(false); + var releases = await JsonSerializer.DeserializeAsync>(jsonStream).ConfigureAwait(false); var latest = releases.Where(r => !r.Prerelease).OrderByDescending(r => r.PublishedAt).First(); var latestUrl = latest.HtmlUrl.Replace("/tag/", "/download/"); @@ -141,12 +149,21 @@ namespace Flow.Launcher.Core return manager; } - public string NewVersionTips(string version) + private string NewVersionTips(string version) { - var translator = InternationalizationManager.Instance; - var tips = string.Format(translator.GetTranslation("newVersionTips"), version); + var tips = string.Format(_api.GetTranslation("newVersionTips"), version); return tips; } + + private static string Formatted(T t) + { + var formatted = JsonSerializer.Serialize(t, new JsonSerializerOptions + { + WriteIndented = true + }); + + return formatted; + } } } diff --git a/Flow.Launcher.Infrastructure/Constant.cs b/Flow.Launcher.Infrastructure/Constant.cs index 8a95ee79f..13da9f79f 100644 --- a/Flow.Launcher.Infrastructure/Constant.cs +++ b/Flow.Launcher.Infrastructure/Constant.cs @@ -31,6 +31,8 @@ namespace Flow.Launcher.Infrastructure public static readonly string ErrorIcon = Path.Combine(ImagesDirectory, "app_error.png"); public static readonly string MissingImgIcon = Path.Combine(ImagesDirectory, "app_missing_img.png"); public static readonly string LoadingImgIcon = Path.Combine(ImagesDirectory, "loading.png"); + public static readonly string ImageIcon = Path.Combine(ImagesDirectory, "image.png"); + public static readonly string HistoryIcon = Path.Combine(ImagesDirectory, "history.png"); public static string PythonPath; public static string NodePath; @@ -46,10 +48,13 @@ namespace Flow.Launcher.Infrastructure public const string Themes = "Themes"; public const string Settings = "Settings"; public const string Logs = "Logs"; + public const string Cache = "Cache"; public const string Website = "https://flowlauncher.com"; public const string SponsorPage = "https://github.com/sponsors/Flow-Launcher"; public const string GitHub = "https://github.com/Flow-Launcher/Flow.Launcher"; public const string Docs = "https://flowlauncher.com/docs"; + + public const string SystemLanguageCode = "system"; } } diff --git a/Flow.Launcher.Infrastructure/FileExplorerHelper.cs b/Flow.Launcher.Infrastructure/FileExplorerHelper.cs index 76695a4e3..1085cc833 100644 --- a/Flow.Launcher.Infrastructure/FileExplorerHelper.cs +++ b/Flow.Launcher.Infrastructure/FileExplorerHelper.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Runtime.InteropServices; +using Windows.Win32; namespace Flow.Launcher.Infrastructure { @@ -15,7 +15,20 @@ namespace Flow.Launcher.Infrastructure { var explorerWindow = GetActiveExplorer(); string locationUrl = explorerWindow?.LocationURL; - return !string.IsNullOrEmpty(locationUrl) ? new Uri(locationUrl).LocalPath + "\\" : null; + return !string.IsNullOrEmpty(locationUrl) ? GetDirectoryPath(new Uri(locationUrl).LocalPath) : null; + } + + /// + /// Get directory path from a file path + /// + private static string GetDirectoryPath(string path) + { + if (!path.EndsWith("\\")) + { + return path + "\\"; + } + + return path; } /// @@ -54,12 +67,6 @@ namespace Flow.Launcher.Infrastructure return explorerWindows.Zip(zOrders).MinBy(x => x.Second).First; } - [DllImport("user32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); - - private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); - /// /// Gets the z-order for one or more windows atomically with respect to each other. In Windows, smaller z-order is higher. If the window is not top level, the z order is returned as -1. /// @@ -70,9 +77,9 @@ namespace Flow.Launcher.Infrastructure var index = 0; var numRemaining = hWnds.Count; - EnumWindows((wnd, _) => + PInvoke.EnumWindows((wnd, _) => { - var searchIndex = hWnds.FindIndex(x => x.HWND == wnd.ToInt32()); + var searchIndex = hWnds.FindIndex(x => new IntPtr(x.HWND) == wnd); if (searchIndex != -1) { z[searchIndex] = index; diff --git a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj index d9cb5893a..31547200b 100644 --- a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj +++ b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj @@ -35,6 +35,10 @@ false + + + + @@ -49,15 +53,23 @@ - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - + + all + + diff --git a/Flow.Launcher.Infrastructure/Helper.cs b/Flow.Launcher.Infrastructure/Helper.cs index 864d796c7..b02d84ca7 100644 --- a/Flow.Launcher.Infrastructure/Helper.cs +++ b/Flow.Launcher.Infrastructure/Helper.cs @@ -1,19 +1,11 @@ #nullable enable using System; -using System.IO; -using System.Text.Json; -using System.Text.Json.Serialization; namespace Flow.Launcher.Infrastructure { public static class Helper { - static Helper() - { - jsonFormattedSerializerOptions.Converters.Add(new JsonStringEnumConverter()); - } - /// /// http://www.yinwang.org/blog-cn/2015/11/21/programming-philosophy /// @@ -36,55 +28,5 @@ namespace Flow.Launcher.Infrastructure throw new NullReferenceException(); } } - - public static void ValidateDataDirectory(string bundledDataDirectory, string dataDirectory) - { - if (!Directory.Exists(dataDirectory)) - { - Directory.CreateDirectory(dataDirectory); - } - - foreach (var bundledDataPath in Directory.GetFiles(bundledDataDirectory)) - { - var data = Path.GetFileName(bundledDataPath); - var dataPath = Path.Combine(dataDirectory, data.NonNull()); - if (!File.Exists(dataPath)) - { - File.Copy(bundledDataPath, dataPath); - } - else - { - var time1 = new FileInfo(bundledDataPath).LastWriteTimeUtc; - var time2 = new FileInfo(dataPath).LastWriteTimeUtc; - if (time1 != time2) - { - File.Copy(bundledDataPath, dataPath, true); - } - } - } - } - - public static void ValidateDirectory(string path) - { - if (!Directory.Exists(path)) - { - Directory.CreateDirectory(path); - } - } - - private static readonly JsonSerializerOptions jsonFormattedSerializerOptions = new JsonSerializerOptions - { - WriteIndented = true - }; - - public static string Formatted(this T t) - { - var formatted = JsonSerializer.Serialize(t, new JsonSerializerOptions - { - WriteIndented = true - }); - - return formatted; - } } } diff --git a/Flow.Launcher.Infrastructure/Hotkey/GlobalHotkey.cs b/Flow.Launcher.Infrastructure/Hotkey/GlobalHotkey.cs index f847ab189..b2a140755 100644 --- a/Flow.Launcher.Infrastructure/Hotkey/GlobalHotkey.cs +++ b/Flow.Launcher.Infrastructure/Hotkey/GlobalHotkey.cs @@ -1,6 +1,11 @@ -using System; +using System; +using System.Diagnostics; using System.Runtime.InteropServices; using Flow.Launcher.Plugin; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Input.KeyboardAndMouse; +using Windows.Win32.UI.WindowsAndMessaging; namespace Flow.Launcher.Infrastructure.Hotkey { @@ -10,44 +15,45 @@ namespace Flow.Launcher.Infrastructure.Hotkey /// public unsafe class GlobalHotkey : IDisposable { - private static readonly IntPtr hookId; - - - + private static readonly HOOKPROC _procKeyboard = HookKeyboardCallback; + private static readonly UnhookWindowsHookExSafeHandle hookId; + public delegate bool KeyboardCallback(KeyEvent keyEvent, int vkCode, SpecialKeyState state); internal static Func hookedKeyboardCallback; - //Modifier key constants - private const int VK_SHIFT = 0x10; - private const int VK_CONTROL = 0x11; - private const int VK_ALT = 0x12; - private const int VK_WIN = 91; - static GlobalHotkey() { // Set the hook - hookId = InterceptKeys.SetHook(& LowLevelKeyboardProc); + hookId = SetHook(_procKeyboard, WINDOWS_HOOK_ID.WH_KEYBOARD_LL); + } + + private static UnhookWindowsHookExSafeHandle SetHook(HOOKPROC proc, WINDOWS_HOOK_ID hookId) + { + using var curProcess = Process.GetCurrentProcess(); + using var curModule = curProcess.MainModule; + return PInvoke.SetWindowsHookEx(hookId, proc, PInvoke.GetModuleHandle(curModule.ModuleName), 0); } public static SpecialKeyState CheckModifiers() { SpecialKeyState state = new SpecialKeyState(); - if ((InterceptKeys.GetKeyState(VK_SHIFT) & 0x8000) != 0) + if ((PInvoke.GetKeyState((int)VIRTUAL_KEY.VK_SHIFT) & 0x8000) != 0) { //SHIFT is pressed state.ShiftPressed = true; } - if ((InterceptKeys.GetKeyState(VK_CONTROL) & 0x8000) != 0) + if ((PInvoke.GetKeyState((int)VIRTUAL_KEY.VK_CONTROL) & 0x8000) != 0) { //CONTROL is pressed state.CtrlPressed = true; } - if ((InterceptKeys.GetKeyState(VK_ALT) & 0x8000) != 0) + if ((PInvoke.GetKeyState((int)VIRTUAL_KEY.VK_MENU) & 0x8000) != 0) { //ALT is pressed state.AltPressed = true; } - if ((InterceptKeys.GetKeyState(VK_WIN) & 0x8000) != 0) + if ((PInvoke.GetKeyState((int)VIRTUAL_KEY.VK_LWIN) & 0x8000) != 0 || + (PInvoke.GetKeyState((int)VIRTUAL_KEY.VK_RWIN) & 0x8000) != 0) { //WIN is pressed state.WinPressed = true; @@ -56,33 +62,33 @@ namespace Flow.Launcher.Infrastructure.Hotkey return state; } - [UnmanagedCallersOnly] - private static IntPtr LowLevelKeyboardProc(int nCode, UIntPtr wParam, IntPtr lParam) + private static LRESULT HookKeyboardCallback(int nCode, WPARAM wParam, LPARAM lParam) { bool continues = true; if (nCode >= 0) { - if (wParam.ToUInt32() == (int)KeyEvent.WM_KEYDOWN || - wParam.ToUInt32() == (int)KeyEvent.WM_KEYUP || - wParam.ToUInt32() == (int)KeyEvent.WM_SYSKEYDOWN || - wParam.ToUInt32() == (int)KeyEvent.WM_SYSKEYUP) + if (wParam.Value == (int)KeyEvent.WM_KEYDOWN || + wParam.Value == (int)KeyEvent.WM_KEYUP || + wParam.Value == (int)KeyEvent.WM_SYSKEYDOWN || + wParam.Value == (int)KeyEvent.WM_SYSKEYUP) { if (hookedKeyboardCallback != null) - continues = hookedKeyboardCallback((KeyEvent)wParam.ToUInt32(), Marshal.ReadInt32(lParam), CheckModifiers()); + continues = hookedKeyboardCallback((KeyEvent)wParam.Value, Marshal.ReadInt32(lParam), CheckModifiers()); } } if (continues) { - return InterceptKeys.CallNextHookEx(hookId, nCode, wParam, lParam); + return PInvoke.CallNextHookEx(hookId, nCode, wParam, lParam); } - return (IntPtr)(-1); + + return new LRESULT(1); } public void Dispose() { - InterceptKeys.UnhookWindowsHookEx(hookId); + hookId.Dispose(); } ~GlobalHotkey() @@ -90,4 +96,4 @@ namespace Flow.Launcher.Infrastructure.Hotkey Dispose(); } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Infrastructure/Hotkey/InterceptKeys.cs b/Flow.Launcher.Infrastructure/Hotkey/InterceptKeys.cs deleted file mode 100644 index d33bac34c..000000000 --- a/Flow.Launcher.Infrastructure/Hotkey/InterceptKeys.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Diagnostics; -using System.Runtime.InteropServices; - -namespace Flow.Launcher.Infrastructure.Hotkey -{ - internal static unsafe class InterceptKeys - { - public delegate IntPtr LowLevelKeyboardProc(int nCode, UIntPtr wParam, IntPtr lParam); - - private const int WH_KEYBOARD_LL = 13; - - public static IntPtr SetHook(delegate* unmanaged proc) - { - using (Process curProcess = Process.GetCurrentProcess()) - using (ProcessModule curModule = curProcess.MainModule) - { - return SetWindowsHookEx(WH_KEYBOARD_LL, proc, GetModuleHandle(curModule.ModuleName), 0); - } - } - - [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] - public static extern IntPtr SetWindowsHookEx(int idHook, delegate* unmanaged lpfn, IntPtr hMod, uint dwThreadId); - - [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool UnhookWindowsHookEx(IntPtr hhk); - - [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] - public static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, UIntPtr wParam, IntPtr lParam); - - [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] - public static extern IntPtr GetModuleHandle(string lpModuleName); - - [DllImport("user32.dll", CharSet = CharSet.Auto, ExactSpelling = true, CallingConvention = CallingConvention.Winapi)] - public static extern short GetKeyState(int keyCode); - } -} \ No newline at end of file diff --git a/Flow.Launcher.Infrastructure/Hotkey/KeyEvent.cs b/Flow.Launcher.Infrastructure/Hotkey/KeyEvent.cs deleted file mode 100644 index 15e306883..000000000 --- a/Flow.Launcher.Infrastructure/Hotkey/KeyEvent.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace Flow.Launcher.Infrastructure.Hotkey -{ - public enum KeyEvent - { - /// - /// Key down - /// - WM_KEYDOWN = 256, - - /// - /// Key up - /// - WM_KEYUP = 257, - - /// - /// System key up - /// - WM_SYSKEYUP = 261, - - /// - /// System key down - /// - WM_SYSKEYDOWN = 260 - } -} \ No newline at end of file diff --git a/Flow.Launcher.Infrastructure/Http/Http.cs b/Flow.Launcher.Infrastructure/Http/Http.cs index 14b8eef4e..a29f8accf 100644 --- a/Flow.Launcher.Infrastructure/Http/Http.cs +++ b/Flow.Launcher.Infrastructure/Http/Http.cs @@ -1,31 +1,31 @@ -using System.IO; +using System; +using System.IO; using System.Net; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; -using JetBrains.Annotations; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; -using System; -using System.Threading; using Flow.Launcher.Plugin; +using JetBrains.Annotations; namespace Flow.Launcher.Infrastructure.Http { public static class Http { + private static readonly string ClassName = nameof(Http); + private const string UserAgent = @"Mozilla/5.0 (Trident/7.0; rv:11.0) like Gecko"; - private static HttpClient client = new HttpClient(); - - public static IPublicAPI API { get; set; } + private static readonly HttpClient client = new(); static Http() { // need to be added so it would work on a win10 machine ServicePointManager.Expect100Continue = true; ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls - | SecurityProtocolType.Tls11 - | SecurityProtocolType.Tls12; + | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12; client.DefaultRequestHeaders.Add("User-Agent", UserAgent); HttpClient.DefaultProxy = WebProxy; @@ -35,7 +35,7 @@ namespace Flow.Launcher.Infrastructure.Http public static HttpProxy Proxy { - private get { return proxy; } + private get => proxy; set { proxy = value; @@ -73,25 +73,60 @@ namespace Flow.Launcher.Infrastructure.Http ProxyProperty.Port => (new Uri($"http://{Proxy.Server}:{Proxy.Port}"), WebProxy.Credentials), ProxyProperty.UserName => (WebProxy.Address, new NetworkCredential(Proxy.UserName, Proxy.Password)), ProxyProperty.Password => (WebProxy.Address, new NetworkCredential(Proxy.UserName, Proxy.Password)), - _ => throw new ArgumentOutOfRangeException() + _ => throw new ArgumentOutOfRangeException(null) }; } catch (UriFormatException e) { - API.ShowMsg("Please try again", "Unable to parse Http Proxy"); - Log.Exception("Flow.Launcher.Infrastructure.Http", "Unable to parse Uri", e); + Ioc.Default.GetRequiredService().ShowMsg("Please try again", "Unable to parse Http Proxy"); + Log.Exception(ClassName, "Unable to parse Uri", e); } } - public static async Task DownloadAsync([NotNull] string url, [NotNull] string filePath, CancellationToken token = default) + public static async Task DownloadAsync([NotNull] string url, [NotNull] string filePath, Action reportProgress = null, CancellationToken token = default) { try { using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token); + if (response.StatusCode == HttpStatusCode.OK) { - await using var fileStream = new FileStream(filePath, FileMode.CreateNew); - await response.Content.CopyToAsync(fileStream, token); + var totalBytes = response.Content.Headers.ContentLength ?? -1L; + var canReportProgress = totalBytes != -1; + + if (canReportProgress && reportProgress != null) + { + await using var contentStream = await response.Content.ReadAsStreamAsync(token); + await using var fileStream = new FileStream(filePath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 8192, true); + + var buffer = new byte[8192]; + long totalRead = 0; + int read; + double progressValue = 0; + + reportProgress(0); + + while ((read = await contentStream.ReadAsync(buffer, token)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, read), token); + totalRead += read; + + progressValue = totalRead * 100.0 / totalBytes; + + if (token.IsCancellationRequested) + return; + else + reportProgress(progressValue); + } + + if (progressValue < 100) + reportProgress(100); + } + else + { + await using var fileStream = new FileStream(filePath, FileMode.CreateNew); + await response.Content.CopyToAsync(fileStream, token); + } } else { @@ -100,7 +135,7 @@ namespace Flow.Launcher.Infrastructure.Http } catch (HttpRequestException e) { - Log.Exception("Infrastructure.Http", "Http Request Error", e, "DownloadAsync"); + Log.Exception(ClassName, "Http Request Error", e, "DownloadAsync"); throw; } } @@ -113,7 +148,7 @@ namespace Flow.Launcher.Infrastructure.Http /// The Http result as string. Null if cancellation requested public static Task GetAsync([NotNull] string url, CancellationToken token = default) { - Log.Debug($"|Http.Get|Url <{url}>"); + Log.Debug(ClassName, $"Url <{url}>"); return GetAsync(new Uri(url), token); } @@ -125,7 +160,7 @@ namespace Flow.Launcher.Infrastructure.Http /// The Http result as string. Null if cancellation requested public static async Task GetAsync([NotNull] Uri url, CancellationToken token = default) { - Log.Debug($"|Http.Get|Url <{url}>"); + Log.Debug(ClassName, $"Url <{url}>"); using var response = await client.GetAsync(url, token); var content = await response.Content.ReadAsStringAsync(token); if (response.StatusCode != HttpStatusCode.OK) @@ -147,7 +182,6 @@ namespace Flow.Launcher.Infrastructure.Http public static Task GetStreamAsync([NotNull] string url, CancellationToken token = default) => GetStreamAsync(new Uri(url), token); - /// /// Send a GET request to the specified Uri with an HTTP completion option and a cancellation token as an asynchronous operation. /// @@ -157,7 +191,7 @@ namespace Flow.Launcher.Infrastructure.Http public static async Task GetStreamAsync([NotNull] Uri url, CancellationToken token = default) { - Log.Debug($"|Http.Get|Url <{url}>"); + Log.Debug(ClassName, $"Url <{url}>"); return await client.GetStreamAsync(url, token); } @@ -168,7 +202,7 @@ namespace Flow.Launcher.Infrastructure.Http public static async Task GetResponseAsync([NotNull] Uri url, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken token = default) { - Log.Debug($"|Http.Get|Url <{url}>"); + Log.Debug(ClassName, $"Url <{url}>"); return await client.GetAsync(url, completionOption, token); } @@ -177,7 +211,14 @@ namespace Flow.Launcher.Infrastructure.Http /// public static async Task SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken token = default) { - return await client.SendAsync(request, completionOption, token); + try + { + return await client.SendAsync(request, completionOption, token); + } + catch (System.Exception) + { + return new HttpResponseMessage(HttpStatusCode.InternalServerError); + } } } } diff --git a/Flow.Launcher.Infrastructure/Image/ImageCache.cs b/Flow.Launcher.Infrastructure/Image/ImageCache.cs index ddbab4ef0..b8c12868b 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageCache.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageCache.cs @@ -1,8 +1,6 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Threading; using System.Threading.Tasks; using System.Windows.Media; using BitFaster.Caching.Lfu; @@ -55,7 +53,6 @@ namespace Flow.Launcher.Infrastructure.Image return image != null; } - image = null; return false; } diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 612f495be..86df01a30 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -5,30 +5,34 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using System.Windows.Documents; using System.Windows.Media; using System.Windows.Media.Imaging; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.Storage; -using static Flow.Launcher.Infrastructure.Http.Http; +using SharpVectors.Converters; +using SharpVectors.Renderers.Wpf; namespace Flow.Launcher.Infrastructure.Image { public static class ImageLoader { + private static readonly string ClassName = nameof(ImageLoader); + private static readonly ImageCache ImageCache = new(); private static SemaphoreSlim storageLock { get; } = new SemaphoreSlim(1, 1); private static BinaryStorage> _storage; private static readonly ConcurrentDictionary GuidToKey = new(); private static IImageHashGenerator _hashGenerator; private static readonly bool EnableImageHash = true; + public static ImageSource Image { get; } = new BitmapImage(new Uri(Constant.ImageIcon)); public static ImageSource MissingImage { get; } = new BitmapImage(new Uri(Constant.MissingImgIcon)); public static ImageSource LoadingImage { get; } = new BitmapImage(new Uri(Constant.LoadingImgIcon)); public const int SmallIconSize = 64; public const int FullIconSize = 256; - + public const int FullImageSize = 320; private static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".ico" }; + private static readonly string SvgExtension = ".svg"; public static async Task InitializeAsync() { @@ -36,6 +40,7 @@ namespace Flow.Launcher.Infrastructure.Image _hashGenerator = new ImageHashGenerator(); var usage = await LoadStorageToConcurrentDictionaryAsync(); + _storage.ClearData(); ImageCache.Initialize(usage); @@ -48,19 +53,18 @@ namespace Flow.Launcher.Infrastructure.Image _ = Task.Run(async () => { - await Stopwatch.NormalAsync("|ImageLoader.Initialize|Preload images cost", async () => + await Stopwatch.InfoAsync(ClassName, "Preload images cost", async () => { foreach (var (path, isFullImage) in usage) { await LoadAsync(path, isFullImage); } }); - Log.Info( - $"|ImageLoader.Initialize|Number of preload images is <{ImageCache.CacheSize()}>, Images Number: {ImageCache.CacheSize()}, Unique Items {ImageCache.UniqueImagesInCache()}"); + Log.Info(ClassName, $"Number of preload images is <{ImageCache.CacheSize()}>, Images Number: {ImageCache.CacheSize()}, Unique Items {ImageCache.UniqueImagesInCache()}"); }); } - public static async Task Save() + public static async Task SaveAsync() { await storageLock.WaitAsync(); @@ -70,12 +74,22 @@ namespace Flow.Launcher.Infrastructure.Image .Select(x => x.Key) .ToList()); } + catch (System.Exception e) + { + Log.Exception(ClassName, "Failed to save image cache to file", e); + } finally { storageLock.Release(); } } + public static async Task WaitSaveAsync() + { + await storageLock.WaitAsync(); + storageLock.Release(); + } + private static async Task> LoadStorageToConcurrentDictionaryAsync() { await storageLock.WaitAsync(); @@ -139,7 +153,7 @@ namespace Flow.Launcher.Infrastructure.Image return new ImageResult(image, ImageType.ImageFile); } - if (path.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + if (path.StartsWith("data:image", StringComparison.OrdinalIgnoreCase)) { var imageSource = new BitmapImage(new Uri(path)); imageSource.Freeze(); @@ -157,8 +171,8 @@ namespace Flow.Launcher.Infrastructure.Image } catch (System.Exception e2) { - Log.Exception($"|ImageLoader.Load|Failed to get thumbnail for {path} on first try", e); - Log.Exception($"|ImageLoader.Load|Failed to get thumbnail for {path} on second try", e2); + Log.Exception(ClassName, $"Failed to get thumbnail for {path} on first try", e); + Log.Exception(ClassName, $"Failed to get thumbnail for {path} on second try", e2); ImageSource image = ImageCache[Constant.MissingImgIcon, false]; ImageCache[path, false] = image; @@ -172,7 +186,7 @@ namespace Flow.Launcher.Infrastructure.Image private static async Task LoadRemoteImageAsync(bool loadFullImage, Uri uriResult) { // Download image from url - await using var resp = await GetStreamAsync(uriResult); + await using var resp = await Http.Http.GetStreamAsync(uriResult); await using var buffer = new MemoryStream(); await resp.CopyToAsync(buffer); buffer.Seek(0, SeekOrigin.Begin); @@ -215,8 +229,17 @@ namespace Flow.Launcher.Infrastructure.Image type = ImageType.ImageFile; if (loadFullImage) { - image = LoadFullImage(path); - type = ImageType.FullImageFile; + try + { + image = LoadFullImage(path); + type = ImageType.FullImageFile; + } + catch (NotSupportedException ex) + { + image = Image; + type = ImageType.Error; + Log.Exception(ClassName, $"Failed to load image file from path {path}: {ex.Message}", ex); + } } else { @@ -228,6 +251,20 @@ namespace Flow.Launcher.Infrastructure.Image image = GetThumbnail(path, ThumbnailOptions.ThumbnailOnly); } } + else if (extension == SvgExtension) + { + try + { + image = LoadSvgImage(path, loadFullImage); + type = ImageType.FullImageFile; + } + catch (System.Exception ex) + { + image = Image; + type = ImageType.Error; + Log.Exception(ClassName, $"Failed to load SVG image from path {path}: {ex.Message}", ex); + } + } else { type = ImageType.File; @@ -268,7 +305,7 @@ namespace Flow.Launcher.Infrastructure.Image return ImageCache.TryGetValue(path, loadFullImage, out image); } - public static async ValueTask LoadAsync(string path, bool loadFullImage = false) + public static async ValueTask LoadAsync(string path, bool loadFullImage = false, bool cacheImage = true) { var imageResult = await LoadInternalAsync(path, loadFullImage); @@ -284,22 +321,24 @@ namespace Flow.Launcher.Infrastructure.Image // image already exists img = ImageCache[key, loadFullImage] ?? img; } - else + else if (cacheImage) { - // new guid - + // save guid key GuidToKey[hash] = path; } } - // update cache - ImageCache[path, loadFullImage] = img; + if (cacheImage) + { + // update cache + ImageCache[path, loadFullImage] = img; + } } return img; } - private static BitmapImage LoadFullImage(string path) + private static ImageSource LoadFullImage(string path) { BitmapImage image = new BitmapImage(); image.BeginInit(); @@ -308,24 +347,24 @@ namespace Flow.Launcher.Infrastructure.Image image.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; image.EndInit(); - if (image.PixelWidth > 320) + if (image.PixelWidth > FullImageSize) { BitmapImage resizedWidth = new BitmapImage(); resizedWidth.BeginInit(); resizedWidth.CacheOption = BitmapCacheOption.OnLoad; resizedWidth.UriSource = new Uri(path); resizedWidth.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; - resizedWidth.DecodePixelWidth = 320; + resizedWidth.DecodePixelWidth = FullImageSize; resizedWidth.EndInit(); - if (resizedWidth.PixelHeight > 320) + if (resizedWidth.PixelHeight > FullImageSize) { BitmapImage resizedHeight = new BitmapImage(); resizedHeight.BeginInit(); resizedHeight.CacheOption = BitmapCacheOption.OnLoad; resizedHeight.UriSource = new Uri(path); resizedHeight.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; - resizedHeight.DecodePixelHeight = 320; + resizedHeight.DecodePixelHeight = FullImageSize; resizedHeight.EndInit(); return resizedHeight; } @@ -335,5 +374,50 @@ namespace Flow.Launcher.Infrastructure.Image return image; } + + private static ImageSource LoadSvgImage(string path, bool loadFullImage = false) + { + // Set up drawing settings + var desiredHeight = loadFullImage ? FullImageSize : SmallIconSize; + var drawingSettings = new WpfDrawingSettings + { + IncludeRuntime = true, + // Set IgnoreRootViewbox to false to respect the SVG's viewBox + IgnoreRootViewbox = false + }; + + // Load and render the SVG + var converter = new FileSvgReader(drawingSettings); + var drawing = converter.Read(new Uri(path)); + + // Calculate scale to achieve desired height + var drawingBounds = drawing.Bounds; + if (drawingBounds.Height <= 0) + { + throw new InvalidOperationException($"Invalid SVG dimensions: Height must be greater than zero in {path}"); + } + var scale = desiredHeight / drawingBounds.Height; + var scaledWidth = drawingBounds.Width * scale; + var scaledHeight = drawingBounds.Height * scale; + + // Convert the Drawing to a Bitmap + var drawingVisual = new DrawingVisual(); + using (DrawingContext drawingContext = drawingVisual.RenderOpen()) + { + drawingContext.PushTransform(new ScaleTransform(scale, scale)); + drawingContext.DrawDrawing(drawing); + } + + // Create a RenderTargetBitmap to hold the rendered image + var bitmap = new RenderTargetBitmap( + (int)Math.Ceiling(scaledWidth), + (int)Math.Ceiling(scaledHeight), + 96, // DpiX + 96, // DpiY + PixelFormats.Pbgra32); + bitmap.Render(drawingVisual); + + return bitmap; + } } } diff --git a/Flow.Launcher.Infrastructure/Image/ThumbnailReader.cs b/Flow.Launcher.Infrastructure/Image/ThumbnailReader.cs index 247238bb6..4ce0df026 100644 --- a/Flow.Launcher.Infrastructure/Image/ThumbnailReader.cs +++ b/Flow.Launcher.Infrastructure/Image/ThumbnailReader.cs @@ -1,12 +1,19 @@ using System; using System.Runtime.InteropServices; using System.IO; +using System.Windows; using System.Windows.Interop; using System.Windows.Media.Imaging; -using System.Windows; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Shell; +using Windows.Win32.Graphics.Gdi; namespace Flow.Launcher.Infrastructure.Image { + /// + /// Subclass of + /// [Flags] public enum ThumbnailOptions { @@ -22,91 +29,15 @@ namespace Flow.Launcher.Infrastructure.Image { // Based on https://stackoverflow.com/questions/21751747/extract-thumbnail-for-any-file-in-windows - private const string IShellItem2Guid = "7E9FB0D3-919F-4307-AB2E-9B1860310C93"; + private static readonly Guid GUID_IShellItem = typeof(IShellItem).GUID; - [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - internal static extern int SHCreateItemFromParsingName( - [MarshalAs(UnmanagedType.LPWStr)] string path, - IntPtr pbc, - ref Guid riid, - [MarshalAs(UnmanagedType.Interface)] out IShellItem shellItem); - - [DllImport("gdi32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - internal static extern bool DeleteObject(IntPtr hObject); - - [ComImport] - [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - [Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe")] - internal interface IShellItem - { - void BindToHandler(IntPtr pbc, - [MarshalAs(UnmanagedType.LPStruct)]Guid bhid, - [MarshalAs(UnmanagedType.LPStruct)]Guid riid, - out IntPtr ppv); - - void GetParent(out IShellItem ppsi); - void GetDisplayName(SIGDN sigdnName, out IntPtr ppszName); - void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs); - void Compare(IShellItem psi, uint hint, out int piOrder); - }; - - internal enum SIGDN : uint - { - NORMALDISPLAY = 0, - PARENTRELATIVEPARSING = 0x80018001, - PARENTRELATIVEFORADDRESSBAR = 0x8001c001, - DESKTOPABSOLUTEPARSING = 0x80028000, - PARENTRELATIVEEDITING = 0x80031001, - DESKTOPABSOLUTEEDITING = 0x8004c000, - FILESYSPATH = 0x80058000, - URL = 0x80068000 - } - - internal enum HResult - { - Ok = 0x0000, - False = 0x0001, - InvalidArguments = unchecked((int)0x80070057), - OutOfMemory = unchecked((int)0x8007000E), - NoInterface = unchecked((int)0x80004002), - Fail = unchecked((int)0x80004005), - ExtractionFailed = unchecked((int)0x8004B200), - ElementNotFound = unchecked((int)0x80070490), - TypeElementNotFound = unchecked((int)0x8002802B), - NoObject = unchecked((int)0x800401E5), - Win32ErrorCanceled = 1223, - Canceled = unchecked((int)0x800704C7), - ResourceInUse = unchecked((int)0x800700AA), - AccessDenied = unchecked((int)0x80030005) - } - - [ComImportAttribute()] - [GuidAttribute("bcc18b79-ba16-442f-80c4-8a59c30c463b")] - [InterfaceTypeAttribute(ComInterfaceType.InterfaceIsIUnknown)] - internal interface IShellItemImageFactory - { - [PreserveSig] - HResult GetImage( - [In, MarshalAs(UnmanagedType.Struct)] NativeSize size, - [In] ThumbnailOptions flags, - [Out] out IntPtr phbm); - } - - [StructLayout(LayoutKind.Sequential)] - internal struct NativeSize - { - private int width; - private int height; - - public int Width { set { width = value; } } - public int Height { set { height = value; } } - }; + private static readonly HRESULT S_EXTRACTIONFAILED = (HRESULT)0x8004B200; + private static readonly HRESULT S_PATHNOTFOUND = (HRESULT)0x8004B205; public static BitmapSource GetThumbnail(string fileName, int width, int height, ThumbnailOptions options) { - IntPtr hBitmap = GetHBitmap(Path.GetFullPath(fileName), width, height, options); + HBITMAP hBitmap = GetHBitmap(Path.GetFullPath(fileName), width, height, options); try { @@ -115,39 +46,67 @@ namespace Flow.Launcher.Infrastructure.Image finally { // delete HBitmap to avoid memory leaks - DeleteObject(hBitmap); + PInvoke.DeleteObject(hBitmap); } } - - private static IntPtr GetHBitmap(string fileName, int width, int height, ThumbnailOptions options) - { - IShellItem nativeShellItem; - Guid shellItem2Guid = new Guid(IShellItem2Guid); - int retCode = SHCreateItemFromParsingName(fileName, IntPtr.Zero, ref shellItem2Guid, out nativeShellItem); - if (retCode != 0) + private static unsafe HBITMAP GetHBitmap(string fileName, int width, int height, ThumbnailOptions options) + { + var retCode = PInvoke.SHCreateItemFromParsingName( + fileName, + null, + GUID_IShellItem, + out var nativeShellItem); + + if (retCode != HRESULT.S_OK) throw Marshal.GetExceptionForHR(retCode); - NativeSize nativeSize = new NativeSize + if (nativeShellItem is not IShellItemImageFactory imageFactory) { - Width = width, - Height = height - }; - - IntPtr hBitmap; - HResult hr = ((IShellItemImageFactory)nativeShellItem).GetImage(nativeSize, options, out hBitmap); - - // if extracting image thumbnail and failed, extract shell icon - if (options == ThumbnailOptions.ThumbnailOnly && hr == HResult.ExtractionFailed) - { - hr = ((IShellItemImageFactory) nativeShellItem).GetImage(nativeSize, ThumbnailOptions.IconOnly, out hBitmap); + Marshal.ReleaseComObject(nativeShellItem); + nativeShellItem = null; + throw new InvalidOperationException("Failed to get IShellItemImageFactory"); } - Marshal.ReleaseComObject(nativeShellItem); + SIZE size = new SIZE + { + cx = width, + cy = height + }; - if (hr == HResult.Ok) return hBitmap; + HBITMAP hBitmap = default; + try + { + try + { + imageFactory.GetImage(size, (SIIGBF)options, &hBitmap); + } + catch (COMException ex) when (options == ThumbnailOptions.ThumbnailOnly && + (ex.HResult == S_PATHNOTFOUND || ex.HResult == S_EXTRACTIONFAILED)) + { + // Fallback to IconOnly if extraction fails or files cannot be found + imageFactory.GetImage(size, (SIIGBF)ThumbnailOptions.IconOnly, &hBitmap); + } + catch (FileNotFoundException) when (options == ThumbnailOptions.ThumbnailOnly) + { + // Fallback to IconOnly if files cannot be found + imageFactory.GetImage(size, (SIIGBF)ThumbnailOptions.IconOnly, &hBitmap); + } + catch (System.Exception ex) + { + // Handle other exceptions + throw new InvalidOperationException("Failed to get thumbnail", ex); + } + } + finally + { + if (nativeShellItem != null) + { + Marshal.ReleaseComObject(nativeShellItem); + } + } - throw new COMException($"Error while extracting thumbnail for {fileName}", Marshal.GetExceptionForHR((int)hr)); + return hBitmap; } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Infrastructure/Logger/Log.cs b/Flow.Launcher.Infrastructure/Logger/Log.cs index d4bd473ac..09eb98f46 100644 --- a/Flow.Launcher.Infrastructure/Logger/Log.cs +++ b/Flow.Launcher.Infrastructure/Logger/Log.cs @@ -1,24 +1,24 @@ using System.Diagnostics; using System.IO; using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using Flow.Launcher.Infrastructure.UserSettings; using NLog; using NLog.Config; using NLog.Targets; -using Flow.Launcher.Infrastructure.UserSettings; using NLog.Targets.Wrappers; -using System.Runtime.ExceptionServices; namespace Flow.Launcher.Infrastructure.Logger { public static class Log { - public const string DirectoryName = "Logs"; + public const string DirectoryName = Constant.Logs; public static string CurrentLogDirectory { get; } static Log() { - CurrentLogDirectory = Path.Combine(DataLocation.DataDirectory(), DirectoryName, Constant.Version); + CurrentLogDirectory = DataLocation.VersionLogDirectory; if (!Directory.Exists(CurrentLogDirectory)) { Directory.CreateDirectory(CurrentLogDirectory); @@ -48,17 +48,45 @@ namespace Flow.Launcher.Infrastructure.Logger configuration.AddTarget("file", fileTargetASyncWrapper); configuration.AddTarget("debug", debugTarget); + var fileRule = new LoggingRule("*", LogLevel.Debug, fileTargetASyncWrapper) + { + RuleName = "file" + }; #if DEBUG - var fileRule = new LoggingRule("*", LogLevel.Debug, fileTargetASyncWrapper); - var debugRule = new LoggingRule("*", LogLevel.Debug, debugTarget); + var debugRule = new LoggingRule("*", LogLevel.Debug, debugTarget) + { + RuleName = "debug" + }; configuration.LoggingRules.Add(debugRule); -#else - var fileRule = new LoggingRule("*", LogLevel.Info, fileTargetASyncWrapper); #endif configuration.LoggingRules.Add(fileRule); LogManager.Configuration = configuration; } + public static void SetLogLevel(LOGLEVEL level) + { + switch (level) + { + case LOGLEVEL.DEBUG: + UseDebugLogLevel(); + break; + default: + UseInfoLogLevel(); + break; + } + Info(nameof(Logger), $"Using log level: {level}."); + } + + private static void UseDebugLogLevel() + { + LogManager.Configuration.FindRuleByName("file").SetLoggingLevels(LogLevel.Debug, LogLevel.Fatal); + } + + private static void UseInfoLogLevel() + { + LogManager.Configuration.FindRuleByName("file").SetLoggingLevels(LogLevel.Info, LogLevel.Fatal); + } + private static void LogFaultyFormat(string message) { var logger = LogManager.GetLogger("FaultyLogger"); @@ -66,13 +94,6 @@ namespace Flow.Launcher.Infrastructure.Logger logger.Fatal(message); } - private static bool FormatValid(string message) - { - var parts = message.Split('|'); - var valid = parts.Length == 3 && !string.IsNullOrWhiteSpace(parts[1]) && !string.IsNullOrWhiteSpace(parts[2]); - return valid; - } - public static void Exception(string className, string message, System.Exception exception, [CallerMemberName] string methodName = "") { exception = exception.Demystify(); @@ -107,57 +128,14 @@ namespace Flow.Launcher.Infrastructure.Logger return className; } +#if !DEBUG private static void ExceptionInternal(string classAndMethod, string message, System.Exception e) { var logger = LogManager.GetLogger(classAndMethod); logger.Error(e, message); } - - private static void LogInternal(string message, LogLevel level) - { - if (FormatValid(message)) - { - var parts = message.Split('|'); - var prefix = parts[1]; - var unprefixed = parts[2]; - var logger = LogManager.GetLogger(prefix); - logger.Log(level, unprefixed); - } - else - { - LogFaultyFormat(message); - } - } - - /// Example: "|ClassName.MethodName|Message" - /// Example: "|ClassName.MethodName|Message" - /// Exception - public static void Exception(string message, System.Exception e) - { - e = e.Demystify(); -#if DEBUG - ExceptionDispatchInfo.Capture(e).Throw(); -#else - if (FormatValid(message)) - { - var parts = message.Split('|'); - var prefix = parts[1]; - var unprefixed = parts[2]; - ExceptionInternal(prefix, unprefixed, e); - } - else - { - LogFaultyFormat(message); - } #endif - } - - /// Example: "|ClassName.MethodName|Message" - public static void Error(string message) - { - LogInternal(message, LogLevel.Error); - } public static void Error(string className, string message, [CallerMemberName] string methodName = "") { @@ -178,32 +156,20 @@ namespace Flow.Launcher.Infrastructure.Logger LogInternal(LogLevel.Debug, className, message, methodName); } - /// Example: "|ClassName.MethodName|Message"" - public static void Debug(string message) - { - LogInternal(message, LogLevel.Debug); - } - public static void Info(string className, string message, [CallerMemberName] string methodName = "") { LogInternal(LogLevel.Info, className, message, methodName); } - /// Example: "|ClassName.MethodName|Message" - public static void Info(string message) - { - LogInternal(message, LogLevel.Info); - } - public static void Warn(string className, string message, [CallerMemberName] string methodName = "") { LogInternal(LogLevel.Warn, className, message, methodName); } + } - /// Example: "|ClassName.MethodName|Message" - public static void Warn(string message) - { - LogInternal(message, LogLevel.Warn); - } + public enum LOGLEVEL + { + DEBUG, + INFO } } diff --git a/Flow.Launcher.Infrastructure/MonitorInfo.cs b/Flow.Launcher.Infrastructure/MonitorInfo.cs new file mode 100644 index 000000000..3221708c1 --- /dev/null +++ b/Flow.Launcher.Infrastructure/MonitorInfo.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using System; +using System.Runtime.InteropServices; +using System.Windows; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Gdi; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Flow.Launcher.Infrastructure; + +/// +/// Contains full information about a display monitor. +/// Codes are edited from: . +/// +internal class MonitorInfo +{ + /// + /// Gets the display monitors (including invisible pseudo-monitors associated with the mirroring drivers). + /// + /// A list of display monitors + public static unsafe IList GetDisplayMonitors() + { + var monitorCount = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CMONITORS); + var list = new List(monitorCount); + var callback = new MONITORENUMPROC((HMONITOR monitor, HDC deviceContext, RECT* rect, LPARAM data) => + { + list.Add(new MonitorInfo(monitor, rect)); + return true; + }); + var dwData = new LPARAM(); + var hdc = new HDC(); + bool ok = PInvoke.EnumDisplayMonitors(hdc, (RECT?)null, callback, dwData); + if (!ok) + { + Marshal.ThrowExceptionForHR(Marshal.GetLastWin32Error()); + } + return list; + } + + /// + /// Gets the display monitor that is nearest to a given window. + /// + /// Window handle + /// The display monitor that is nearest to a given window, or null if no monitor is found. + public static unsafe MonitorInfo GetNearestDisplayMonitor(HWND hwnd) + { + var nearestMonitor = PInvoke.MonitorFromWindow(hwnd, MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST); + MonitorInfo nearestMonitorInfo = null; + var callback = new MONITORENUMPROC((HMONITOR monitor, HDC deviceContext, RECT* rect, LPARAM data) => + { + if (monitor == nearestMonitor) + { + nearestMonitorInfo = new MonitorInfo(monitor, rect); + return false; + } + return true; + }); + var dwData = new LPARAM(); + var hdc = new HDC(); + bool ok = PInvoke.EnumDisplayMonitors(hdc, (RECT?)null, callback, dwData); + if (!ok) + { + Marshal.ThrowExceptionForHR(Marshal.GetLastWin32Error()); + } + return nearestMonitorInfo; + } + + private readonly HMONITOR _monitor; + + internal unsafe MonitorInfo(HMONITOR monitor, RECT* rect) + { + RectMonitor = + new Rect(new Point(rect->left, rect->top), + new Point(rect->right, rect->bottom)); + _monitor = monitor; + var info = new MONITORINFOEXW() { monitorInfo = new MONITORINFO() { cbSize = (uint)sizeof(MONITORINFOEXW) } }; + GetMonitorInfo(monitor, ref info); + RectWork = + new Rect(new Point(info.monitorInfo.rcWork.left, info.monitorInfo.rcWork.top), + new Point(info.monitorInfo.rcWork.right, info.monitorInfo.rcWork.bottom)); + Name = new string(info.szDevice.AsSpan()).Replace("\0", "").Trim(); + } + + /// + /// Gets the name of the display. + /// + public string Name { get; } + + /// + /// Gets the display monitor rectangle, expressed in virtual-screen coordinates. + /// + /// + /// If the monitor is not the primary display monitor, some of the rectangle's coordinates may be negative values. + /// + public Rect RectMonitor { get; } + + /// + /// Gets the work area rectangle of the display monitor, expressed in virtual-screen coordinates. + /// + /// + /// If the monitor is not the primary display monitor, some of the rectangle's coordinates may be negative values. + /// + public Rect RectWork { get; } + + /// + /// Gets if the monitor is the the primary display monitor. + /// + public bool IsPrimary => _monitor == PInvoke.MonitorFromWindow(new(IntPtr.Zero), MONITOR_FROM_FLAGS.MONITOR_DEFAULTTOPRIMARY); + + /// + public override string ToString() => $"{Name} {RectMonitor.Width}x{RectMonitor.Height}"; + + private static unsafe bool GetMonitorInfo(HMONITOR hMonitor, ref MONITORINFOEXW lpmi) + { + fixed (MONITORINFOEXW* lpmiLocal = &lpmi) + { + var lpmiBase = (MONITORINFO*)lpmiLocal; + var __result = PInvoke.GetMonitorInfo(hMonitor, lpmiBase); + return __result; + } + } +} diff --git a/Flow.Launcher.Infrastructure/NativeMethods.txt b/Flow.Launcher.Infrastructure/NativeMethods.txt new file mode 100644 index 000000000..53c877c4f --- /dev/null +++ b/Flow.Launcher.Infrastructure/NativeMethods.txt @@ -0,0 +1,63 @@ +SHCreateItemFromParsingName +DeleteObject +IShellItem +IShellItemImageFactory +S_OK + +SetWindowsHookEx +UnhookWindowsHookEx +CallNextHookEx +GetModuleHandle +GetKeyState +VIRTUAL_KEY + +EnumWindows + +DwmSetWindowAttribute +DWM_SYSTEMBACKDROP_TYPE +DWM_WINDOW_CORNER_PREFERENCE + +MAX_PATH +SystemParametersInfo + +SetForegroundWindow + +WINDOW_LONG_PTR_INDEX +GetForegroundWindow +GetDesktopWindow +GetShellWindow +GetWindowRect +GetClassName +FindWindowEx +WINDOW_STYLE + +SetLastError +WINDOW_EX_STYLE + +GetSystemMetrics +EnumDisplayMonitors +MonitorFromWindow +GetMonitorInfo +MONITORINFOEXW + +WM_ENTERSIZEMOVE +WM_EXITSIZEMOVE + +OleInitialize +OleUninitialize + +GetKeyboardLayout +GetWindowThreadProcessId +ActivateKeyboardLayout +GetKeyboardLayoutList +PostMessage +WM_INPUTLANGCHANGEREQUEST +INPUTLANGCHANGE_FORWARD +LOCALE_TRANSIENT_KEYBOARD1 +LOCALE_TRANSIENT_KEYBOARD2 +LOCALE_TRANSIENT_KEYBOARD3 +LOCALE_TRANSIENT_KEYBOARD4 + +SHParseDisplayName +SHOpenFolderAndSelectItems +CoTaskMemFree diff --git a/Flow.Launcher.Infrastructure/PInvokeExtensions.cs b/Flow.Launcher.Infrastructure/PInvokeExtensions.cs new file mode 100644 index 000000000..18b992043 --- /dev/null +++ b/Flow.Launcher.Infrastructure/PInvokeExtensions.cs @@ -0,0 +1,45 @@ +using System.Runtime.InteropServices; +using Windows.Win32.Foundation; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Windows.Win32; + +internal static partial class PInvoke +{ + // SetWindowLong + // Edited from: https://github.com/files-community/Files + + [DllImport("User32", EntryPoint = "SetWindowLongW", ExactSpelling = true)] + private static extern int _SetWindowLong(HWND hWnd, int nIndex, int dwNewLong); + + [DllImport("User32", EntryPoint = "SetWindowLongPtrW", ExactSpelling = true)] + private static extern nint _SetWindowLongPtr(HWND hWnd, int nIndex, nint dwNewLong); + + // NOTE: + // CsWin32 doesn't generate SetWindowLong on other than x86 and vice versa. + // For more info, visit https://github.com/microsoft/CsWin32/issues/882 + public static unsafe nint SetWindowLongPtr(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex, nint dwNewLong) + { + return sizeof(nint) is 4 + ? _SetWindowLong(hWnd, (int)nIndex, (int)dwNewLong) + : _SetWindowLongPtr(hWnd, (int)nIndex, dwNewLong); + } + + // GetWindowLong + + [DllImport("User32", EntryPoint = "GetWindowLongW", ExactSpelling = true)] + private static extern int _GetWindowLong(HWND hWnd, int nIndex); + + [DllImport("User32", EntryPoint = "GetWindowLongPtrW", ExactSpelling = true)] + private static extern nint _GetWindowLongPtr(HWND hWnd, int nIndex); + + // NOTE: + // CsWin32 doesn't generate GetWindowLong on other than x86 and vice versa. + // For more info, visit https://github.com/microsoft/CsWin32/issues/882 + public static unsafe nint GetWindowLongPtr(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex) + { + return sizeof(nint) is 4 + ? _GetWindowLong(hWnd, (int)nIndex) + : _GetWindowLongPtr(hWnd, (int)nIndex); + } +} diff --git a/Flow.Launcher.Infrastructure/PinyinAlphabet.cs b/Flow.Launcher.Infrastructure/PinyinAlphabet.cs index 7d7235968..8eaa757be 100644 --- a/Flow.Launcher.Infrastructure/PinyinAlphabet.cs +++ b/Flow.Launcher.Infrastructure/PinyinAlphabet.cs @@ -6,6 +6,7 @@ using System.Text; using JetBrains.Annotations; using Flow.Launcher.Infrastructure.UserSettings; using ToolGood.Words.Pinyin; +using CommunityToolkit.Mvvm.DependencyInjection; namespace Flow.Launcher.Infrastructure { @@ -129,7 +130,12 @@ namespace Flow.Launcher.Infrastructure private Settings _settings; - public void Initialize([NotNull] Settings settings) + public PinyinAlphabet() + { + Initialize(Ioc.Default.GetRequiredService()); + } + + private void Initialize([NotNull] Settings settings) { _settings = settings ?? throw new ArgumentNullException(nameof(settings)); } diff --git a/Flow.Launcher.Infrastructure/Stopwatch.cs b/Flow.Launcher.Infrastructure/Stopwatch.cs index dd6edaff9..870e0fe26 100644 --- a/Flow.Launcher.Infrastructure/Stopwatch.cs +++ b/Flow.Launcher.Infrastructure/Stopwatch.cs @@ -1,5 +1,5 @@ using System; -using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using Flow.Launcher.Infrastructure.Logger; @@ -7,91 +7,54 @@ namespace Flow.Launcher.Infrastructure { public static class Stopwatch { - private static readonly Dictionary Count = new Dictionary(); - private static readonly object Locker = new object(); /// /// This stopwatch will appear only in Debug mode /// - public static long Debug(string message, Action action) + public static long Debug(string className, string message, Action action, [CallerMemberName] string methodName = "") { var stopWatch = new System.Diagnostics.Stopwatch(); stopWatch.Start(); action(); stopWatch.Stop(); var milliseconds = stopWatch.ElapsedMilliseconds; - string info = $"{message} <{milliseconds}ms>"; - Log.Debug(info); + Log.Debug(className, $"{message} <{milliseconds}ms>", methodName); return milliseconds; } /// /// This stopwatch will appear only in Debug mode /// - public static async Task DebugAsync(string message, Func action) + public static async Task DebugAsync(string className, string message, Func action, [CallerMemberName] string methodName = "") { var stopWatch = new System.Diagnostics.Stopwatch(); stopWatch.Start(); await action(); stopWatch.Stop(); var milliseconds = stopWatch.ElapsedMilliseconds; - string info = $"{message} <{milliseconds}ms>"; - Log.Debug(info); + Log.Debug(className, $"{message} <{milliseconds}ms>", methodName); return milliseconds; } - public static long Normal(string message, Action action) + public static long Info(string className, string message, Action action, [CallerMemberName] string methodName = "") { var stopWatch = new System.Diagnostics.Stopwatch(); stopWatch.Start(); action(); stopWatch.Stop(); var milliseconds = stopWatch.ElapsedMilliseconds; - string info = $"{message} <{milliseconds}ms>"; - Log.Info(info); + Log.Info(className, $"{message} <{milliseconds}ms>", methodName); return milliseconds; } - public static async Task NormalAsync(string message, Func action) + public static async Task InfoAsync(string className, string message, Func action, [CallerMemberName] string methodName = "") { var stopWatch = new System.Diagnostics.Stopwatch(); stopWatch.Start(); await action(); stopWatch.Stop(); var milliseconds = stopWatch.ElapsedMilliseconds; - string info = $"{message} <{milliseconds}ms>"; - Log.Info(info); + Log.Info(className, $"{message} <{milliseconds}ms>", methodName); return milliseconds; } - - - - public static void StartCount(string name, Action action) - { - var stopWatch = new System.Diagnostics.Stopwatch(); - stopWatch.Start(); - action(); - stopWatch.Stop(); - var milliseconds = stopWatch.ElapsedMilliseconds; - lock (Locker) - { - if (Count.ContainsKey(name)) - { - Count[name] += milliseconds; - } - else - { - Count[name] = 0; - } - } - } - - public static void EndCount() - { - foreach (var key in Count.Keys) - { - string info = $"{key} already cost {Count[key]}ms"; - Log.Debug(info); - } - } } } diff --git a/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs b/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs index 2a439b8cc..48e6b5523 100644 --- a/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs @@ -1,14 +1,14 @@ using System; using System.IO; -using System.Reflection; -using System.Runtime.Serialization; -using System.Runtime.Serialization.Formatters; -using System.Runtime.Serialization.Formatters.Binary; using System.Threading.Tasks; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; using MemoryPack; +#nullable enable + namespace Flow.Launcher.Infrastructure.Storage { /// @@ -16,64 +16,111 @@ namespace Flow.Launcher.Infrastructure.Storage /// Normally, it has better performance, but not readable /// /// - /// It utilize MemoryPack, which means the object must be MemoryPackSerializable - /// https://github.com/Cysharp/MemoryPack + /// It utilizes MemoryPack, which means the object must be MemoryPackSerializable /// - public class BinaryStorage + public class BinaryStorage : ISavable { - const string DirectoryName = "Cache"; + private static readonly string ClassName = "BinaryStorage"; - const string FileSuffix = ".cache"; + protected T? Data; + + public const string FileSuffix = ".cache"; + + protected string FilePath { get; init; } = null!; + + protected string DirectoryPath { get; init; } = null!; + + // Let the derived class to set the file path + protected BinaryStorage() + { + } public BinaryStorage(string filename) { - var directoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName); - Helper.ValidateDirectory(directoryPath); + DirectoryPath = DataLocation.CacheDirectory; + FilesFolders.ValidateDirectory(DirectoryPath); - FilePath = Path.Combine(directoryPath, $"{filename}{FileSuffix}"); + FilePath = Path.Combine(DirectoryPath, $"{filename}{FileSuffix}"); } - public string FilePath { get; } + // Let the old Program plugin get this constructor + [Obsolete("This constructor is obsolete. Use BinaryStorage(string filename) instead.")] + public BinaryStorage(string filename, string directoryPath = null!) + { + DirectoryPath = directoryPath ?? DataLocation.CacheDirectory; + FilesFolders.ValidateDirectory(DirectoryPath); + + FilePath = Path.Combine(DirectoryPath, $"{filename}{FileSuffix}"); + } public async ValueTask TryLoadAsync(T defaultData) { + if (Data != null) return Data; + if (File.Exists(FilePath)) { if (new FileInfo(FilePath).Length == 0) { - Log.Error($"|BinaryStorage.TryLoad|Zero length cache file <{FilePath}>"); - await SaveAsync(defaultData); - return defaultData; + Log.Error(ClassName, $"Zero length cache file <{FilePath}>"); + Data = defaultData; + await SaveAsync(); } await using var stream = new FileStream(FilePath, FileMode.Open); - var d = await DeserializeAsync(stream, defaultData); - return d; + Data = await DeserializeAsync(stream, defaultData); } else { - Log.Info("|BinaryStorage.TryLoad|Cache file not exist, load default data"); - await SaveAsync(defaultData); - return defaultData; + Log.Info(ClassName, "Cache file not exist, load default data"); + Data = defaultData; + await SaveAsync(); } + + return Data; } - private async ValueTask DeserializeAsync(Stream stream, T defaultData) + private static async ValueTask DeserializeAsync(Stream stream, T defaultData) { try { var t = await MemoryPackSerializer.DeserializeAsync(stream); - return t; + return t ?? defaultData; } - catch (System.Exception e) + catch (System.Exception) { // Log.Exception($"|BinaryStorage.Deserialize|Deserialize error for file <{FilePath}>", e); return defaultData; } } + public void Save() + { + // User may delete the directory, so we need to check it + FilesFolders.ValidateDirectory(DirectoryPath); + + var serialized = MemoryPackSerializer.Serialize(Data); + File.WriteAllBytes(FilePath, serialized); + } + + public async ValueTask SaveAsync() + { + await SaveAsync(Data.NonNull()); + } + + // ImageCache need to convert data into concurrent dictionary for usage, + // so we would better to clear the data + public void ClearData() + { + Data = default; + } + + // ImageCache storages data in its class, + // so we need to pass it to SaveAsync public async ValueTask SaveAsync(T data) { + // User may delete the directory, so we need to check it + FilesFolders.ValidateDirectory(DirectoryPath); + await using var stream = new FileStream(FilePath, FileMode.Create); await MemoryPackSerializer.SerializeAsync(stream, data); } diff --git a/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs b/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs index 865041fb3..158e0cdf5 100644 --- a/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs @@ -1,17 +1,46 @@ using System.IO; +using System.Threading.Tasks; +using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin.SharedCommands; namespace Flow.Launcher.Infrastructure.Storage { public class FlowLauncherJsonStorage : JsonStorage where T : new() { + private static readonly string ClassName = "FlowLauncherJsonStorage"; + public FlowLauncherJsonStorage() { - var directoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName); - Helper.ValidateDirectory(directoryPath); + DirectoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName); + FilesFolders.ValidateDirectory(DirectoryPath); var filename = typeof(T).Name; - FilePath = Path.Combine(directoryPath, $"{filename}{FileSuffix}"); + FilePath = Path.Combine(DirectoryPath, $"{filename}{FileSuffix}"); + } + + public new void Save() + { + try + { + base.Save(); + } + catch (System.Exception e) + { + Log.Exception(ClassName, $"Failed to save FL settings to path: {FilePath}", e); + } + } + + public new async Task SaveAsync() + { + try + { + await base.SaveAsync(); + } + catch (System.Exception e) + { + Log.Exception(ClassName, $"Failed to save FL settings to path: {FilePath}", e); + } } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs b/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs index 642250627..c7eba05fd 100644 --- a/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs @@ -1,22 +1,27 @@ -#nullable enable -using System; +using System; using System.Globalization; using System.IO; using System.Text.Json; using System.Threading.Tasks; using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; + +#nullable enable namespace Flow.Launcher.Infrastructure.Storage { /// /// Serialize object using json format. /// - public class JsonStorage where T : new() + public class JsonStorage : ISavable where T : new() { + private static readonly string ClassName = "JsonStorage"; + protected T? Data; // need a new directory name - public const string DirectoryName = "Settings"; + public const string DirectoryName = Constant.Settings; public const string FileSuffix = ".json"; protected string FilePath { get; init; } = null!; @@ -31,12 +36,29 @@ namespace Flow.Launcher.Infrastructure.Storage protected JsonStorage() { } + public JsonStorage(string filePath) { FilePath = filePath; DirectoryPath = Path.GetDirectoryName(filePath) ?? throw new ArgumentException("Invalid file path"); - - Helper.ValidateDirectory(DirectoryPath); + + FilesFolders.ValidateDirectory(DirectoryPath); + } + + public bool Exists() + { + return File.Exists(FilePath); + } + + public void Delete() + { + foreach (var path in new[] { FilePath, BackupFilePath, TempFilePath }) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } } public async Task LoadAsync() @@ -97,9 +119,10 @@ namespace Flow.Launcher.Infrastructure.Storage return default; } } + private void RestoreBackup() { - Log.Info($"|JsonStorage.Load|Failed to load settings.json, {BackupFilePath} restored successfully"); + Log.Info(ClassName, $"Failed to load settings.json, {BackupFilePath} restored successfully"); if (File.Exists(FilePath)) File.Replace(BackupFilePath, FilePath, null); @@ -178,26 +201,28 @@ namespace Flow.Launcher.Infrastructure.Storage public void Save() { - string serialized = JsonSerializer.Serialize(Data, - new JsonSerializerOptions - { - WriteIndented = true - }); + // User may delete the directory, so we need to check it + FilesFolders.ValidateDirectory(DirectoryPath); + + var serialized = JsonSerializer.Serialize(Data, + new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(TempFilePath, serialized); AtomicWriteSetting(); } + public async Task SaveAsync() { - var tempOutput = File.OpenWrite(TempFilePath); + // User may delete the directory, so we need to check it + FilesFolders.ValidateDirectory(DirectoryPath); + + await using var tempOutput = File.OpenWrite(TempFilePath); await JsonSerializer.SerializeAsync(tempOutput, Data, - new JsonSerializerOptions - { - WriteIndented = true - }); + new JsonSerializerOptions { WriteIndented = true }); AtomicWriteSetting(); } + private void AtomicWriteSetting() { if (!File.Exists(FilePath)) @@ -206,9 +231,9 @@ namespace Flow.Launcher.Infrastructure.Storage } else { - File.Replace(TempFilePath, FilePath, BackupFilePath); + var finalFilePath = new FileInfo(FilePath).LinkTarget ?? FilePath; + File.Replace(TempFilePath, finalFilePath, BackupFilePath); } } - } } diff --git a/Flow.Launcher.Infrastructure/Storage/PluginBinaryStorage.cs b/Flow.Launcher.Infrastructure/Storage/PluginBinaryStorage.cs new file mode 100644 index 000000000..01da96d62 --- /dev/null +++ b/Flow.Launcher.Infrastructure/Storage/PluginBinaryStorage.cs @@ -0,0 +1,44 @@ +using System.IO; +using System.Threading.Tasks; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Plugin.SharedCommands; + +namespace Flow.Launcher.Infrastructure.Storage +{ + public class PluginBinaryStorage : BinaryStorage where T : new() + { + private static readonly string ClassName = "PluginBinaryStorage"; + + public PluginBinaryStorage(string cacheName, string cacheDirectory) + { + DirectoryPath = cacheDirectory; + FilesFolders.ValidateDirectory(DirectoryPath); + + FilePath = Path.Combine(DirectoryPath, $"{cacheName}{FileSuffix}"); + } + + public new void Save() + { + try + { + base.Save(); + } + catch (System.Exception e) + { + Log.Exception(ClassName, $"Failed to save plugin caches to path: {FilePath}", e); + } + } + + public new async Task SaveAsync() + { + try + { + await base.SaveAsync(); + } + catch (System.Exception e) + { + Log.Exception(ClassName, $"Failed to save plugin caches to path: {FilePath}", e); + } + } + } +} diff --git a/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs b/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs index abe3f55b5..147152949 100644 --- a/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs @@ -1,17 +1,25 @@ using System.IO; +using System.Threading.Tasks; +using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin.SharedCommands; namespace Flow.Launcher.Infrastructure.Storage { - public class PluginJsonStorage :JsonStorage where T : new() + public class PluginJsonStorage : JsonStorage where T : new() { + // Use assembly name to check which plugin is using this storage + public readonly string AssemblyName; + + private static readonly string ClassName = "PluginJsonStorage"; + public PluginJsonStorage() { // C# related, add python related below var dataType = typeof(T); - var assemblyName = dataType.Assembly.GetName().Name; - DirectoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName, Constant.Plugins, assemblyName); - Helper.ValidateDirectory(DirectoryPath); + AssemblyName = dataType.Assembly.GetName().Name; + DirectoryPath = Path.Combine(DataLocation.PluginSettingsDirectory, AssemblyName); + FilesFolders.ValidateDirectory(DirectoryPath); FilePath = Path.Combine(DirectoryPath, $"{dataType.Name}{FileSuffix}"); } @@ -20,6 +28,29 @@ namespace Flow.Launcher.Infrastructure.Storage { Data = data; } + + public new void Save() + { + try + { + base.Save(); + } + catch (System.Exception e) + { + Log.Exception(ClassName, $"Failed to save plugin settings to path: {FilePath}", e); + } + } + + public new async Task SaveAsync() + { + try + { + await base.SaveAsync(); + } + catch (System.Exception e) + { + Log.Exception(ClassName, $"Failed to save plugin settings to path: {FilePath}", e); + } + } } } - diff --git a/Flow.Launcher.Infrastructure/StringMatcher.cs b/Flow.Launcher.Infrastructure/StringMatcher.cs index bd5dbdda9..e85c5d6f4 100644 --- a/Flow.Launcher.Infrastructure/StringMatcher.cs +++ b/Flow.Launcher.Infrastructure/StringMatcher.cs @@ -1,28 +1,35 @@ -using Flow.Launcher.Plugin.SharedModels; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Plugin.SharedModels; using System; using System.Collections.Generic; using System.Linq; +using Flow.Launcher.Infrastructure.UserSettings; namespace Flow.Launcher.Infrastructure { public class StringMatcher { - private readonly MatchOption _defaultMatchOption = new MatchOption(); + private readonly MatchOption _defaultMatchOption = new(); public SearchPrecisionScore UserSettingSearchPrecision { get; set; } private readonly IAlphabet _alphabet; - public StringMatcher(IAlphabet alphabet = null) + public StringMatcher(IAlphabet alphabet, Settings settings) + { + _alphabet = alphabet; + UserSettingSearchPrecision = settings.QuerySearchPrecision; + } + + // This is a workaround to allow unit tests to set the instance + public StringMatcher(IAlphabet alphabet) { _alphabet = alphabet; } - public static StringMatcher Instance { get; internal set; } - public static MatchResult FuzzySearch(string query, string stringToCompare) { - return Instance.FuzzyMatch(query, stringToCompare); + return Ioc.Default.GetRequiredService().FuzzyMatch(query, stringToCompare); } public MatchResult FuzzyMatch(string query, string stringToCompare) @@ -241,16 +248,16 @@ namespace Flow.Launcher.Infrastructure return false; } - private bool IsAcronymChar(string stringToCompare, int compareStringIndex) + private static bool IsAcronymChar(string stringToCompare, int compareStringIndex) => char.IsUpper(stringToCompare[compareStringIndex]) || compareStringIndex == 0 || // 0 index means char is the start of the compare string, which is an acronym char.IsWhiteSpace(stringToCompare[compareStringIndex - 1]); - private bool IsAcronymNumber(string stringToCompare, int compareStringIndex) + private static bool IsAcronymNumber(string stringToCompare, int compareStringIndex) => stringToCompare[compareStringIndex] >= 0 && stringToCompare[compareStringIndex] <= 9; // To get the index of the closest space which preceeds the first matching index - private int CalculateClosestSpaceIndex(List spaceIndices, int firstMatchIndex) + private static int CalculateClosestSpaceIndex(List spaceIndices, int firstMatchIndex) { var closestSpaceIndex = -1; diff --git a/Flow.Launcher.Infrastructure/UI/EnumBindingSource.cs b/Flow.Launcher.Infrastructure/UI/EnumBindingSource.cs index 350c892cf..f9504e6d9 100644 --- a/Flow.Launcher.Infrastructure/UI/EnumBindingSource.cs +++ b/Flow.Launcher.Infrastructure/UI/EnumBindingSource.cs @@ -3,6 +3,7 @@ using System.Windows.Markup; namespace Flow.Launcher.Infrastructure.UI { + [Obsolete("EnumBindingSourceExtension is obsolete. Use with Flow.Launcher.Localization NuGet package instead.")] public class EnumBindingSourceExtension : MarkupExtension { private Type _enumType; diff --git a/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs b/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs index 71020369a..2d15b54c5 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs @@ -1,15 +1,15 @@ using System; using System.Text.Json.Serialization; +using System.Threading.Tasks; namespace Flow.Launcher.Infrastructure.UserSettings { + #region Base + public abstract class ShortcutBaseModel { public string Key { get; set; } - [JsonIgnore] - public Func Expand { get; set; } = () => { return ""; }; - public override bool Equals(object obj) { return obj is ShortcutBaseModel other && @@ -22,16 +22,14 @@ namespace Flow.Launcher.Infrastructure.UserSettings } } - public class CustomShortcutModel : ShortcutBaseModel + public class BaseCustomShortcutModel : ShortcutBaseModel { public string Value { get; set; } - [JsonConstructorAttribute] - public CustomShortcutModel(string key, string value) + public BaseCustomShortcutModel(string key, string value) { Key = key; Value = value; - Expand = () => { return Value; }; } public void Deconstruct(out string key, out string value) @@ -40,26 +38,69 @@ namespace Flow.Launcher.Infrastructure.UserSettings value = Value; } - public static implicit operator (string Key, string Value)(CustomShortcutModel shortcut) + public static implicit operator (string Key, string Value)(BaseCustomShortcutModel shortcut) { return (shortcut.Key, shortcut.Value); } - public static implicit operator CustomShortcutModel((string Key, string Value) shortcut) + public static implicit operator BaseCustomShortcutModel((string Key, string Value) shortcut) { - return new CustomShortcutModel(shortcut.Key, shortcut.Value); + return new BaseCustomShortcutModel(shortcut.Key, shortcut.Value); } } - public class BuiltinShortcutModel : ShortcutBaseModel + public class BaseBuiltinShortcutModel : ShortcutBaseModel { public string Description { get; set; } - public BuiltinShortcutModel(string key, string description, Func expand) + public BaseBuiltinShortcutModel(string key, string description) { Key = key; Description = description; - Expand = expand ?? (() => { return ""; }); } } + + #endregion + + #region Custom Shortcut + + public class CustomShortcutModel : BaseCustomShortcutModel + { + [JsonIgnore] + public Func Expand { get; set; } = () => { return string.Empty; }; + + [JsonConstructor] + public CustomShortcutModel(string key, string value) : base(key, value) + { + Expand = () => { return Value; }; + } + } + + #endregion + + #region Builtin Shortcut + + public class BuiltinShortcutModel : BaseBuiltinShortcutModel + { + [JsonIgnore] + public Func Expand { get; set; } = () => { return string.Empty; }; + + public BuiltinShortcutModel(string key, string description, Func expand) : base(key, description) + { + Expand = expand ?? (() => { return string.Empty; }); + } + } + + public class AsyncBuiltinShortcutModel : BaseBuiltinShortcutModel + { + [JsonIgnore] + public Func> ExpandAsync { get; set; } = () => { return Task.FromResult(string.Empty); }; + + public AsyncBuiltinShortcutModel(string key, string description, Func> expandAsync) : base(key, description) + { + ExpandAsync = expandAsync ?? (() => { return Task.FromResult(string.Empty); }); + } + } + + #endregion } diff --git a/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs b/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs index e294f52b8..5b948e450 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs @@ -25,8 +25,16 @@ namespace Flow.Launcher.Infrastructure.UserSettings return false; } + public static string VersionLogDirectory => Path.Combine(LogDirectory, Constant.Version); + public static string LogDirectory => Path.Combine(DataDirectory(), Constant.Logs); + + public static readonly string CacheDirectory = Path.Combine(DataDirectory(), Constant.Cache); + public static readonly string SettingsDirectory = Path.Combine(DataDirectory(), Constant.Settings); public static readonly string PluginsDirectory = Path.Combine(DataDirectory(), Constant.Plugins); - public static readonly string PluginSettingsDirectory = Path.Combine(DataDirectory(), "Settings", Constant.Plugins); + public static readonly string ThemesDirectory = Path.Combine(DataDirectory(), Constant.Themes); + + public static readonly string PluginSettingsDirectory = Path.Combine(SettingsDirectory, Constant.Plugins); + public static readonly string PluginCacheDirectory = Path.Combine(DataDirectory(), Constant.Cache, Constant.Plugins); public const string PythonEnvironmentName = "Python"; public const string NodeEnvironmentName = "Node.js"; diff --git a/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs b/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs index 98f4dccda..920abc284 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Text.Json.Serialization; using Flow.Launcher.Plugin; namespace Flow.Launcher.Infrastructure.UserSettings @@ -6,8 +7,9 @@ namespace Flow.Launcher.Infrastructure.UserSettings public class PluginsSettings : BaseModel { private string pythonExecutablePath = string.Empty; - public string PythonExecutablePath { - get { return pythonExecutablePath; } + public string PythonExecutablePath + { + get => pythonExecutablePath; set { pythonExecutablePath = value; @@ -18,7 +20,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings private string nodeExecutablePath = string.Empty; public string NodeExecutablePath { - get { return nodeExecutablePath; } + get => nodeExecutablePath; set { nodeExecutablePath = value; @@ -26,19 +28,32 @@ namespace Flow.Launcher.Infrastructure.UserSettings } } - public Dictionary Plugins { get; set; } = new Dictionary(); + /// + /// Only used for serialization + /// + public Dictionary Plugins { get; set; } = new(); + /// + /// Update plugin settings with metadata. + /// FL will get default values from metadata first and then load settings to metadata + /// + /// Parsed plugin metadatas public void UpdatePluginSettings(List metadatas) { foreach (var metadata in metadatas) { - if (Plugins.ContainsKey(metadata.ID)) + if (Plugins.TryGetValue(metadata.ID, out var settings)) { - var settings = Plugins[metadata.ID]; - + // If settings exist, update settings & metadata value + // update settings values with metadata if (string.IsNullOrEmpty(settings.Version)) + { settings.Version = metadata.Version; + } + settings.DefaultActionKeywords = metadata.ActionKeywords; // metadata provides default values + settings.DefaultSearchDelayTime = metadata.SearchDelayTime; // metadata provides default values + // update metadata values with settings if (settings.ActionKeywords?.Count > 0) { metadata.ActionKeywords = settings.ActionKeywords; @@ -51,33 +66,70 @@ namespace Flow.Launcher.Infrastructure.UserSettings } metadata.Disabled = settings.Disabled; metadata.Priority = settings.Priority; + metadata.SearchDelayTime = settings.SearchDelayTime; + metadata.HomeDisabled = settings.HomeDisabled; } else { + // If settings does not exist, create a new one Plugins[metadata.ID] = new Plugin { ID = metadata.ID, Name = metadata.Name, Version = metadata.Version, - ActionKeywords = metadata.ActionKeywords, + DefaultActionKeywords = metadata.ActionKeywords, // metadata provides default values + ActionKeywords = metadata.ActionKeywords, // use default value Disabled = metadata.Disabled, - Priority = metadata.Priority + HomeDisabled = metadata.HomeDisabled, + Priority = metadata.Priority, + DefaultSearchDelayTime = metadata.SearchDelayTime, // metadata provides default values + SearchDelayTime = metadata.SearchDelayTime, // use default value }; } } } + + public Plugin GetPluginSettings(string id) + { + if (Plugins.TryGetValue(id, out var plugin)) + { + return plugin; + } + return null; + } + + public Plugin RemovePluginSettings(string id) + { + Plugins.Remove(id, out var plugin); + return plugin; + } } + public class Plugin { public string ID { get; set; } + public string Name { get; set; } + public string Version { get; set; } - public List ActionKeywords { get; set; } // a reference of the action keywords from plugin manager + + [JsonIgnore] + public List DefaultActionKeywords { get; set; } + + // a reference of the action keywords from plugin manager + public List ActionKeywords { get; set; } + public int Priority { get; set; } + [JsonIgnore] + public int? DefaultSearchDelayTime { get; set; } + + public int? SearchDelayTime { get; set; } + /// /// Used only to save the state of the plugin in settings /// public bool Disabled { get; set; } + public bool HomeDisabled { get; set; } } } diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs index 0c7de10fd..892045994 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -1,10 +1,12 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Drawing; using System.Text.Json.Serialization; using System.Windows; +using System.Windows.Media; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Infrastructure.Hotkey; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.Storage; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedModels; using Flow.Launcher.ViewModel; @@ -13,7 +15,24 @@ namespace Flow.Launcher.Infrastructure.UserSettings { public class Settings : BaseModel, IHotkeySettings { - private string language = "en"; + private FlowLauncherJsonStorage _storage; + private StringMatcher _stringMatcher = null; + + public void SetStorage(FlowLauncherJsonStorage storage) + { + _storage = storage; + } + + public void Initialize() + { + _stringMatcher = Ioc.Default.GetRequiredService(); + } + + public void Save() + { + _storage.Save(); + } + private string _theme = Constant.DefaultTheme; public string Hotkey { get; set; } = $"{KeyConstant.Alt} + {KeyConstant.Space}"; public string OpenResultModifiers { get; set; } = KeyConstant.Alt; @@ -31,15 +50,17 @@ namespace Flow.Launcher.Infrastructure.UserSettings public string SelectPrevPageHotkey { get; set; } = $"PageDown"; public string OpenContextMenuHotkey { get; set; } = $"Ctrl+O"; public string SettingWindowHotkey { get; set; } = $"Ctrl+I"; + public string OpenHistoryHotkey { get; set; } = $"Ctrl+H"; public string CycleHistoryUpHotkey { get; set; } = $"{KeyConstant.Alt} + Up"; public string CycleHistoryDownHotkey { get; set; } = $"{KeyConstant.Alt} + Down"; + private string _language = Constant.SystemLanguageCode; public string Language { - get => language; + get => _language; set { - language = value; + _language = value; OnPropertyChanged(); } } @@ -48,30 +69,32 @@ namespace Flow.Launcher.Infrastructure.UserSettings get => _theme; set { - if (value == _theme) - return; - _theme = value; - OnPropertyChanged(); - OnPropertyChanged(nameof(MaxResultsToShow)); + if (value != _theme) + { + _theme = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(MaxResultsToShow)); + } } } public bool UseDropShadowEffect { get; set; } = true; + public BackdropTypes BackdropType{ get; set; } = BackdropTypes.None; /* Appearance Settings. It should be separated from the setting later.*/ public double WindowHeightSize { get; set; } = 42; public double ItemHeightSize { get; set; } = 58; - public double QueryBoxFontSize { get; set; } = 20; + public double QueryBoxFontSize { get; set; } = 16; public double ResultItemFontSize { get; set; } = 16; - public double ResultSubItemFontSize { get; set; } = 13; - public string QueryBoxFont { get; set; } = FontFamily.GenericSansSerif.Name; + public double ResultSubItemFontSize { get; set; } = 13; + public string QueryBoxFont { get; set; } = Win32Helper.GetSystemDefaultFont(); public string QueryBoxFontStyle { get; set; } public string QueryBoxFontWeight { get; set; } public string QueryBoxFontStretch { get; set; } - public string ResultFont { get; set; } = FontFamily.GenericSansSerif.Name; + public string ResultFont { get; set; } = Win32Helper.GetSystemDefaultFont(); public string ResultFontStyle { get; set; } public string ResultFontWeight { get; set; } public string ResultFontStretch { get; set; } - public string ResultSubFont { get; set; } = FontFamily.GenericSansSerif.Name; + public string ResultSubFont { get; set; } = Win32Helper.GetSystemDefaultFont(); public string ResultSubFontStyle { get; set; } public string ResultSubFontWeight { get; set; } public string ResultSubFontStretch { get; set; } @@ -79,6 +102,24 @@ namespace Flow.Launcher.Infrastructure.UserSettings public bool UseAnimation { get; set; } = true; public bool UseSound { get; set; } = true; public double SoundVolume { get; set; } = 50; + public bool ShowBadges { get; set; } = false; + public bool ShowBadgesGlobalOnly { get; set; } = false; + + private string _settingWindowFont { get; set; } = Win32Helper.GetSystemDefaultFont(false); + public string SettingWindowFont + { + get => _settingWindowFont; + set + { + if (_settingWindowFont != value) + { + _settingWindowFont = value; + OnPropertyChanged(); + Application.Current.Resources["SettingWindowFont"] = new FontFamily(value); + Application.Current.Resources["ContentControlThemeFontFamily"] = new FontFamily(value); + } + } + } public bool UseClock { get; set; } = true; public bool UseDate { get; set; } = false; @@ -90,7 +131,64 @@ namespace Flow.Launcher.Infrastructure.UserSettings public double SettingWindowHeight { get; set; } = 700; public double? SettingWindowTop { get; set; } = null; public double? SettingWindowLeft { get; set; } = null; - public System.Windows.WindowState SettingWindowState { get; set; } = WindowState.Normal; + public WindowState SettingWindowState { get; set; } = WindowState.Normal; + + private bool _showPlaceholder { get; set; } = true; + public bool ShowPlaceholder + { + get => _showPlaceholder; + set + { + if (_showPlaceholder != value) + { + _showPlaceholder = value; + OnPropertyChanged(); + } + } + } + private string _placeholderText { get; set; } = string.Empty; + public string PlaceholderText + { + get => _placeholderText; + set + { + if (_placeholderText != value) + { + _placeholderText = value; + OnPropertyChanged(); + } + } + } + + private bool _showHomePage { get; set; } = true; + public bool ShowHomePage + { + get => _showHomePage; + set + { + if (_showHomePage != value) + { + _showHomePage = value; + OnPropertyChanged(); + } + } + } + + private bool _showHistoryResultsForHomePage = false; + public bool ShowHistoryResultsForHomePage + { + get => _showHistoryResultsForHomePage; + set + { + if (_showHistoryResultsForHomePage != value) + { + _showHistoryResultsForHomePage = value; + OnPropertyChanged(); + } + } + } + + public int MaxHistoryResultsToShowForHomePage { get; set; } = 5; public int CustomExplorerIndex { get; set; } = 0; @@ -129,8 +227,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings new() { Name = "Files", - Path = "Files", - DirectoryArgument = "-select \"%d\"", + Path = "Files-Stable", + DirectoryArgument = "\"%d\"", FileArgument = "-select \"%f\"" } }; @@ -180,6 +278,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings } }; + [JsonConverter(typeof(JsonStringEnumConverter))] + public LOGLEVEL LogLevel { get; set; } = LOGLEVEL.INFO; /// /// when false Alphabet static service will always return empty results @@ -187,7 +287,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings public bool ShouldUsePinyin { get; set; } = false; public bool AlwaysPreview { get; set; } = false; - + public bool AlwaysStartEn { get; set; } = false; private SearchPrecisionScore _querySearchPrecision = SearchPrecisionScore.Regular; @@ -198,8 +298,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings set { _querySearchPrecision = value; - if (StringMatcher.Instance != null) - StringMatcher.Instance.UserSettingSearchPrecision = value; + if (_stringMatcher != null) + _stringMatcher.UserSettingSearchPrecision = value; } } @@ -207,6 +307,10 @@ namespace Flow.Launcher.Infrastructure.UserSettings public double WindowLeft { get; set; } public double WindowTop { get; set; } + public double PreviousScreenWidth { get; set; } + public double PreviousScreenHeight { get; set; } + public double PreviousDpiX { get; set; } + public double PreviousDpiY { get; set; } /// /// Custom left position on selected monitor @@ -218,19 +322,35 @@ namespace Flow.Launcher.Infrastructure.UserSettings /// public double CustomWindowTop { get; set; } = 0; - public bool KeepMaxResults { get; set; } = false; - public int MaxResultsToShow { get; set; } = 5; - public int ActivateTimes { get; set; } + /// + /// Fixed window size + /// + private bool _keepMaxResults { get; set; } = false; + public bool KeepMaxResults + { + get => _keepMaxResults; + set + { + if (_keepMaxResults != value) + { + _keepMaxResults = value; + OnPropertyChanged(); + } + } + } + public int MaxResultsToShow { get; set; } = 5; + + public int ActivateTimes { get; set; } public ObservableCollection CustomPluginHotkeys { get; set; } = new ObservableCollection(); public ObservableCollection CustomShortcuts { get; set; } = new ObservableCollection(); [JsonIgnore] - public ObservableCollection BuiltinShortcuts { get; set; } = new() + public ObservableCollection BuiltinShortcuts { get; set; } = new() { - new BuiltinShortcutModel("{clipboard}", "shortcut_clipboard_description", Clipboard.GetText), + new AsyncBuiltinShortcutModel("{clipboard}", "shortcut_clipboard_description", () => Win32Helper.StartSTATaskAsync(Clipboard.GetText)), new BuiltinShortcutModel("{active_explorer_path}", "shortcut_active_explorer_path", FileExplorerHelper.GetActiveExplorerPath) }; @@ -238,11 +358,12 @@ namespace Flow.Launcher.Infrastructure.UserSettings public bool EnableUpdateLog { get; set; } public bool StartFlowLauncherOnSystemStartup { get; set; } = false; + public bool UseLogonTaskForStartup { get; set; } = false; public bool HideOnStartup { get; set; } = true; - bool _hideNotifyIcon { get; set; } + private bool _hideNotifyIcon; public bool HideNotifyIcon { - get { return _hideNotifyIcon; } + get => _hideNotifyIcon; set { _hideNotifyIcon = value; @@ -252,6 +373,9 @@ namespace Flow.Launcher.Infrastructure.UserSettings public bool LeaveCmdOpen { get; set; } public bool HideWhenDeactivated { get; set; } = true; + public bool SearchQueryResultsWithDelay { get; set; } + public int SearchDelayTime { get; set; } = 150; + [JsonConverter(typeof(JsonStringEnumConverter))] public SearchWindowScreens SearchWindowScreen { get; set; } = SearchWindowScreens.Cursor; @@ -274,7 +398,6 @@ namespace Flow.Launcher.Infrastructure.UserSettings [JsonIgnore] public bool WMPInstalled { get; set; } = true; - // This needs to be loaded last by staying at the bottom public PluginsSettings PluginSettings { get; set; } = new PluginsSettings(); @@ -286,29 +409,31 @@ namespace Flow.Launcher.Infrastructure.UserSettings var list = FixedHotkeys(); // Customizeable hotkeys - if(!string.IsNullOrEmpty(Hotkey)) + if (!string.IsNullOrEmpty(Hotkey)) list.Add(new(Hotkey, "flowlauncherHotkey", () => Hotkey = "")); - if(!string.IsNullOrEmpty(PreviewHotkey)) + if (!string.IsNullOrEmpty(PreviewHotkey)) list.Add(new(PreviewHotkey, "previewHotkey", () => PreviewHotkey = "")); - if(!string.IsNullOrEmpty(AutoCompleteHotkey)) + if (!string.IsNullOrEmpty(AutoCompleteHotkey)) list.Add(new(AutoCompleteHotkey, "autoCompleteHotkey", () => AutoCompleteHotkey = "")); - if(!string.IsNullOrEmpty(AutoCompleteHotkey2)) + if (!string.IsNullOrEmpty(AutoCompleteHotkey2)) list.Add(new(AutoCompleteHotkey2, "autoCompleteHotkey", () => AutoCompleteHotkey2 = "")); - if(!string.IsNullOrEmpty(SelectNextItemHotkey)) + if (!string.IsNullOrEmpty(SelectNextItemHotkey)) list.Add(new(SelectNextItemHotkey, "SelectNextItemHotkey", () => SelectNextItemHotkey = "")); - if(!string.IsNullOrEmpty(SelectNextItemHotkey2)) + if (!string.IsNullOrEmpty(SelectNextItemHotkey2)) list.Add(new(SelectNextItemHotkey2, "SelectNextItemHotkey", () => SelectNextItemHotkey2 = "")); - if(!string.IsNullOrEmpty(SelectPrevItemHotkey)) + if (!string.IsNullOrEmpty(SelectPrevItemHotkey)) list.Add(new(SelectPrevItemHotkey, "SelectPrevItemHotkey", () => SelectPrevItemHotkey = "")); - if(!string.IsNullOrEmpty(SelectPrevItemHotkey2)) + if (!string.IsNullOrEmpty(SelectPrevItemHotkey2)) list.Add(new(SelectPrevItemHotkey2, "SelectPrevItemHotkey", () => SelectPrevItemHotkey2 = "")); - if(!string.IsNullOrEmpty(SettingWindowHotkey)) + if (!string.IsNullOrEmpty(SettingWindowHotkey)) list.Add(new(SettingWindowHotkey, "SettingWindowHotkey", () => SettingWindowHotkey = "")); - if(!string.IsNullOrEmpty(OpenContextMenuHotkey)) + if (!string.IsNullOrEmpty(OpenHistoryHotkey)) + list.Add(new(OpenHistoryHotkey, "OpenHistoryHotkey", () => OpenHistoryHotkey = "")); + if (!string.IsNullOrEmpty(OpenContextMenuHotkey)) list.Add(new(OpenContextMenuHotkey, "OpenContextMenuHotkey", () => OpenContextMenuHotkey = "")); - if(!string.IsNullOrEmpty(SelectNextPageHotkey)) + if (!string.IsNullOrEmpty(SelectNextPageHotkey)) list.Add(new(SelectNextPageHotkey, "SelectNextPageHotkey", () => SelectNextPageHotkey = "")); - if(!string.IsNullOrEmpty(SelectPrevPageHotkey)) + if (!string.IsNullOrEmpty(SelectPrevPageHotkey)) list.Add(new(SelectPrevPageHotkey, "SelectPrevPageHotkey", () => SelectPrevPageHotkey = "")); if (!string.IsNullOrEmpty(CycleHistoryUpHotkey)) list.Add(new(CycleHistoryUpHotkey, "CycleHistoryUpHotkey", () => CycleHistoryUpHotkey = "")); @@ -339,7 +464,6 @@ namespace Flow.Launcher.Infrastructure.UserSettings new("Alt+Home", "HotkeySelectFirstResult"), new("Alt+End", "HotkeySelectLastResult"), new("Ctrl+R", "HotkeyRequery"), - new("Ctrl+H", "ToggleHistoryHotkey"), new("Ctrl+OemCloseBrackets", "QuickWidthHotkey"), new("Ctrl+OemOpenBrackets", "QuickWidthHotkey"), new("Ctrl+OemPlus", "QuickHeightHotkey"), @@ -370,7 +494,9 @@ namespace Flow.Launcher.Infrastructure.UserSettings { Selected, Empty, - Preserved + Preserved, + ActionKeywordPreserved, + ActionKeywordSelected } public enum ColorSchemes @@ -405,4 +531,12 @@ namespace Flow.Launcher.Infrastructure.UserSettings Fast, Custom } + + public enum BackdropTypes + { + None, + Acrylic, + Mica, + MicaAlt + } } diff --git a/Flow.Launcher.Infrastructure/Win32Helper.cs b/Flow.Launcher.Infrastructure/Win32Helper.cs new file mode 100644 index 000000000..1be803fd4 --- /dev/null +++ b/Flow.Launcher.Infrastructure/Win32Helper.cs @@ -0,0 +1,790 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Markup; +using System.Windows.Media; +using Flow.Launcher.Infrastructure.UserSettings; +using Microsoft.Win32; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Dwm; +using Windows.Win32.UI.Input.KeyboardAndMouse; +using Windows.Win32.UI.Shell.Common; +using Windows.Win32.UI.WindowsAndMessaging; +using Point = System.Windows.Point; +using SystemFonts = System.Windows.SystemFonts; + +namespace Flow.Launcher.Infrastructure +{ + public static class Win32Helper + { + #region Blur Handling + + public static bool IsBackdropSupported() + { + // Mica and Acrylic only supported Windows 11 22000+ + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + Environment.OSVersion.Version.Build >= 22000; + } + + public static unsafe bool DWMSetCloakForWindow(Window window, bool cloak) + { + var cloaked = cloak ? 1 : 0; + + return PInvoke.DwmSetWindowAttribute( + GetWindowHandle(window), + DWMWINDOWATTRIBUTE.DWMWA_CLOAK, + &cloaked, + (uint)Marshal.SizeOf()).Succeeded; + } + + public static unsafe bool DWMSetBackdropForWindow(Window window, BackdropTypes backdrop) + { + var backdropType = backdrop switch + { + BackdropTypes.Acrylic => DWM_SYSTEMBACKDROP_TYPE.DWMSBT_TRANSIENTWINDOW, + BackdropTypes.Mica => DWM_SYSTEMBACKDROP_TYPE.DWMSBT_MAINWINDOW, + BackdropTypes.MicaAlt => DWM_SYSTEMBACKDROP_TYPE.DWMSBT_TABBEDWINDOW, + _ => DWM_SYSTEMBACKDROP_TYPE.DWMSBT_AUTO + }; + + return PInvoke.DwmSetWindowAttribute( + GetWindowHandle(window), + DWMWINDOWATTRIBUTE.DWMWA_SYSTEMBACKDROP_TYPE, + &backdropType, + (uint)Marshal.SizeOf()).Succeeded; + } + + public static unsafe bool DWMSetDarkModeForWindow(Window window, bool useDarkMode) + { + var darkMode = useDarkMode ? 1 : 0; + + return PInvoke.DwmSetWindowAttribute( + GetWindowHandle(window), + DWMWINDOWATTRIBUTE.DWMWA_USE_IMMERSIVE_DARK_MODE, + &darkMode, + (uint)Marshal.SizeOf()).Succeeded; + } + + /// + /// + /// + /// + /// DoNotRound, Round, RoundSmall, Default + /// + public static unsafe bool DWMSetCornerPreferenceForWindow(Window window, string cornerType) + { + var preference = cornerType switch + { + "DoNotRound" => DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_DONOTROUND, + "Round" => DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_ROUND, + "RoundSmall" => DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_ROUNDSMALL, + "Default" => DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_DEFAULT, + _ => throw new InvalidOperationException("Invalid corner type") + }; + + return PInvoke.DwmSetWindowAttribute( + GetWindowHandle(window), + DWMWINDOWATTRIBUTE.DWMWA_WINDOW_CORNER_PREFERENCE, + &preference, + (uint)Marshal.SizeOf()).Succeeded; + } + + #endregion + + #region Wallpaper + + public static unsafe string GetWallpaperPath() + { + var wallpaperPtr = stackalloc char[(int)PInvoke.MAX_PATH]; + PInvoke.SystemParametersInfo(SYSTEM_PARAMETERS_INFO_ACTION.SPI_GETDESKWALLPAPER, PInvoke.MAX_PATH, + wallpaperPtr, + 0); + var wallpaper = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(wallpaperPtr); + + return wallpaper.ToString(); + } + + #endregion + + #region Window Foreground + + public static nint GetForegroundWindow() + { + return PInvoke.GetForegroundWindow().Value; + } + + public static bool SetForegroundWindow(Window window) + { + return PInvoke.SetForegroundWindow(GetWindowHandle(window)); + } + + public static bool SetForegroundWindow(nint handle) + { + return PInvoke.SetForegroundWindow(new(handle)); + } + + public static bool IsForegroundWindow(Window window) + { + return IsForegroundWindow(GetWindowHandle(window)); + } + + internal static bool IsForegroundWindow(HWND handle) + { + return handle.Equals(PInvoke.GetForegroundWindow()); + } + + #endregion + + #region Task Switching + + /// + /// Hide windows in the Alt+Tab window list + /// + /// To hide a window + public static void HideFromAltTab(Window window) + { + var hwnd = GetWindowHandle(window); + + var exStyle = GetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE); + + // Add TOOLWINDOW style, remove APPWINDOW style + var newExStyle = ((uint)exStyle | (uint)WINDOW_EX_STYLE.WS_EX_TOOLWINDOW) & ~(uint)WINDOW_EX_STYLE.WS_EX_APPWINDOW; + + SetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE, (int)newExStyle); + } + + /// + /// Restore window display in the Alt+Tab window list. + /// + /// To restore the displayed window + public static void ShowInAltTab(Window window) + { + var hwnd = GetWindowHandle(window); + + var exStyle = GetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE); + + // Remove the TOOLWINDOW style and add the APPWINDOW style. + var newExStyle = ((uint)exStyle & ~(uint)WINDOW_EX_STYLE.WS_EX_TOOLWINDOW) | (uint)WINDOW_EX_STYLE.WS_EX_APPWINDOW; + + SetWindowStyle(GetWindowHandle(window), WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE, (int)newExStyle); + } + + /// + /// Disable windows toolbar's control box + /// This will also disable system menu with Alt+Space hotkey + /// + public static void DisableControlBox(Window window) + { + var hwnd = GetWindowHandle(window); + + var style = GetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_STYLE); + + style &= ~(int)WINDOW_STYLE.WS_SYSMENU; + + SetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_STYLE, style); + } + + private static nint GetWindowStyle(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex) + { + var style = PInvoke.GetWindowLongPtr(hWnd, nIndex); + if (style == 0 && Marshal.GetLastPInvokeError() != 0) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + return style; + } + + private static nint SetWindowStyle(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex, nint dwNewLong) + { + PInvoke.SetLastError(WIN32_ERROR.NO_ERROR); // Clear any existing error + + var result = PInvoke.SetWindowLongPtr(hWnd, nIndex, dwNewLong); + if (result == 0 && Marshal.GetLastPInvokeError() != 0) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + + return result; + } + + #endregion + + #region Window Fullscreen + + private const string WINDOW_CLASS_CONSOLE = "ConsoleWindowClass"; + private const string WINDOW_CLASS_WINTAB = "Flip3D"; + private const string WINDOW_CLASS_PROGMAN = "Progman"; + private const string WINDOW_CLASS_WORKERW = "WorkerW"; + + private static HWND _hwnd_shell; + private static HWND HWND_SHELL => + _hwnd_shell != HWND.Null ? _hwnd_shell : _hwnd_shell = PInvoke.GetShellWindow(); + + private static HWND _hwnd_desktop; + private static HWND HWND_DESKTOP => + _hwnd_desktop != HWND.Null ? _hwnd_desktop : _hwnd_desktop = PInvoke.GetDesktopWindow(); + + public static unsafe bool IsForegroundWindowFullscreen() + { + // Get current active window + var hWnd = PInvoke.GetForegroundWindow(); + if (hWnd.Equals(HWND.Null)) + { + return false; + } + + // If current active window is desktop or shell, exit early + if (hWnd.Equals(HWND_DESKTOP) || hWnd.Equals(HWND_SHELL)) + { + return false; + } + + string windowClass; + const int capacity = 256; + Span buffer = stackalloc char[capacity]; + int validLength; + fixed (char* pBuffer = buffer) + { + validLength = PInvoke.GetClassName(hWnd, pBuffer, capacity); + } + + windowClass = buffer[..validLength].ToString(); + + // For Win+Tab (Flip3D) + if (windowClass == WINDOW_CLASS_WINTAB) + { + return false; + } + + PInvoke.GetWindowRect(hWnd, out var appBounds); + + // For console (ConsoleWindowClass), we have to check for negative dimensions + if (windowClass == WINDOW_CLASS_CONSOLE) + { + return appBounds.top < 0 && appBounds.bottom < 0; + } + + // For desktop (Progman or WorkerW, depends on the system), we have to check + if (windowClass is WINDOW_CLASS_PROGMAN or WINDOW_CLASS_WORKERW) + { + var hWndDesktop = PInvoke.FindWindowEx(hWnd, HWND.Null, "SHELLDLL_DefView", null); + hWndDesktop = PInvoke.FindWindowEx(hWndDesktop, HWND.Null, "SysListView32", "FolderView"); + if (hWndDesktop.Value != IntPtr.Zero) + { + return false; + } + } + + var monitorInfo = MonitorInfo.GetNearestDisplayMonitor(hWnd); + return (appBounds.bottom - appBounds.top) == monitorInfo.RectMonitor.Height && + (appBounds.right - appBounds.left) == monitorInfo.RectMonitor.Width; + } + + #endregion + + #region Pixel to DIP + + /// + /// Transforms pixels to Device Independent Pixels used by WPF + /// + /// current window, required to get presentation source + /// horizontal position in pixels + /// vertical position in pixels + /// point containing device independent pixels + public static Point TransformPixelsToDIP(Visual visual, double unitX, double unitY) + { + Matrix matrix; + var source = PresentationSource.FromVisual(visual); + if (source is not null) + { + matrix = source.CompositionTarget.TransformFromDevice; + } + else + { + using var src = new HwndSource(new HwndSourceParameters()); + matrix = src.CompositionTarget.TransformFromDevice; + } + + return new Point((int)(matrix.M11 * unitX), (int)(matrix.M22 * unitY)); + } + + #endregion + + #region WndProc + + public const int WM_ENTERSIZEMOVE = (int)PInvoke.WM_ENTERSIZEMOVE; + public const int WM_EXITSIZEMOVE = (int)PInvoke.WM_EXITSIZEMOVE; + + #endregion + + #region Window Handle + + internal static HWND GetWindowHandle(Window window, bool ensure = false) + { + var windowHelper = new WindowInteropHelper(window); + if (ensure) + { + windowHelper.EnsureHandle(); + } + return new(windowHelper.Handle); + } + + #endregion + + #region STA Thread + + /* + Inspired by https://github.com/files-community/Files code on STA Thread handling. + */ + + public static Task StartSTATaskAsync(Action action) + { + var taskCompletionSource = new TaskCompletionSource(); + Thread thread = new(() => + { + PInvoke.OleInitialize(); + + try + { + action(); + taskCompletionSource.SetResult(); + } + catch (System.Exception ex) + { + taskCompletionSource.SetException(ex); + } + finally + { + PInvoke.OleUninitialize(); + } + }) + { + IsBackground = true, + Priority = ThreadPriority.Normal + }; + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + return taskCompletionSource.Task; + } + + public static Task StartSTATaskAsync(Func func) + { + var taskCompletionSource = new TaskCompletionSource(); + + Thread thread = new(() => + { + PInvoke.OleInitialize(); + + try + { + taskCompletionSource.SetResult(func()); + } + catch (System.Exception ex) + { + taskCompletionSource.SetException(ex); + } + finally + { + PInvoke.OleUninitialize(); + } + }) + { + IsBackground = true, + Priority = ThreadPriority.Normal + }; + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + return taskCompletionSource.Task; + } + + #endregion + + #region Keyboard Layout + + private const string UserProfileRegistryPath = @"Control Panel\International\User Profile"; + + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/70feba9f-294e-491e-b6eb-56532684c37f + private const string EnglishLanguageTag = "en"; + + private static readonly string[] ImeLanguageTags = + { + "zh", // Chinese + "ja", // Japanese + "ko", // Korean + }; + + private const uint KeyboardLayoutLoWord = 0xFFFF; + + // Store the previous keyboard layout + private static HKL _previousLayout; + + /// + /// Switches the keyboard layout to English if available. + /// + /// If true, the current keyboard layout will be stored for later restoration. + /// Thrown when there's an error getting the window thread process ID. + public static unsafe void SwitchToEnglishKeyboardLayout(bool backupPrevious) + { + // Find an installed English layout + var enHKL = FindEnglishKeyboardLayout(); + + // No installed English layout found + if (enHKL == HKL.Null) return; + + // Get the foreground window + var hwnd = PInvoke.GetForegroundWindow(); + if (hwnd == HWND.Null) return; + + // Get the current foreground window thread ID + var threadId = PInvoke.GetWindowThreadProcessId(hwnd); + if (threadId == 0) throw new Win32Exception(Marshal.GetLastWin32Error()); + + // If the current layout has an IME mode, disable it without switching to another layout. + // This is needed because for languages with IME mode, Flow Launcher just temporarily disables + // the IME mode instead of switching to another layout. + var currentLayout = PInvoke.GetKeyboardLayout(threadId); + var currentLangId = (uint)currentLayout.Value & KeyboardLayoutLoWord; + foreach (var imeLangTag in ImeLanguageTags) + { + var langTag = GetLanguageTag(currentLangId); + if (langTag.StartsWith(imeLangTag, StringComparison.OrdinalIgnoreCase)) return; + } + + // Backup current keyboard layout + if (backupPrevious) _previousLayout = currentLayout; + + // Switch to English layout + PInvoke.ActivateKeyboardLayout(enHKL, 0); + } + + /// + /// Restores the previously backed-up keyboard layout. + /// If it wasn't backed up or has already been restored, this method does nothing. + /// + public static void RestorePreviousKeyboardLayout() + { + if (_previousLayout == HKL.Null) return; + + var hwnd = PInvoke.GetForegroundWindow(); + if (hwnd == HWND.Null) return; + + PInvoke.PostMessage( + hwnd, + PInvoke.WM_INPUTLANGCHANGEREQUEST, + PInvoke.INPUTLANGCHANGE_FORWARD, + _previousLayout.Value + ); + + _previousLayout = HKL.Null; + } + + /// + /// Finds an installed English keyboard layout. + /// + /// + /// + private static unsafe HKL FindEnglishKeyboardLayout() + { + // Get the number of keyboard layouts + int count = PInvoke.GetKeyboardLayoutList(0, null); + if (count <= 0) return HKL.Null; + + // Get all keyboard layouts + var handles = new HKL[count]; + fixed (HKL* h = handles) + { + var result = PInvoke.GetKeyboardLayoutList(count, h); + if (result == 0) throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + // Look for any English keyboard layout + foreach (var hkl in handles) + { + // The lower word contains the language identifier + var langId = (uint)hkl.Value & KeyboardLayoutLoWord; + var langTag = GetLanguageTag(langId); + + // Check if it's an English layout + if (langTag.StartsWith(EnglishLanguageTag, StringComparison.OrdinalIgnoreCase)) + { + return hkl; + } + } + + return HKL.Null; + } + + /// + /// Returns the + /// + /// BCP 47 language tag + /// + /// of the current input language. + /// + /// + /// Edited from: https://github.com/dotnet/winforms + /// + private static string GetLanguageTag(uint langId) + { + // We need to convert the language identifier to a language tag, because they are deprecated and may have a + // transient value. + // https://learn.microsoft.com/globalization/locale/other-locale-names#lcid + // https://learn.microsoft.com/windows/win32/winmsg/wm-inputlangchange#remarks + // + // It turns out that the LCIDToLocaleName API, which is used inside CultureInfo, may return incorrect + // language tags for transient language identifiers. For example, it returns "nqo-GN" and "jv-Java-ID" + // instead of the "nqo" and "jv-Java" (as seen in the Get-WinUserLanguageList PowerShell cmdlet). + // + // Try to extract proper language tag from registry as a workaround approved by a Windows team. + // https://github.com/dotnet/winforms/pull/8573#issuecomment-1542600949 + // + // NOTE: this logic may break in future versions of Windows since it is not documented. + if (langId is PInvoke.LOCALE_TRANSIENT_KEYBOARD1 + or PInvoke.LOCALE_TRANSIENT_KEYBOARD2 + or PInvoke.LOCALE_TRANSIENT_KEYBOARD3 + or PInvoke.LOCALE_TRANSIENT_KEYBOARD4) + { + using var key = Registry.CurrentUser.OpenSubKey(UserProfileRegistryPath); + if (key?.GetValue("Languages") is string[] languages) + { + foreach (string language in languages) + { + using var subKey = key.OpenSubKey(language); + if (subKey?.GetValue("TransientLangId") is int transientLangId + && transientLangId == langId) + { + return language; + } + } + } + } + + return CultureInfo.GetCultureInfo((int)langId).Name; + } + + #endregion + + #region Notification + + public static bool IsNotificationSupported() + { + // Notifications only supported on Windows 10 19041+ + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + Environment.OSVersion.Version.Build >= 19041; + } + + #endregion + + #region Korean IME + + public static bool IsWindows11() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + Environment.OSVersion.Version.Build >= 22000; + } + + public static bool IsKoreanIMEExist() + { + return GetLegacyKoreanIMERegistryValue() != null; + } + + public static bool IsLegacyKoreanIMEEnabled() + { + object value = GetLegacyKoreanIMERegistryValue(); + + if (value is int intValue) + { + return intValue == 1; + } + else if (value != null && int.TryParse(value.ToString(), out int parsedValue)) + { + return parsedValue == 1; + } + + return false; + } + + public static bool SetLegacyKoreanIMEEnabled(bool enable) + { + const string subKeyPath = @"Software\Microsoft\input\tsf\tsf3override\{A028AE76-01B1-46C2-99C4-ACD9858AE02F}"; + const string valueName = "NoTsf3Override5"; + + try + { + using RegistryKey key = Registry.CurrentUser.CreateSubKey(subKeyPath); + if (key != null) + { + int value = enable ? 1 : 0; + key.SetValue(valueName, value, RegistryValueKind.DWord); + return true; + } + } + catch (System.Exception) + { + // Ignored + } + + return false; + } + + public static object GetLegacyKoreanIMERegistryValue() + { + const string subKeyPath = @"Software\Microsoft\input\tsf\tsf3override\{A028AE76-01B1-46C2-99C4-ACD9858AE02F}"; + const string valueName = "NoTsf3Override5"; + + try + { + using RegistryKey key = Registry.CurrentUser.OpenSubKey(subKeyPath); + if (key != null) + { + return key.GetValue(valueName); + } + } + catch (System.Exception) + { + // Ignored + } + + return null; + } + + public static void OpenImeSettings() + { + try + { + Process.Start(new ProcessStartInfo("ms-settings:regionlanguage") { UseShellExecute = true }); + } + catch (System.Exception) + { + // Ignored + } + } + + #endregion + + #region System Font + + private static readonly Dictionary _languageToNotoSans = new() + { + { "ko", "Noto Sans KR" }, + { "ja", "Noto Sans JP" }, + { "zh-CN", "Noto Sans SC" }, + { "zh-SG", "Noto Sans SC" }, + { "zh-Hans", "Noto Sans SC" }, + { "zh-TW", "Noto Sans TC" }, + { "zh-HK", "Noto Sans TC" }, + { "zh-MO", "Noto Sans TC" }, + { "zh-Hant", "Noto Sans TC" }, + { "th", "Noto Sans Thai" }, + { "ar", "Noto Sans Arabic" }, + { "he", "Noto Sans Hebrew" }, + { "hi", "Noto Sans Devanagari" }, + { "bn", "Noto Sans Bengali" }, + { "ta", "Noto Sans Tamil" }, + { "el", "Noto Sans Greek" }, + { "ru", "Noto Sans" }, + { "en", "Noto Sans" }, + { "fr", "Noto Sans" }, + { "de", "Noto Sans" }, + { "es", "Noto Sans" }, + { "pt", "Noto Sans" } + }; + + /// + /// Gets the system default font. + /// + /// + /// If true, it will try to find the Noto font for the current culture. + /// + /// + /// The name of the system default font. + /// + public static string GetSystemDefaultFont(bool useNoto = true) + { + try + { + if (useNoto) + { + var culture = CultureInfo.CurrentCulture; + var language = culture.Name; // e.g., "zh-TW" + var langPrefix = language.Split('-')[0]; // e.g., "zh" + + // First, try to find by full name, and if not found, fallback to prefix + if (TryGetNotoFont(language, out var notoFont) || TryGetNotoFont(langPrefix, out notoFont)) + { + // If the font is installed, return it + if (Fonts.SystemFontFamilies.Any(f => f.Source.Equals(notoFont))) + { + return notoFont; + } + } + } + + // If Noto font is not found, fallback to the system default font + var font = SystemFonts.MessageFontFamily; + if (font.FamilyNames.TryGetValue(XmlLanguage.GetLanguage("en-US"), out var englishName)) + { + return englishName; + } + + return font.Source ?? "Segoe UI"; + } + catch + { + return "Segoe UI"; + } + } + + private static bool TryGetNotoFont(string langKey, out string notoFont) + { + return _languageToNotoSans.TryGetValue(langKey, out notoFont); + } + + #endregion + + #region Explorer + + // https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shopenfolderandselectitems + + public static unsafe void OpenFolderAndSelectFile(string filePath) + { + ITEMIDLIST* pidlFolder = null; + ITEMIDLIST* pidlFile = null; + + var folderPath = Path.GetDirectoryName(filePath); + + try + { + var hrFolder = PInvoke.SHParseDisplayName(folderPath, null, out pidlFolder, 0, null); + if (hrFolder.Failed) throw new COMException("Failed to parse folder path", hrFolder); + + var hrFile = PInvoke.SHParseDisplayName(filePath, null, out pidlFile, 0, null); + if (hrFile.Failed) throw new COMException("Failed to parse file path", hrFile); + + var hrSelect = PInvoke.SHOpenFolderAndSelectItems(pidlFolder, 1, &pidlFile, 0); + if (hrSelect.Failed) throw new COMException("Failed to open folder and select item", hrSelect); + } + finally + { + if (pidlFile != null) PInvoke.CoTaskMemFree(pidlFile); + if (pidlFolder != null) PInvoke.CoTaskMemFree(pidlFolder); + } + } + + #endregion + } +} diff --git a/Flow.Launcher.Plugin/ActionContext.cs b/Flow.Launcher.Plugin/ActionContext.cs index e31c8e31d..9e05bbd06 100644 --- a/Flow.Launcher.Plugin/ActionContext.cs +++ b/Flow.Launcher.Plugin/ActionContext.cs @@ -51,6 +51,9 @@ namespace Flow.Launcher.Plugin (WinPressed ? ModifierKeys.Windows : ModifierKeys.None); } + /// + /// Default object with all keys not pressed. + /// public static readonly SpecialKeyState Default = new () { CtrlPressed = false, ShiftPressed = false, diff --git a/Flow.Launcher.Plugin/AllowedLanguage.cs b/Flow.Launcher.Plugin/AllowedLanguage.cs index 619a94deb..0d22756a7 100644 --- a/Flow.Launcher.Plugin/AllowedLanguage.cs +++ b/Flow.Launcher.Plugin/AllowedLanguage.cs @@ -65,7 +65,42 @@ namespace Flow.Launcher.Plugin public static bool IsDotNet(string language) { return language.Equals(CSharp, StringComparison.OrdinalIgnoreCase) - || language.Equals(FSharp, StringComparison.OrdinalIgnoreCase); + || language.Equals(FSharp, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines if this language is a Python language + /// + /// + /// + public static bool IsPython(string language) + { + return language.Equals(Python, StringComparison.OrdinalIgnoreCase) + || language.Equals(PythonV2, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines if this language is a Node.js language + /// + /// + /// + public static bool IsNodeJs(string language) + { + return language.Equals(TypeScript, StringComparison.OrdinalIgnoreCase) + || language.Equals(TypeScriptV2, StringComparison.OrdinalIgnoreCase) + || language.Equals(JavaScript, StringComparison.OrdinalIgnoreCase) + || language.Equals(JavaScriptV2, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines if this language is a executable language + /// + /// + /// + public static bool IsExecutable(string language) + { + return language.Equals(Executable, StringComparison.OrdinalIgnoreCase) + || language.Equals(ExecutableV2, StringComparison.OrdinalIgnoreCase); } /// @@ -76,15 +111,9 @@ namespace Flow.Launcher.Plugin public static bool IsAllowed(string language) { return IsDotNet(language) - || language.Equals(Python, StringComparison.OrdinalIgnoreCase) - || language.Equals(PythonV2, StringComparison.OrdinalIgnoreCase) - || language.Equals(Executable, StringComparison.OrdinalIgnoreCase) - || language.Equals(TypeScript, StringComparison.OrdinalIgnoreCase) - || language.Equals(JavaScript, StringComparison.OrdinalIgnoreCase) - || language.Equals(ExecutableV2, StringComparison.OrdinalIgnoreCase) - || language.Equals(TypeScriptV2, StringComparison.OrdinalIgnoreCase) - || language.Equals(JavaScriptV2, StringComparison.OrdinalIgnoreCase); - ; + || IsPython(language) + || IsNodeJs(language) + || IsExecutable(language); } } } diff --git a/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj b/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj index 35b9af1c9..4a26cec95 100644 --- a/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj +++ b/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj @@ -14,10 +14,10 @@ - 4.4.0 - 4.4.0 - 4.4.0 - 4.4.0 + 4.5.0 + 4.5.0 + 4.5.0 + 4.5.0 Flow.Launcher.Plugin Flow-Launcher MIT @@ -57,18 +57,28 @@ - + + + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + diff --git a/Flow.Launcher.Plugin/Interfaces/IAsyncHomeQuery.cs b/Flow.Launcher.Plugin/Interfaces/IAsyncHomeQuery.cs new file mode 100644 index 000000000..78d6454ae --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IAsyncHomeQuery.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Plugin +{ + /// + /// Asynchronous Query Model for Flow Launcher When Query Text is Empty + /// + public interface IAsyncHomeQuery : IFeatures + { + /// + /// Asynchronous Querying When Query Text is Empty + /// + /// + /// If the Querying method requires high IO transmission + /// or performing CPU intense jobs (performing better with cancellation), please use this IAsyncHomeQuery interface + /// + /// Cancel when querying job is obsolete + /// + Task> HomeQueryAsync(CancellationToken token); + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IHomeQuery.cs b/Flow.Launcher.Plugin/Interfaces/IHomeQuery.cs new file mode 100644 index 000000000..81186fca2 --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IHomeQuery.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Plugin +{ + /// + /// Synchronous Query Model for Flow Launcher When Query Text is Empty + /// + /// If the Querying method requires high IO transmission + /// or performing CPU intense jobs (performing better with cancellation), please try the IAsyncHomeQuery interface + /// + /// + public interface IHomeQuery : IAsyncHomeQuery + { + /// + /// Querying When Query Text is Empty + /// + /// This method will be called within a Task.Run, + /// so please avoid synchronously wait for long. + /// + /// + /// + List HomeQuery(); + + Task> IAsyncHomeQuery.HomeQueryAsync(CancellationToken token) => Task.Run(HomeQuery); + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs index c95a8ce7b..cb60251ed 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -1,12 +1,14 @@ -using Flow.Launcher.Plugin.SharedModels; -using JetBrains.Annotations; -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using System.Windows; +using System.Windows.Media; +using Flow.Launcher.Plugin.SharedModels; +using JetBrains.Annotations; namespace Flow.Launcher.Plugin { @@ -16,7 +18,8 @@ namespace Flow.Launcher.Plugin public interface IPublicAPI { /// - /// Change Flow.Launcher query + /// Change Flow.Launcher query. + /// When current results are from context menu or history, it will go back to query results before changing query. /// /// query text /// @@ -85,6 +88,11 @@ namespace Flow.Launcher.Plugin /// Show the MainWindow when hiding /// void ShowMainWindow(); + + /// + /// Focus the query text box in the main window + /// + void FocusQueryTextBox(); /// /// Hide MainWindow @@ -139,18 +147,49 @@ namespace Flow.Launcher.Plugin List GetAllPlugins(); /// - /// Register a callback for Global Keyboard Event + /// Registers a callback function for global keyboard events. /// - /// + /// + /// The callback function to invoke when a global keyboard event occurs. + /// + /// Parameters: + /// + /// int: The type of (key down, key up, etc.) + /// int: The virtual key code of the pressed/released key + /// : The state of modifier keys (Ctrl, Alt, Shift, etc.) + /// + /// + /// + /// Returns: true to allow normal system processing of the key event, + /// or false to intercept and prevent default handling. + /// + /// + /// + /// This callback will be invoked for all keyboard events system-wide. + /// Use with caution as intercepting system keys may affect normal system operation. + /// public void RegisterGlobalKeyboardCallback(Func callback); - + /// /// Remove a callback for Global Keyboard Event /// - /// + /// + /// The callback function to invoke when a global keyboard event occurs. + /// + /// Parameters: + /// + /// int: The type of (key down, key up, etc.) + /// int: The virtual key code of the pressed/released key + /// : The state of modifier keys (Ctrl, Alt, Shift, etc.) + /// + /// + /// + /// Returns: true to allow normal system processing of the key event, + /// or false to intercept and prevent default handling. + /// + /// public void RemoveGlobalKeyboardCallback(Func callback); - /// /// Fuzzy Search the string with the given query. This is the core search mechanism Flow uses /// @@ -180,19 +219,28 @@ namespace Flow.Launcher.Plugin /// /// URL to download file /// path to save downloaded file + /// + /// Action to report progress. The input of the action is the progress value which is a double value between 0 and 100. + /// It will be called if url support range request and the reportProgress is not null. + /// /// place to store file /// Task showing the progress - Task HttpDownloadAsync([NotNull] string url, [NotNull] string filePath, CancellationToken token = default); + Task HttpDownloadAsync([NotNull] string url, [NotNull] string filePath, Action reportProgress = null, CancellationToken token = default); /// - /// Add ActionKeyword for specific plugin + /// Add ActionKeyword and update action keyword metadata for specific plugin. + /// Before adding, please check if action keyword is already assigned by /// /// ID for plugin that needs to add action keyword /// The actionkeyword that is supposed to be added + /// + /// If new action keyword contains any whitespace, FL will still add it but it will not work for users. + /// So plugin should check the whitespace before calling this function. + /// void AddActionKeyword(string pluginId, string newActionKeyword); /// - /// Remove ActionKeyword for specific plugin + /// Remove ActionKeyword and update action keyword metadata for specific plugin /// /// ID for plugin that needs to remove action keyword /// The actionkeyword that is supposed to be removed @@ -221,6 +269,11 @@ namespace Flow.Launcher.Plugin /// void LogWarn(string className, string message, [CallerMemberName] string methodName = ""); + /// + /// Log error message. Preferred error logging method for plugins. + /// + void LogError(string className, string message, [CallerMemberName] string methodName = ""); + /// /// Log an Exception. Will throw if in debug mode so developer will be aware, /// otherwise logs the eror message. This is the primary logging method used for Flow @@ -236,9 +289,10 @@ namespace Flow.Launcher.Plugin T LoadSettingJsonStorage() where T : new(); /// - /// Save JsonStorage for current plugin's setting. This is the method used to save settings to json in Flow.Launcher + /// Save JsonStorage for current plugin's setting. This is the method used to save settings to json in Flow. /// This method will save the original instance loaded with LoadJsonStorage. - /// This API call is for manually Save. Flow will automatically save all setting type that has called LoadSettingJsonStorage or SaveSettingJsonStorage previously. + /// This API call is for manually Save. + /// Flow will automatically save all setting type that has called or previously. /// /// Type for Serialization /// @@ -294,9 +348,207 @@ namespace Flow.Launcher.Plugin /// /// Reloads the query. - /// This method should run + /// When current results are from context menu or history, it will go back to query results before requerying. /// /// Choose the first result after reload if true; keep the last selected result if false. Default is true. public void ReQuery(bool reselect = true); + + /// + /// Back to the query results. + /// This method should run when selected item is from context menu or history. + /// + public void BackToQueryResults(); + + /// + /// Displays a standardised Flow message box. + /// + /// The message of the message box. + /// The caption of the message box. + /// Specifies which button or buttons to display. + /// Specifies the icon to display. + /// Specifies the default result of the message box. + /// Specifies which message box button is clicked by the user. + public MessageBoxResult ShowMsgBox(string messageBoxText, string caption = "", MessageBoxButton button = MessageBoxButton.OK, MessageBoxImage icon = MessageBoxImage.None, MessageBoxResult defaultResult = MessageBoxResult.OK); + + /// + /// Displays a standardised Flow progress box. + /// + /// The caption of the progress box. + /// + /// Time-consuming task function, whose input is the action to report progress. + /// The input of the action is the progress value which is a double value between 0 and 100. + /// If there are any exceptions, this action will be null. + /// + /// When user cancel the progress, this action will be called. + /// + public Task ShowProgressBoxAsync(string caption, Func, Task> reportProgressAsync, Action cancelProgress = null); + + /// + /// Start the loading bar in main window + /// + public void StartLoadingBar(); + + /// + /// Stop the loading bar in main window + /// + public void StopLoadingBar(); + + /// + /// Get all available themes + /// + /// + public List GetAvailableThemes(); + + /// + /// Get the current theme + /// + /// + public ThemeData GetCurrentTheme(); + + /// + /// Set the current theme + /// + /// + /// + /// True if the theme is set successfully, false otherwise. + /// + public bool SetCurrentTheme(ThemeData theme); + + /// + /// Save all Flow's plugins caches + /// + void SavePluginCaches(); + + /// + /// Load BinaryStorage for current plugin's cache. This is the method used to load cache from binary in Flow. + /// When the file is not exist, it will create a new instance for the specific type. + /// + /// Type for deserialization + /// Cache file name + /// Cache directory from plugin metadata + /// Default data to return + /// + /// + /// BinaryStorage utilizes MemoryPack, which means the object must be MemoryPackSerializable + /// + Task LoadCacheBinaryStorageAsync(string cacheName, string cacheDirectory, T defaultData) where T : new(); + + /// + /// Save BinaryStorage for current plugin's cache. This is the method used to save cache to binary in Flow. + /// This method will save the original instance loaded with LoadCacheBinaryStorageAsync. + /// This API call is for manually Save. + /// Flow will automatically save all cache type that has called or previously. + /// + /// Type for Serialization + /// Cache file name + /// Cache directory from plugin metadata + /// + /// + /// BinaryStorage utilizes MemoryPack, which means the object must be MemoryPackSerializable + /// + Task SaveCacheBinaryStorageAsync(string cacheName, string cacheDirectory) where T : new(); + + /// + /// Load image from path. + /// Support local, remote and data:image url. + /// Support png, jpg, jpeg, gif, bmp, tiff, ico, svg image files. + /// If image path is missing, it will return a missing icon. + /// + /// The path of the image. + /// + /// Load full image or not. + /// + /// + /// Cache the image or not. Cached image will be stored in FL cache. + /// If the image is just used one time, it's better to set this to false. + /// + /// + ValueTask LoadImageAsync(string path, bool loadFullImage = false, bool cacheImage = true); + + /// + /// Update the plugin manifest + /// + /// + /// FL has multiple urls to download the plugin manifest. Set this to true to only use the primary url. + /// + /// + /// True if the manifest is updated successfully, false otherwise + public Task UpdatePluginManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default); + + /// + /// Get the plugin manifest. + /// + /// + /// If Flow cannot get manifest data, this could be null + /// + /// + public IReadOnlyList GetPluginManifest(); + + /// + /// Check if the plugin has been modified. + /// If this plugin is updated, installed or uninstalled and users do not restart the app, + /// it will be marked as modified + /// + /// Plugin id + /// + public bool PluginModified(string id); + + /// + /// Update a plugin to new version, from a zip file. By default will remove the zip file if update is via url, + /// unless it's a local path installation + /// + /// The metadata of the old plugin to update + /// The new plugin to update + /// + /// Path to the zip file containing the plugin. It will be unzipped to the temporary directory, removed and installed. + /// + /// + public Task UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath); + + /// + /// Install a plugin. By default will remove the zip file if installation is from url, + /// unless it's a local path installation + /// + /// The plugin to install + /// + /// Path to the zip file containing the plugin. It will be unzipped to the temporary directory, removed and installed. + /// + public void InstallPlugin(UserPlugin plugin, string zipFilePath); + + /// + /// Uninstall a plugin + /// + /// The metadata of the plugin to uninstall + /// + /// Plugin has their own settings. If this is set to true, the plugin settings will be removed. + /// + /// + public Task UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false); + + /// + /// Log debug message of the time taken to execute a method + /// Message will only be logged in Debug mode + /// + /// The time taken to execute the method in milliseconds + public long StopwatchLogDebug(string className, string message, Action action, [CallerMemberName] string methodName = ""); + + /// + /// Log debug message of the time taken to execute a method asynchronously + /// Message will only be logged in Debug mode + /// + /// The time taken to execute the method in milliseconds + public Task StopwatchLogDebugAsync(string className, string message, Func action, [CallerMemberName] string methodName = ""); + + /// + /// Log info message of the time taken to execute a method + /// + /// The time taken to execute the method in milliseconds + public long StopwatchLogInfo(string className, string message, Action action, [CallerMemberName] string methodName = ""); + + /// + /// Log info message of the time taken to execute a method asynchronously + /// + /// The time taken to execute the method in milliseconds + public Task StopwatchLogInfoAsync(string className, string message, Func action, [CallerMemberName] string methodName = ""); } } diff --git a/Flow.Launcher.Plugin/Interfaces/IResultUpdated.cs b/Flow.Launcher.Plugin/Interfaces/IResultUpdated.cs index fd21460ac..aa4e4a56d 100644 --- a/Flow.Launcher.Plugin/Interfaces/IResultUpdated.cs +++ b/Flow.Launcher.Plugin/Interfaces/IResultUpdated.cs @@ -4,17 +4,42 @@ using System.Threading; namespace Flow.Launcher.Plugin { + /// + /// Interface for plugins that want to manually update their results + /// public interface IResultUpdated : IFeatures { + /// + /// Event that is triggered when the results are updated + /// event ResultUpdatedEventHandler ResultsUpdated; } + /// + /// Delegate for the ResultsUpdated event + /// + /// + /// public delegate void ResultUpdatedEventHandler(IResultUpdated sender, ResultUpdatedEventArgs e); + /// + /// Event arguments for the ResultsUpdated event + /// public class ResultUpdatedEventArgs : EventArgs { + /// + /// List of results that should be displayed + /// public List Results; + + /// + /// Query that triggered the update + /// public Query Query; + + /// + /// Token that can be used to cancel the update + /// public CancellationToken Token { get; init; } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin/Interfaces/ISavable.cs b/Flow.Launcher.Plugin/Interfaces/ISavable.cs index 77bd304e4..38cbf8e08 100644 --- a/Flow.Launcher.Plugin/Interfaces/ISavable.cs +++ b/Flow.Launcher.Plugin/Interfaces/ISavable.cs @@ -1,18 +1,21 @@ -namespace Flow.Launcher.Plugin +namespace Flow.Launcher.Plugin { /// - /// Inherit this interface if additional data e.g. cache needs to be saved. + /// Inherit this interface if you need to save additional data which is not a setting or cache, + /// please implement this interface. /// /// /// For storing plugin settings, prefer - /// or . - /// Once called, your settings will be automatically saved by Flow. + /// or . + /// For storing plugin caches, prefer + /// or . + /// Once called, those settings and caches will be automatically saved by Flow. /// public interface ISavable : IFeatures { /// - /// Save additional plugin data, such as cache. + /// Save additional plugin data. /// void Save(); } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin/Interfaces/ISettingProvider.cs b/Flow.Launcher.Plugin/Interfaces/ISettingProvider.cs index d5ffba20b..f034243c3 100644 --- a/Flow.Launcher.Plugin/Interfaces/ISettingProvider.cs +++ b/Flow.Launcher.Plugin/Interfaces/ISettingProvider.cs @@ -2,8 +2,15 @@ namespace Flow.Launcher.Plugin { + /// + /// This interface is used to create settings panel for .Net plugins + /// public interface ISettingProvider { + /// + /// Create settings panel control for .Net plugins + /// + /// Control CreateSettingPanel(); } } diff --git a/Flow.Launcher.Plugin/KeyEvent.cs b/Flow.Launcher.Plugin/KeyEvent.cs new file mode 100644 index 000000000..321f17cc1 --- /dev/null +++ b/Flow.Launcher.Plugin/KeyEvent.cs @@ -0,0 +1,32 @@ +using Windows.Win32; + +namespace Flow.Launcher.Plugin +{ + /// + /// Enumeration of key events for + /// + /// and + /// + public enum KeyEvent + { + /// + /// Key down + /// + WM_KEYDOWN = (int)PInvoke.WM_KEYDOWN, + + /// + /// Key up + /// + WM_KEYUP = (int)PInvoke.WM_KEYUP, + + /// + /// System key up + /// + WM_SYSKEYUP = (int)PInvoke.WM_SYSKEYUP, + + /// + /// System key down + /// + WM_SYSKEYDOWN = (int)PInvoke.WM_SYSKEYDOWN + } +} diff --git a/Flow.Launcher.Plugin/NativeMethods.txt b/Flow.Launcher.Plugin/NativeMethods.txt new file mode 100644 index 000000000..0596691cc --- /dev/null +++ b/Flow.Launcher.Plugin/NativeMethods.txt @@ -0,0 +1,8 @@ +EnumThreadWindows +GetWindowText +GetWindowTextLength + +WM_KEYDOWN +WM_KEYUP +WM_SYSKEYDOWN +WM_SYSKEYUP \ No newline at end of file diff --git a/Flow.Launcher.Plugin/PluginInitContext.cs b/Flow.Launcher.Plugin/PluginInitContext.cs index f040752bd..a42e3930c 100644 --- a/Flow.Launcher.Plugin/PluginInitContext.cs +++ b/Flow.Launcher.Plugin/PluginInitContext.cs @@ -5,10 +5,18 @@ /// public class PluginInitContext { + /// + /// Default constructor. + /// public PluginInitContext() { } + /// + /// Constructor. + /// + /// + /// public PluginInitContext(PluginMetadata currentPluginMetadata, IPublicAPI api) { CurrentPluginMetadata = currentPluginMetadata; diff --git a/Flow.Launcher.Plugin/PluginMetadata.cs b/Flow.Launcher.Plugin/PluginMetadata.cs index b4e06913e..09803cbd7 100644 --- a/Flow.Launcher.Plugin/PluginMetadata.cs +++ b/Flow.Launcher.Plugin/PluginMetadata.cs @@ -4,24 +4,82 @@ using System.Text.Json.Serialization; namespace Flow.Launcher.Plugin { + /// + /// Plugin metadata + /// public class PluginMetadata : BaseModel { - private string _pluginDirectory; + /// + /// Plugin ID. + /// public string ID { get; set; } - public string Name { get; set; } - public string Author { get; set; } - public string Version { get; set; } - public string Language { get; set; } - public string Description { get; set; } - public string Website { get; set; } - public bool Disabled { get; set; } - public string ExecuteFilePath { get; private set;} + /// + /// Plugin name. + /// + public string Name { get; set; } + + /// + /// Plugin author. + /// + public string Author { get; set; } + + /// + /// Plugin version. + /// + public string Version { get; set; } + + /// + /// Plugin language. + /// See + /// + public string Language { get; set; } + + /// + /// Plugin description. + /// + public string Description { get; set; } + + /// + /// Plugin website. + /// + public string Website { get; set; } + + /// + /// Whether plugin is disabled. + /// + public bool Disabled { get; set; } + + /// + /// Whether plugin is disabled in home query. + /// + public bool HomeDisabled { get; set; } + + /// + /// Plugin execute file path. + /// + public string ExecuteFilePath { get; private set; } + + /// + /// Plugin execute file name. + /// public string ExecuteFileName { get; set; } + /// + /// Plugin assembly name. + /// Only available for .Net plugins. + /// + [JsonIgnore] + public string AssemblyName { get; internal set; } + + private string _pluginDirectory; + + /// + /// Plugin source directory. + /// public string PluginDirectory { - get { return _pluginDirectory; } + get => _pluginDirectory; internal set { _pluginDirectory = value; @@ -30,28 +88,77 @@ namespace Flow.Launcher.Plugin } } + /// + /// The first action keyword of plugin. + /// public string ActionKeyword { get; set; } + /// + /// All action keywords of plugin. + /// public List ActionKeywords { get; set; } - public string IcoPath { get; set;} - - public override string ToString() - { - return Name; - } + /// + /// Hide plugin keyword setting panel. + /// + public bool HideActionKeywordPanel { get; set; } + /// + /// Plugin search delay time in ms. Null means use default search delay time. + /// + public int? SearchDelayTime { get; set; } = null; + + /// + /// Plugin icon path. + /// + public string IcoPath { get; set;} + + /// + /// Plugin priority. + /// [JsonIgnore] public int Priority { get; set; } /// - /// Init time include both plugin load time and init time + /// Init time include both plugin load time and init time. /// [JsonIgnore] public long InitTime { get; set; } + + /// + /// Average query time. + /// [JsonIgnore] public long AvgQueryTime { get; set; } + + /// + /// Query count. + /// [JsonIgnore] public int QueryCount { get; set; } + + /// + /// The path to the plugin settings directory which is not validated. + /// It is used to store plugin settings files and data files. + /// When plugin is deleted, FL will ask users whether to keep its settings. + /// If users do not want to keep, this directory will be deleted. + /// + public string PluginSettingsDirectoryPath { get; internal set; } + + /// + /// The path to the plugin cache directory which is not validated. + /// It is used to store cache files. + /// When plugin is deleted, this directory will be deleted as well. + /// + public string PluginCacheDirectoryPath { get; internal set; } + + /// + /// Convert to string. + /// + /// + public override string ToString() + { + return Name; + } } } diff --git a/Flow.Launcher.Plugin/PluginPair.cs b/Flow.Launcher.Plugin/PluginPair.cs index 7bf634691..f2c14d70c 100644 --- a/Flow.Launcher.Plugin/PluginPair.cs +++ b/Flow.Launcher.Plugin/PluginPair.cs @@ -1,21 +1,37 @@ namespace Flow.Launcher.Plugin { + /// + /// Plugin instance and plugin metadata + /// public class PluginPair { + /// + /// Plugin instance + /// public IAsyncPlugin Plugin { get; internal set; } + + /// + /// Plugin metadata + /// public PluginMetadata Metadata { get; internal set; } - - + /// + /// Convert to string + /// + /// public override string ToString() { return Metadata.Name; } + /// + /// Compare by plugin metadata ID + /// + /// + /// public override bool Equals(object obj) { - PluginPair r = obj as PluginPair; - if (r != null) + if (obj is PluginPair r) { return string.Equals(r.Metadata.ID, Metadata.ID); } @@ -25,6 +41,10 @@ } } + /// + /// Get hash code + /// + /// public override int GetHashCode() { var hashcode = Metadata.ID?.GetHashCode() ?? 0; diff --git a/Flow.Launcher.Plugin/Query.cs b/Flow.Launcher.Plugin/Query.cs index b41675a1a..f50614699 100644 --- a/Flow.Launcher.Plugin/Query.cs +++ b/Flow.Launcher.Plugin/Query.cs @@ -1,25 +1,15 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace Flow.Launcher.Plugin { + /// + /// Represents a query that is sent to a plugin. + /// public class Query { - public Query() { } - - [Obsolete("Use the default Query constructor.")] - public Query(string rawQuery, string search, string[] terms, string[] searchTerms, string actionKeyword = "") - { - Search = search; - RawQuery = rawQuery; - SearchTerms = searchTerms; - ActionKeyword = actionKeyword; - } - /// - /// Raw query, this includes action keyword if it has + /// Raw query, this includes action keyword if it has. + /// It has handled buildin custom query shortkeys and build-in shortcuts, and it trims the whitespace. /// We didn't recommend use this property directly. You should always use Search property. /// public string RawQuery { get; internal init; } @@ -31,6 +21,11 @@ namespace Flow.Launcher.Plugin /// public bool IsReQuery { get; internal set; } = false; + /// + /// Determines whether the query is a home query. + /// + public bool IsHomeQuery { get; internal init; } = false; + /// /// Search part of a query. /// This will not include action keyword if exclusive plugin gets it, otherwise it should be same as RawQuery. @@ -51,10 +46,9 @@ namespace Flow.Launcher.Plugin public const string TermSeparator = " "; /// - /// User can set multiple action keywords seperated by ';' + /// User can set multiple action keywords seperated by whitespace /// - public const string ActionKeywordSeparator = ";"; - + public const string ActionKeywordSeparator = TermSeparator; /// /// Wildcard action keyword. Plugins using this value will be queried on every search. @@ -67,18 +61,18 @@ namespace Flow.Launcher.Plugin /// public string ActionKeyword { get; init; } - [JsonIgnore] /// /// Splits by spaces and returns the first item. /// /// /// returns an empty string when does not have enough items. /// + [JsonIgnore] public string FirstSearch => SplitSearch(0); - + [JsonIgnore] private string _secondToEndSearch; - + /// /// strings from second search (including) to last search /// diff --git a/Flow.Launcher.Plugin/Result.cs b/Flow.Launcher.Plugin/Result.cs index 9b42b1021..f0fcd48ff 100644 --- a/Flow.Launcher.Plugin/Result.cs +++ b/Flow.Launcher.Plugin/Result.cs @@ -1,5 +1,4 @@ using System; -using System.Runtime; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; @@ -13,6 +12,10 @@ namespace Flow.Launcher.Plugin /// public class Result { + /// + /// Maximum score. This can be useful when set one result to the top by default. This is the score for the results set to the topmost by users. + /// + public const int MaxScore = int.MaxValue; private string _pluginDirectory; @@ -20,6 +23,8 @@ namespace Flow.Launcher.Plugin private string _copyText = string.Empty; + private string _badgeIcoPath; + /// /// The title of the result. This is always required. /// @@ -62,7 +67,7 @@ namespace Flow.Launcher.Plugin /// GlyphInfo is prioritized if not null public string IcoPath { - get { return _icoPath; } + get => _icoPath; set { // As a standard this property will handle prepping and converting to absolute local path for icon image processing @@ -70,7 +75,8 @@ namespace Flow.Launcher.Plugin && !string.IsNullOrEmpty(PluginDirectory) && !Path.IsPathRooted(value) && !value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) - && !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + && !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + && !value.StartsWith("data:image", StringComparison.OrdinalIgnoreCase)) { _icoPath = Path.Combine(PluginDirectory, value); } @@ -81,6 +87,33 @@ namespace Flow.Launcher.Plugin } } + /// + /// The image to be displayed for the badge of the result. + /// + /// Can be a local file path or a URL. + /// If null or empty, will use plugin icon + public string BadgeIcoPath + { + get => _badgeIcoPath; + set + { + // As a standard this property will handle prepping and converting to absolute local path for icon image processing + if (!string.IsNullOrEmpty(value) + && !string.IsNullOrEmpty(PluginDirectory) + && !Path.IsPathRooted(value) + && !value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + && !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + && !value.StartsWith("data:image", StringComparison.OrdinalIgnoreCase)) + { + _badgeIcoPath = Path.Combine(PluginDirectory, value); + } + else + { + _badgeIcoPath = value; + } + } + } + /// /// Determines if Icon has a border radius /// @@ -95,14 +128,18 @@ namespace Flow.Launcher.Plugin /// /// Delegate to load an icon for this result. /// - public IconDelegate Icon; + public IconDelegate Icon = null; + + /// + /// Delegate to load an icon for the badge of this result. + /// + public IconDelegate BadgeIcon = null; /// /// Information for Glyph Icon (Prioritized than IcoPath/Icon if user enable Glyph Icons) /// public GlyphInfo Glyph { get; init; } - /// /// An action to take in the form of a function call when the result has been selected. /// @@ -144,70 +181,19 @@ namespace Flow.Launcher.Plugin /// public string PluginDirectory { - get { return _pluginDirectory; } + get => _pluginDirectory; set { _pluginDirectory = value; // When the Result object is returned from the query call, PluginDirectory is not provided until // UpdatePluginMetadata call is made at PluginManager.cs L196. Once the PluginDirectory becomes available - // we need to update (only if not Uri path) the IcoPath with the full absolute path so the image can be loaded. + // we need to update (only if not Uri path) the IcoPath and BadgeIcoPath with the full absolute path so the image can be loaded. IcoPath = _icoPath; + BadgeIcoPath = _badgeIcoPath; } } - /// - public override bool Equals(object obj) - { - var r = obj as Result; - - var equality = string.Equals(r?.Title, Title) && - string.Equals(r?.SubTitle, SubTitle) && - string.Equals(r?.AutoCompleteText, AutoCompleteText) && - string.Equals(r?.CopyText, CopyText) && - string.Equals(r?.IcoPath, IcoPath) && - TitleHighlightData == r.TitleHighlightData; - - return equality; - } - - /// - public override int GetHashCode() - { - return HashCode.Combine(Title, SubTitle, AutoCompleteText, CopyText, IcoPath); - } - - /// - public override string ToString() - { - return Title + SubTitle + Score; - } - - /// - /// Clones the current result - /// - public Result Clone() - { - return new Result - { - Title = Title, - SubTitle = SubTitle, - ActionKeywordAssigned = ActionKeywordAssigned, - CopyText = CopyText, - AutoCompleteText = AutoCompleteText, - IcoPath = IcoPath, - RoundedIcon = RoundedIcon, - Icon = Icon, - Glyph = Glyph, - Action = Action, - AsyncAction = AsyncAction, - Score = Score, - TitleHighlightData = TitleHighlightData, - OriginQuery = OriginQuery, - PluginDirectory = PluginDirectory, - }; - } - /// /// Additional data associated with this result /// @@ -236,16 +222,6 @@ namespace Flow.Launcher.Plugin /// public Lazy PreviewPanel { get; set; } - /// - /// Run this result, asynchronously - /// - /// - /// - public ValueTask ExecuteAsync(ActionContext context) - { - return AsyncAction?.Invoke(context) ?? ValueTask.FromResult(Action?.Invoke(context) ?? false); - } - /// /// Progress bar display. Providing an int value between 0-100 will trigger the progress bar to be displayed on the result /// @@ -262,6 +238,79 @@ namespace Flow.Launcher.Plugin /// public PreviewInfo Preview { get; set; } = PreviewInfo.Default; + /// + /// Determines if the user selection count should be added to the score. This can be useful when set to false to allow the result sequence order to be the same everytime instead of changing based on selection. + /// + public bool AddSelectedCount { get; set; } = true; + + /// + /// The key to identify the record. This is used when FL checks whether the result is the topmost record. Or FL calculates the hashcode of the result for user selected records. + /// This can be useful when your plugin will change the Title or SubTitle of the result dynamically. + /// If the plugin does not specific this, FL just uses Title and SubTitle to identify this result. + /// Note: Because old data does not have this key, we should use null as the default value for consistency. + /// + public string RecordKey { get; set; } = null; + + /// + /// Determines if the badge icon should be shown. + /// If users want to show the result badges and here you set this to true, the results will show the badge icon. + /// + public bool ShowBadge { get; set; } = false; + + /// + /// Run this result, asynchronously + /// + /// + /// + public ValueTask ExecuteAsync(ActionContext context) + { + return AsyncAction?.Invoke(context) ?? ValueTask.FromResult(Action?.Invoke(context) ?? false); + } + + /// + public override string ToString() + { + return Title + SubTitle + Score; + } + + /// + /// Clones the current result + /// + public Result Clone() + { + return new Result + { + Title = Title, + SubTitle = SubTitle, + ActionKeywordAssigned = ActionKeywordAssigned, + CopyText = CopyText, + AutoCompleteText = AutoCompleteText, + IcoPath = IcoPath, + BadgeIcoPath = BadgeIcoPath, + RoundedIcon = RoundedIcon, + Icon = Icon, + BadgeIcon = BadgeIcon, + Glyph = Glyph, + Action = Action, + AsyncAction = AsyncAction, + Score = Score, + TitleHighlightData = TitleHighlightData, + OriginQuery = OriginQuery, + PluginDirectory = PluginDirectory, + ContextData = ContextData, + PluginID = PluginID, + TitleToolTip = TitleToolTip, + SubTitleToolTip = SubTitleToolTip, + PreviewPanel = PreviewPanel, + ProgressBar = ProgressBar, + ProgressBarColor = ProgressBarColor, + Preview = Preview, + AddSelectedCount = AddSelectedCount, + RecordKey = RecordKey, + ShowBadge = ShowBadge, + }; + } + /// /// Info of the preview section of a /// diff --git a/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs b/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs index dd8c4b112..6c506cfc0 100644 --- a/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs +++ b/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs @@ -21,7 +21,8 @@ namespace Flow.Launcher.Plugin.SharedCommands /// /// /// - public static void CopyAll(this string sourcePath, string targetPath) + /// + public static void CopyAll(this string sourcePath, string targetPath, Func messageBoxExShow = null) { // Get the subdirectories for the specified directory. DirectoryInfo dir = new DirectoryInfo(sourcePath); @@ -54,7 +55,7 @@ namespace Flow.Launcher.Plugin.SharedCommands foreach (DirectoryInfo subdir in dirs) { string temppath = Path.Combine(targetPath, subdir.Name); - CopyAll(subdir.FullName, temppath); + CopyAll(subdir.FullName, temppath, messageBoxExShow); } } catch (Exception) @@ -62,8 +63,9 @@ namespace Flow.Launcher.Plugin.SharedCommands #if DEBUG throw; #else - MessageBox.Show(string.Format("Copying path {0} has failed, it will now be deleted for consistency", targetPath)); - RemoveFolderIfExists(targetPath); + messageBoxExShow ??= MessageBox.Show; + messageBoxExShow(string.Format("Copying path {0} has failed, it will now be deleted for consistency", targetPath)); + RemoveFolderIfExists(targetPath, messageBoxExShow); #endif } @@ -75,8 +77,9 @@ namespace Flow.Launcher.Plugin.SharedCommands /// /// /// + /// /// - public static bool VerifyBothFolderFilesEqual(this string fromPath, string toPath) + public static bool VerifyBothFolderFilesEqual(this string fromPath, string toPath, Func messageBoxExShow = null) { try { @@ -96,7 +99,8 @@ namespace Flow.Launcher.Plugin.SharedCommands #if DEBUG throw; #else - MessageBox.Show(string.Format("Unable to verify folders and files between {0} and {1}", fromPath, toPath)); + messageBoxExShow ??= MessageBox.Show; + messageBoxExShow(string.Format("Unable to verify folders and files between {0} and {1}", fromPath, toPath)); return false; #endif } @@ -107,7 +111,8 @@ namespace Flow.Launcher.Plugin.SharedCommands /// Deletes a folder if it exists /// /// - public static void RemoveFolderIfExists(this string path) + /// + public static void RemoveFolderIfExists(this string path, Func messageBoxExShow = null) { try { @@ -119,7 +124,8 @@ namespace Flow.Launcher.Plugin.SharedCommands #if DEBUG throw; #else - MessageBox.Show(string.Format("Not able to delete folder {0}, please go to the location and manually delete it", path)); + messageBoxExShow ??= MessageBox.Show; + messageBoxExShow(string.Format("Not able to delete folder {0}, please go to the location and manually delete it", path)); #endif } } @@ -148,7 +154,8 @@ namespace Flow.Launcher.Plugin.SharedCommands /// Open a directory window (using the OS's default handler, usually explorer) /// /// - public static void OpenPath(string fileOrFolderPath) + /// + public static void OpenPath(string fileOrFolderPath, Func messageBoxExShow = null) { var psi = new ProcessStartInfo { @@ -166,7 +173,8 @@ namespace Flow.Launcher.Plugin.SharedCommands #if DEBUG throw; #else - MessageBox.Show(string.Format("Unable to open the path {0}, please check if it exists", fileOrFolderPath)); + messageBoxExShow ??= MessageBox.Show; + messageBoxExShow(string.Format("Unable to open the path {0}, please check if it exists", fileOrFolderPath)); #endif } } @@ -177,7 +185,8 @@ namespace Flow.Launcher.Plugin.SharedCommands /// File path /// Working directory /// Open as Administrator - public static void OpenFile(string filePath, string workingDir = "", bool asAdmin = false) + /// + public static void OpenFile(string filePath, string workingDir = "", bool asAdmin = false, Func messageBoxExShow = null) { var psi = new ProcessStartInfo { @@ -196,7 +205,8 @@ namespace Flow.Launcher.Plugin.SharedCommands #if DEBUG throw; #else - MessageBox.Show(string.Format("Unable to open the path {0}, please check if it exists", filePath)); + messageBoxExShow ??= MessageBox.Show; + messageBoxExShow(string.Format("Unable to open the path {0}, please check if it exists", filePath)); #endif } } @@ -254,12 +264,12 @@ namespace Flow.Launcher.Plugin.SharedCommands var index = path.LastIndexOf('\\'); if (index > 0 && index < (path.Length - 1)) { - string previousDirectoryPath = path.Substring(0, index + 1); - return locationExists(previousDirectoryPath) ? previousDirectoryPath : ""; + string previousDirectoryPath = path[..(index + 1)]; + return locationExists(previousDirectoryPath) ? previousDirectoryPath : string.Empty; } else { - return ""; + return string.Empty; } } @@ -275,7 +285,7 @@ namespace Flow.Launcher.Plugin.SharedCommands // not full path, get previous level directory string var indexOfSeparator = path.LastIndexOf('\\'); - return path.Substring(0, indexOfSeparator + 1); + return path[..(indexOfSeparator + 1)]; } return path; @@ -308,5 +318,51 @@ namespace Flow.Launcher.Plugin.SharedCommands { return path.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; } + + /// + /// Validates a directory, creating it if it doesn't exist + /// + /// + public static void ValidateDirectory(string path) + { + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + } + + /// + /// Validates a data directory, synchronizing it by ensuring all files from a bundled source directory exist in it. + /// If files are missing or outdated, they are copied from the bundled directory to the data directory. + /// + /// + /// + public static void ValidateDataDirectory(string bundledDataDirectory, string dataDirectory) + { + if (!Directory.Exists(dataDirectory)) + { + Directory.CreateDirectory(dataDirectory); + } + + foreach (var bundledDataPath in Directory.GetFiles(bundledDataDirectory)) + { + var data = Path.GetFileName(bundledDataPath); + if (data == null) continue; + var dataPath = Path.Combine(dataDirectory, data); + if (!File.Exists(dataPath)) + { + File.Copy(bundledDataPath, dataPath); + } + else + { + var time1 = new FileInfo(bundledDataPath).LastWriteTimeUtc; + var time2 = new FileInfo(dataPath).LastWriteTimeUtc; + if (time1 != time2) + { + File.Copy(bundledDataPath, dataPath, true); + } + } + } + } } } diff --git a/Flow.Launcher.Plugin/SharedCommands/SearchWeb.cs b/Flow.Launcher.Plugin/SharedCommands/SearchWeb.cs index a7744ffac..752c85933 100644 --- a/Flow.Launcher.Plugin/SharedCommands/SearchWeb.cs +++ b/Flow.Launcher.Plugin/SharedCommands/SearchWeb.cs @@ -6,6 +6,9 @@ using System.Linq; namespace Flow.Launcher.Plugin.SharedCommands { + /// + /// Contains methods to open a search in a new browser window or tab. + /// public static class SearchWeb { private static string GetDefaultBrowserPath() @@ -106,4 +109,4 @@ namespace Flow.Launcher.Plugin.SharedCommands } } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin/SharedCommands/ShellCommand.cs b/Flow.Launcher.Plugin/SharedCommands/ShellCommand.cs index 49f78b458..288222d4f 100644 --- a/Flow.Launcher.Plugin/SharedCommands/ShellCommand.cs +++ b/Flow.Launcher.Plugin/SharedCommands/ShellCommand.cs @@ -2,21 +2,32 @@ using System.ComponentModel; using System.Diagnostics; using System.IO; -using System.Runtime.InteropServices; -using System.Text; using System.Threading; +using Windows.Win32; +using Windows.Win32.Foundation; namespace Flow.Launcher.Plugin.SharedCommands { + /// + /// Contains methods for running shell commands + /// public static class ShellCommand { + /// + /// Delegate for EnumThreadWindows + /// + /// + /// + /// public delegate bool EnumThreadDelegate(IntPtr hwnd, IntPtr lParam); - [DllImport("user32.dll")] static extern bool EnumThreadWindows(uint threadId, EnumThreadDelegate lpfn, IntPtr lParam); - [DllImport("user32.dll")] static extern int GetWindowText(IntPtr hwnd, StringBuilder lpString, int nMaxCount); - [DllImport("user32.dll")] static extern int GetWindowTextLength(IntPtr hwnd); private static bool containsSecurityWindow; + /// + /// Runs a windows command using the provided ProcessStartInfo + /// + /// + /// public static Process RunAsDifferentUser(ProcessStartInfo processStartInfo) { processStartInfo.Verb = "RunAsUser"; @@ -28,6 +39,7 @@ namespace Flow.Launcher.Plugin.SharedCommands CheckSecurityWindow(); Thread.Sleep(25); } + while (containsSecurityWindow) // while this process contains a "Windows Security" dialog, stay open { containsSecurityWindow = false; @@ -42,24 +54,42 @@ namespace Flow.Launcher.Plugin.SharedCommands { ProcessThreadCollection ptc = Process.GetCurrentProcess().Threads; for (int i = 0; i < ptc.Count; i++) - EnumThreadWindows((uint)ptc[i].Id, CheckSecurityThread, IntPtr.Zero); + PInvoke.EnumThreadWindows((uint)ptc[i].Id, CheckSecurityThread, IntPtr.Zero); } - private static bool CheckSecurityThread(IntPtr hwnd, IntPtr lParam) + private static BOOL CheckSecurityThread(HWND hwnd, LPARAM lParam) { if (GetWindowTitle(hwnd) == "Windows Security") containsSecurityWindow = true; return true; } - private static string GetWindowTitle(IntPtr hwnd) + private static unsafe string GetWindowTitle(HWND hwnd) { - StringBuilder sb = new StringBuilder(GetWindowTextLength(hwnd) + 1); - GetWindowText(hwnd, sb, sb.Capacity); - return sb.ToString(); + var capacity = PInvoke.GetWindowTextLength(hwnd) + 1; + int length; + Span buffer = capacity < 1024 ? stackalloc char[capacity] : new char[capacity]; + fixed (char* pBuffer = buffer) + { + // If the window has no title bar or text, if the title bar is empty, + // or if the window or control handle is invalid, the return value is zero. + length = PInvoke.GetWindowText(hwnd, pBuffer, capacity); + } + + return buffer[..length].ToString(); } - public static ProcessStartInfo SetProcessStartInfo(this string fileName, string workingDirectory = "", string arguments = "", string verb = "", bool createNoWindow = false) + /// + /// Runs a windows command using the provided ProcessStartInfo + /// + /// + /// + /// + /// + /// + /// + public static ProcessStartInfo SetProcessStartInfo(this string fileName, string workingDirectory = "", + string arguments = "", string verb = "", bool createNoWindow = false) { var info = new ProcessStartInfo { diff --git a/Flow.Launcher.Plugin/SharedModels/MatchResult.cs b/Flow.Launcher.Plugin/SharedModels/MatchResult.cs index 5144eb61d..36677d4bb 100644 --- a/Flow.Launcher.Plugin/SharedModels/MatchResult.cs +++ b/Flow.Launcher.Plugin/SharedModels/MatchResult.cs @@ -2,14 +2,29 @@ namespace Flow.Launcher.Plugin.SharedModels { + /// + /// Represents the result of a match operation. + /// public class MatchResult { + /// + /// Initializes a new instance of the class. + /// + /// + /// public MatchResult(bool success, SearchPrecisionScore searchPrecision) { Success = success; SearchPrecision = searchPrecision; } + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// public MatchResult(bool success, SearchPrecisionScore searchPrecision, List matchData, int rawScore) { Success = success; @@ -18,6 +33,9 @@ namespace Flow.Launcher.Plugin.SharedModels RawScore = rawScore; } + /// + /// Whether the match operation was successful. + /// public bool Success { get; set; } /// @@ -30,6 +48,9 @@ namespace Flow.Launcher.Plugin.SharedModels /// private int _rawScore; + /// + /// The raw calculated search score without any search precision filtering applied. + /// public int RawScore { get { return _rawScore; } @@ -45,8 +66,15 @@ namespace Flow.Launcher.Plugin.SharedModels /// public List MatchData { get; set; } + /// + /// The search precision score used to filter the search results. + /// public SearchPrecisionScore SearchPrecision { get; set; } + /// + /// Determines if the search precision score is met. + /// + /// public bool IsSearchPrecisionScoreMet() { return IsSearchPrecisionScoreMet(_rawScore); @@ -63,10 +91,24 @@ namespace Flow.Launcher.Plugin.SharedModels } } + /// + /// Represents the search precision score used to filter search results. + /// public enum SearchPrecisionScore { + /// + /// The highest search precision score. + /// Regular = 50, + + /// + /// The medium search precision score. + /// Low = 20, + + /// + /// The lowest search precision score. + /// None = 0 } } diff --git a/Flow.Launcher.Plugin/SharedModels/ThemeData.cs b/Flow.Launcher.Plugin/SharedModels/ThemeData.cs new file mode 100644 index 000000000..cb389c21f --- /dev/null +++ b/Flow.Launcher.Plugin/SharedModels/ThemeData.cs @@ -0,0 +1,77 @@ +using System; + +namespace Flow.Launcher.Plugin.SharedModels; + +/// +/// Theme data model +/// +public class ThemeData +{ + /// + /// Theme file name without extension + /// + public string FileNameWithoutExtension { get; private init; } + + /// + /// Theme name + /// + public string Name { get; private init; } + + /// + /// Indicates whether the theme supports dark mode + /// + public bool? IsDark { get; private init; } + + /// + /// Indicates whether the theme supports blur effects + /// + public bool? HasBlur { get; private init; } + + /// + /// Theme data constructor + /// + public ThemeData(string fileNameWithoutExtension, string name, bool? isDark = null, bool? hasBlur = null) + { + FileNameWithoutExtension = fileNameWithoutExtension; + Name = name; + IsDark = isDark; + HasBlur = hasBlur; + } + + /// + public static bool operator ==(ThemeData left, ThemeData right) + { + if (left is null && right is null) + return true; + if (left is null || right is null) + return false; + return left.Equals(right); + } + + /// + public static bool operator !=(ThemeData left, ThemeData right) + { + return !(left == right); + } + + /// + public override bool Equals(object obj) + { + if (obj is not ThemeData other) + return false; + return FileNameWithoutExtension == other.FileNameWithoutExtension && + Name == other.Name; + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(FileNameWithoutExtension, Name); + } + + /// + public override string ToString() + { + return Name; + } +} diff --git a/Flow.Launcher.Plugin/UserPlugin.cs b/Flow.Launcher.Plugin/UserPlugin.cs new file mode 100644 index 000000000..74a16b83d --- /dev/null +++ b/Flow.Launcher.Plugin/UserPlugin.cs @@ -0,0 +1,80 @@ +using System; + +namespace Flow.Launcher.Plugin +{ + /// + /// User Plugin Model for Flow Launcher + /// + public record UserPlugin + { + /// + /// Unique identifier of the plugin + /// + public string ID { get; set; } + + /// + /// Name of the plugin + /// + public string Name { get; set; } + + /// + /// Description of the plugin + /// + public string Description { get; set; } + + /// + /// Author of the plugin + /// + public string Author { get; set; } + + /// + /// Version of the plugin + /// + public string Version { get; set; } + + /// + /// Allow language of the plugin + /// + public string Language { get; set; } + + /// + /// Website of the plugin + /// + public string Website { get; set; } + + /// + /// URL to download the plugin + /// + public string UrlDownload { get; set; } + + /// + /// URL to the source code of the plugin + /// + public string UrlSourceCode { get; set; } + + /// + /// Local path where the plugin is installed + /// + public string LocalInstallPath { get; set; } + + /// + /// Icon path of the plugin + /// + public string IcoPath { get; set; } + + /// + /// The date when the plugin was last updated + /// + public DateTime? LatestReleaseDate { get; set; } + + /// + /// The date when the plugin was added to the local system + /// + public DateTime? DateAdded { get; set; } + + /// + /// Indicates whether the plugin is installed from a local path + /// + public bool IsFromLocalInstallPath => !string.IsNullOrEmpty(LocalInstallPath); + } +} diff --git a/Flow.Launcher.Test/FilesFoldersTest.cs b/Flow.Launcher.Test/FilesFoldersTest.cs index 3dead9918..2621fc2da 100644 --- a/Flow.Launcher.Test/FilesFoldersTest.cs +++ b/Flow.Launcher.Test/FilesFoldersTest.cs @@ -1,5 +1,6 @@ using Flow.Launcher.Plugin.SharedCommands; using NUnit.Framework; +using NUnit.Framework.Legacy; namespace Flow.Launcher.Test { @@ -35,7 +36,7 @@ namespace Flow.Launcher.Test [TestCase(@"c:\barr", @"c:\foo\..\bar\baz", false)] public void GivenTwoPaths_WhenCheckPathContains_ThenShouldBeExpectedResult(string parentPath, string path, bool expectedResult) { - Assert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path)); + ClassicAssert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path)); } // Equality @@ -47,7 +48,7 @@ namespace Flow.Launcher.Test [TestCase(@"c:\foo", @"c:\foo\", true)] public void GivenTwoPathsAreTheSame_WhenCheckPathContains_ThenShouldBeExpectedResult(string parentPath, string path, bool expectedResult) { - Assert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path, allowEqual: expectedResult)); + ClassicAssert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path, allowEqual: expectedResult)); } } } diff --git a/Flow.Launcher.Test/Flow.Launcher.Test.csproj b/Flow.Launcher.Test/Flow.Launcher.Test.csproj index a4bc4ab19..0241a374e 100644 --- a/Flow.Launcher.Test/Flow.Launcher.Test.csproj +++ b/Flow.Launcher.Test/Flow.Launcher.Test.csproj @@ -49,12 +49,12 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/Flow.Launcher.Test/FuzzyMatcherTest.cs b/Flow.Launcher.Test/FuzzyMatcherTest.cs index d7f143218..090719642 100644 --- a/Flow.Launcher.Test/FuzzyMatcherTest.cs +++ b/Flow.Launcher.Test/FuzzyMatcherTest.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using Flow.Launcher.Infrastructure; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedModels; @@ -21,6 +22,8 @@ namespace Flow.Launcher.Test private const string MicrosoftSqlServerManagementStudio = "Microsoft SQL Server Management Studio"; private const string VisualStudioCode = "Visual Studio Code"; + private readonly IAlphabet alphabet = null; + public List GetSearchStrings() => new List { @@ -34,7 +37,7 @@ namespace Flow.Launcher.Test OneOneOneOne }; - public List GetPrecisionScores() + public static List GetPrecisionScores() { var listToReturn = new List(); @@ -59,7 +62,7 @@ namespace Flow.Launcher.Test }; var results = new List(); - var matcher = new StringMatcher(); + var matcher = new StringMatcher(alphabet); foreach (var str in sources) { results.Add(new Result @@ -71,20 +74,20 @@ namespace Flow.Launcher.Test results = results.Where(x => x.Score > 0).OrderByDescending(x => x.Score).ToList(); - Assert.IsTrue(results.Count == 3); - Assert.IsTrue(results[0].Title == "Inste"); - Assert.IsTrue(results[1].Title == "Install Package"); - Assert.IsTrue(results[2].Title == "file open in browser-test"); + ClassicAssert.IsTrue(results.Count == 3); + ClassicAssert.IsTrue(results[0].Title == "Inste"); + ClassicAssert.IsTrue(results[1].Title == "Install Package"); + ClassicAssert.IsTrue(results[2].Title == "file open in browser-test"); } [TestCase("Chrome")] public void WhenNotAllCharactersFoundInSearchString_ThenShouldReturnZeroScore(string searchString) { var compareString = "Can have rum only in my glass"; - var matcher = new StringMatcher(); + var matcher = new StringMatcher(alphabet); var scoreResult = matcher.FuzzyMatch(searchString, compareString).RawScore; - Assert.True(scoreResult == 0); + ClassicAssert.True(scoreResult == 0); } [TestCase("chr")] @@ -97,7 +100,7 @@ namespace Flow.Launcher.Test string searchTerm) { var results = new List(); - var matcher = new StringMatcher(); + var matcher = new StringMatcher(alphabet); foreach (var str in GetSearchStrings()) { results.Add(new Result @@ -125,7 +128,7 @@ namespace Flow.Launcher.Test Debug.WriteLine("###############################################"); Debug.WriteLine(""); - Assert.IsFalse(filteredResult.Any(x => x.Score < precisionScore)); + ClassicAssert.IsFalse(filteredResult.Any(x => x.Score < precisionScore)); } } @@ -147,11 +150,11 @@ namespace Flow.Launcher.Test string queryString, string compareString, int expectedScore) { // When, Given - var matcher = new StringMatcher {UserSettingSearchPrecision = SearchPrecisionScore.Regular}; + var matcher = new StringMatcher(alphabet) {UserSettingSearchPrecision = SearchPrecisionScore.Regular}; var rawScore = matcher.FuzzyMatch(queryString, compareString).RawScore; // Should - Assert.AreEqual(expectedScore, rawScore, + ClassicAssert.AreEqual(expectedScore, rawScore, $"Expected score for compare string '{compareString}': {expectedScore}, Actual: {rawScore}"); } @@ -181,7 +184,7 @@ namespace Flow.Launcher.Test bool expectedPrecisionResult) { // When - var matcher = new StringMatcher {UserSettingSearchPrecision = expectedPrecisionScore}; + var matcher = new StringMatcher(alphabet) {UserSettingSearchPrecision = expectedPrecisionScore}; // Given var matchResult = matcher.FuzzyMatch(queryString, compareString); @@ -190,12 +193,12 @@ namespace Flow.Launcher.Test Debug.WriteLine("###############################################"); Debug.WriteLine($"QueryString: {queryString} CompareString: {compareString}"); Debug.WriteLine( - $"RAW SCORE: {matchResult.RawScore.ToString()}, PrecisionLevelSetAt: {expectedPrecisionScore} ({(int) expectedPrecisionScore})"); + $"RAW SCORE: {matchResult.RawScore}, PrecisionLevelSetAt: {expectedPrecisionScore} ({(int) expectedPrecisionScore})"); Debug.WriteLine("###############################################"); Debug.WriteLine(""); // Should - Assert.AreEqual(expectedPrecisionResult, matchResult.IsSearchPrecisionScoreMet(), + ClassicAssert.AreEqual(expectedPrecisionResult, matchResult.IsSearchPrecisionScoreMet(), $"Query: {queryString}{Environment.NewLine} " + $"Compare: {compareString}{Environment.NewLine}" + $"Raw Score: {matchResult.RawScore}{Environment.NewLine}" + @@ -232,7 +235,7 @@ namespace Flow.Launcher.Test bool expectedPrecisionResult) { // When - var matcher = new StringMatcher {UserSettingSearchPrecision = expectedPrecisionScore}; + var matcher = new StringMatcher(alphabet) {UserSettingSearchPrecision = expectedPrecisionScore}; // Given var matchResult = matcher.FuzzyMatch(queryString, compareString); @@ -241,12 +244,12 @@ namespace Flow.Launcher.Test Debug.WriteLine("###############################################"); Debug.WriteLine($"QueryString: {queryString} CompareString: {compareString}"); Debug.WriteLine( - $"RAW SCORE: {matchResult.RawScore.ToString()}, PrecisionLevelSetAt: {expectedPrecisionScore} ({(int) expectedPrecisionScore})"); + $"RAW SCORE: {matchResult.RawScore}, PrecisionLevelSetAt: {expectedPrecisionScore} ({(int) expectedPrecisionScore})"); Debug.WriteLine("###############################################"); Debug.WriteLine(""); // Should - Assert.AreEqual(expectedPrecisionResult, matchResult.IsSearchPrecisionScoreMet(), + ClassicAssert.AreEqual(expectedPrecisionResult, matchResult.IsSearchPrecisionScoreMet(), $"Query:{queryString}{Environment.NewLine} " + $"Compare:{compareString}{Environment.NewLine}" + $"Raw Score: {matchResult.RawScore}{Environment.NewLine}" + @@ -260,7 +263,7 @@ namespace Flow.Launcher.Test string queryString, string compareString1, string compareString2) { // When - var matcher = new StringMatcher {UserSettingSearchPrecision = SearchPrecisionScore.Regular}; + var matcher = new StringMatcher(alphabet) {UserSettingSearchPrecision = SearchPrecisionScore.Regular}; // Given var compareString1Result = matcher.FuzzyMatch(queryString, compareString1); @@ -277,7 +280,7 @@ namespace Flow.Launcher.Test Debug.WriteLine(""); // Should - Assert.True(compareString1Result.Score > compareString2Result.Score, + ClassicAssert.True(compareString1Result.Score > compareString2Result.Score, $"Query: \"{queryString}\"{Environment.NewLine} " + $"CompareString1: \"{compareString1}\", Score: {compareString1Result.Score}{Environment.NewLine}" + $"Should be greater than{Environment.NewLine}" + @@ -293,7 +296,7 @@ namespace Flow.Launcher.Test string queryString, string compareString1, string compareString2) { // When - var matcher = new StringMatcher { UserSettingSearchPrecision = SearchPrecisionScore.Regular }; + var matcher = new StringMatcher(alphabet) { UserSettingSearchPrecision = SearchPrecisionScore.Regular }; // Given var compareString1Result = matcher.FuzzyMatch(queryString, compareString1); @@ -310,7 +313,7 @@ namespace Flow.Launcher.Test Debug.WriteLine(""); // Should - Assert.True(compareString1Result.Score > compareString2Result.Score, + ClassicAssert.True(compareString1Result.Score > compareString2Result.Score, $"Query: \"{queryString}\"{Environment.NewLine} " + $"CompareString1: \"{compareString1}\", Score: {compareString1Result.Score}{Environment.NewLine}" + $"Should be greater than{Environment.NewLine}" + @@ -323,7 +326,7 @@ namespace Flow.Launcher.Test string secondName, string secondDescription, string secondExecutableName) { // Act - var matcher = new StringMatcher(); + var matcher = new StringMatcher(alphabet); var firstNameMatch = matcher.FuzzyMatch(queryString, firstName).RawScore; var firstDescriptionMatch = matcher.FuzzyMatch(queryString, firstDescription).RawScore; var firstExecutableNameMatch = matcher.FuzzyMatch(queryString, firstExecutableName).RawScore; @@ -336,7 +339,7 @@ namespace Flow.Launcher.Test var secondScore = new[] {secondNameMatch, secondDescriptionMatch, secondExecutableNameMatch}.Max(); // Assert - Assert.IsTrue(firstScore > secondScore, + ClassicAssert.IsTrue(firstScore > secondScore, $"Query: \"{queryString}\"{Environment.NewLine} " + $"Name of first: \"{firstName}\", Final Score: {firstScore}{Environment.NewLine}" + $"Should be greater than{Environment.NewLine}" + @@ -358,9 +361,9 @@ namespace Flow.Launcher.Test public void WhenGivenAnAcronymQuery_ShouldReturnAcronymScore(string queryString, string compareString, int desiredScore) { - var matcher = new StringMatcher(); + var matcher = new StringMatcher(alphabet); var score = matcher.FuzzyMatch(queryString, compareString).Score; - Assert.IsTrue(score == desiredScore, + ClassicAssert.IsTrue(score == desiredScore, $@"Query: ""{queryString}"" CompareString: ""{compareString}"" Score: {score} diff --git a/Flow.Launcher.Test/HttpTest.cs b/Flow.Launcher.Test/HttpTest.cs index e72ad7a67..4f135978a 100644 --- a/Flow.Launcher.Test/HttpTest.cs +++ b/Flow.Launcher.Test/HttpTest.cs @@ -1,4 +1,5 @@ -using NUnit.Framework; +using NUnit.Framework; +using NUnit.Framework.Legacy; using System; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Infrastructure.Http; @@ -16,16 +17,16 @@ namespace Flow.Launcher.Test proxy.Enabled = true; proxy.Server = "127.0.0.1"; - Assert.AreEqual(Http.WebProxy.Address, new Uri($"http://{proxy.Server}:{proxy.Port}")); - Assert.IsNull(Http.WebProxy.Credentials); + ClassicAssert.AreEqual(Http.WebProxy.Address, new Uri($"http://{proxy.Server}:{proxy.Port}")); + ClassicAssert.IsNull(Http.WebProxy.Credentials); proxy.UserName = "test"; - Assert.NotNull(Http.WebProxy.Credentials); - Assert.AreEqual(Http.WebProxy.Credentials.GetCredential(Http.WebProxy.Address, "Basic").UserName, proxy.UserName); - Assert.AreEqual(Http.WebProxy.Credentials.GetCredential(Http.WebProxy.Address, "Basic").Password, ""); + ClassicAssert.NotNull(Http.WebProxy.Credentials); + ClassicAssert.AreEqual(Http.WebProxy.Credentials.GetCredential(Http.WebProxy.Address, "Basic").UserName, proxy.UserName); + ClassicAssert.AreEqual(Http.WebProxy.Credentials.GetCredential(Http.WebProxy.Address, "Basic").Password, ""); proxy.Password = "test password"; - Assert.AreEqual(Http.WebProxy.Credentials.GetCredential(Http.WebProxy.Address, "Basic").Password, proxy.Password); + ClassicAssert.AreEqual(Http.WebProxy.Credentials.GetCredential(Http.WebProxy.Address, "Basic").Password, proxy.Password); } } } diff --git a/Flow.Launcher.Test/PluginLoadTest.cs b/Flow.Launcher.Test/PluginLoadTest.cs index d6ba48f19..2cc05f95a 100644 --- a/Flow.Launcher.Test/PluginLoadTest.cs +++ b/Flow.Launcher.Test/PluginLoadTest.cs @@ -1,4 +1,5 @@ using NUnit.Framework; +using NUnit.Framework.Legacy; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Plugin; using System.Collections.Generic; @@ -15,37 +16,37 @@ namespace Flow.Launcher.Test // Given var duplicateList = new List { - new PluginMetadata + new() { ID = "CEA0TYUC6D3B4085823D60DC76F28855", Version = "1.0.0" }, - new PluginMetadata + new() { ID = "CEA0TYUC6D3B4085823D60DC76F28855", Version = "1.0.1" }, - new PluginMetadata + new() { ID = "CEA0TYUC6D3B4085823D60DC76F28855", Version = "1.0.2" }, - new PluginMetadata + new() { ID = "CEA0TYUC6D3B4085823D60DC76F28855", Version = "1.0.0" }, - new PluginMetadata + new() { ID = "CEA0TYUC6D3B4085823D60DC76F28855", Version = "1.0.0" }, - new PluginMetadata + new() { ID = "ABC0TYUC6D3B7855823D60DC76F28855", Version = "1.0.0" }, - new PluginMetadata + new() { ID = "ABC0TYUC6D3B7855823D60DC76F28855", Version = "1.0.0" @@ -56,11 +57,11 @@ namespace Flow.Launcher.Test (var unique, var duplicates) = PluginConfig.GetUniqueLatestPluginMetadata(duplicateList); // Then - Assert.True(unique.FirstOrDefault().ID == "CEA0TYUC6D3B4085823D60DC76F28855" && unique.FirstOrDefault().Version == "1.0.2"); - Assert.True(unique.Count() == 1); + ClassicAssert.True(unique.FirstOrDefault().ID == "CEA0TYUC6D3B4085823D60DC76F28855" && unique.FirstOrDefault().Version == "1.0.2"); + ClassicAssert.True(unique.Count == 1); - Assert.False(duplicates.Any(x => x.Version == "1.0.2" && x.ID == "CEA0TYUC6D3B4085823D60DC76F28855")); - Assert.True(duplicates.Count() == 6); + ClassicAssert.False(duplicates.Any(x => x.Version == "1.0.2" && x.ID == "CEA0TYUC6D3B4085823D60DC76F28855")); + ClassicAssert.True(duplicates.Count == 6); } [Test] @@ -69,12 +70,12 @@ namespace Flow.Launcher.Test // Given var duplicateList = new List { - new PluginMetadata + new() { ID = "CEA0TYUC6D3B7855823D60DC76F28855", Version = "1.0.0" }, - new PluginMetadata + new() { ID = "CEA0TYUC6D3B7855823D60DC76F28855", Version = "1.0.0" @@ -85,8 +86,8 @@ namespace Flow.Launcher.Test (var unique, var duplicates) = PluginConfig.GetUniqueLatestPluginMetadata(duplicateList); // Then - Assert.True(unique.Count() == 0); - Assert.True(duplicates.Count() == 2); + ClassicAssert.True(unique.Count == 0); + ClassicAssert.True(duplicates.Count == 2); } } } diff --git a/Flow.Launcher.Test/Plugins/ExplorerTest.cs b/Flow.Launcher.Test/Plugins/ExplorerTest.cs index 80cb74729..9ec952155 100644 --- a/Flow.Launcher.Test/Plugins/ExplorerTest.cs +++ b/Flow.Launcher.Test/Plugins/ExplorerTest.cs @@ -5,12 +5,10 @@ using Flow.Launcher.Plugin.Explorer.Search.DirectoryInfo; using Flow.Launcher.Plugin.Explorer.Search.WindowsIndex; using Flow.Launcher.Plugin.SharedCommands; using NUnit.Framework; +using NUnit.Framework.Legacy; using System; -using System.Collections.Generic; using System.IO; using System.Runtime.Versioning; -using System.Threading; -using System.Threading.Tasks; using static Flow.Launcher.Plugin.Explorer.Search.SearchManager; namespace Flow.Launcher.Test.Plugins @@ -22,28 +20,6 @@ namespace Flow.Launcher.Test.Plugins [TestFixture] public class ExplorerTest { -#pragma warning disable CS1998 // async method with no await (more readable to leave it async to match the tested signature) - private async Task> MethodWindowsIndexSearchReturnsZeroResultsAsync(Query dummyQuery, string dummyString, CancellationToken dummyToken) - { - return new List(); - } -#pragma warning restore CS1998 - - private List MethodDirectoryInfoClassSearchReturnsTwoResults(Query dummyQuery, string dummyString, CancellationToken token) - { - return new List - { - new Result - { - Title = "Result 1" - }, - new Result - { - Title = "Result 2" - } - }; - } - private bool PreviousLocationExistsReturnsTrue(string dummyString) => true; private bool PreviousLocationNotExistReturnsFalse(string dummyString) => false; @@ -57,14 +33,14 @@ namespace Flow.Launcher.Test.Plugins var result = QueryConstructor.TopLevelDirectoryConstraint(folderPath); // Then - Assert.IsTrue(result == expectedString, + ClassicAssert.IsTrue(result == expectedString, $"Expected QueryWhereRestrictions string: {expectedString}{Environment.NewLine} " + $"Actual: {result}{Environment.NewLine}"); } [SupportedOSPlatform("windows7.0")] - [TestCase("C:\\", "SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType FROM SystemIndex WHERE directory='file:C:\\' ORDER BY System.FileName")] - [TestCase("C:\\SomeFolder\\", "SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType FROM SystemIndex WHERE directory='file:C:\\SomeFolder\\' ORDER BY System.FileName")] + [TestCase("C:\\", $"SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType FROM SystemIndex WHERE directory='file:C:\\' ORDER BY {QueryConstructor.OrderIdentifier}")] + [TestCase("C:\\SomeFolder\\", $"SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType FROM SystemIndex WHERE directory='file:C:\\SomeFolder\\' ORDER BY {QueryConstructor.OrderIdentifier}")] public void GivenWindowsIndexSearch_WhenSearchTypeIsTopLevelDirectorySearch_ThenQueryShouldUseExpectedString(string folderPath, string expectedString) { // Given @@ -74,7 +50,7 @@ namespace Flow.Launcher.Test.Plugins var queryString = queryConstructor.Directory(folderPath); // Then - Assert.IsTrue(queryString.Replace(" ", " ") == expectedString.Replace(" ", " "), + ClassicAssert.IsTrue(queryString.Replace(" ", " ") == expectedString.Replace(" ", " "), $"Expected string: {expectedString}{Environment.NewLine} " + $"Actual string was: {queryString}{Environment.NewLine}"); } @@ -83,7 +59,7 @@ namespace Flow.Launcher.Test.Plugins [TestCase("C:\\SomeFolder", "flow.launcher.sln", "SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType" + " FROM SystemIndex WHERE directory='file:C:\\SomeFolder'" + " AND (System.FileName LIKE 'flow.launcher.sln%' OR CONTAINS(System.FileName,'\"flow.launcher.sln*\"'))" + - " ORDER BY System.FileName")] + $" ORDER BY {QueryConstructor.OrderIdentifier}")] public void GivenWindowsIndexSearchTopLevelDirectory_WhenSearchingForSpecificItem_ThenQueryShouldUseExpectedString( string folderPath, string userSearchString, string expectedString) { @@ -94,7 +70,7 @@ namespace Flow.Launcher.Test.Plugins var queryString = queryConstructor.Directory(folderPath, userSearchString); // Then - Assert.AreEqual(expectedString, queryString); + ClassicAssert.AreEqual(expectedString, queryString); } [SupportedOSPlatform("windows7.0")] @@ -105,14 +81,14 @@ namespace Flow.Launcher.Test.Plugins const string resultString = QueryConstructor.RestrictionsForAllFilesAndFoldersSearch; // Then - Assert.AreEqual(expectedString, resultString); + ClassicAssert.AreEqual(expectedString, resultString); } [SupportedOSPlatform("windows7.0")] [TestCase("flow.launcher.sln", "SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" " + "FROM \"SystemIndex\" WHERE (System.FileName LIKE 'flow.launcher.sln%' " + - "OR CONTAINS(System.FileName,'\"flow.launcher.sln*\"',1033)) AND scope='file:' ORDER BY System.FileName")] - [TestCase("", "SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" FROM \"SystemIndex\" WHERE WorkId IS NOT NULL AND scope='file:' ORDER BY System.FileName")] + $"OR CONTAINS(System.FileName,'\"flow.launcher.sln*\"',1033)) AND scope='file:' ORDER BY {QueryConstructor.OrderIdentifier}")] + [TestCase("", $"SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" FROM \"SystemIndex\" WHERE WorkId IS NOT NULL AND scope='file:' ORDER BY {QueryConstructor.OrderIdentifier}")] public void GivenWindowsIndexSearch_WhenSearchAllFoldersAndFiles_ThenQueryShouldUseExpectedString( string userSearchString, string expectedString) { @@ -128,30 +104,29 @@ namespace Flow.Launcher.Test.Plugins var resultString = queryConstructor.FilesAndFolders(userSearchString); // Then - Assert.AreEqual(expectedString, resultString); + ClassicAssert.AreEqual(expectedString, resultString); } - [SupportedOSPlatform("windows7.0")] [TestCase(@"some words", @"FREETEXT('some words')")] public void GivenWindowsIndexSearch_WhenQueryWhereRestrictionsIsForFileContentSearch_ThenShouldReturnFreeTextString( string querySearchString, string expectedString) { // Given - var queryConstructor = new QueryConstructor(new Settings()); + _ = new QueryConstructor(new Settings()); //When var resultString = QueryConstructor.RestrictionsForFileContentSearch(querySearchString); // Then - Assert.IsTrue(resultString == expectedString, + ClassicAssert.IsTrue(resultString == expectedString, $"Expected QueryWhereRestrictions string: {expectedString}{Environment.NewLine} " + $"Actual string was: {resultString}{Environment.NewLine}"); } [SupportedOSPlatform("windows7.0")] [TestCase("some words", "SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType " + - "FROM SystemIndex WHERE FREETEXT('some words') AND scope='file:' ORDER BY System.FileName")] + $"FROM SystemIndex WHERE FREETEXT('some words') AND scope='file:' ORDER BY {QueryConstructor.OrderIdentifier}")] public void GivenWindowsIndexSearch_WhenSearchForFileContent_ThenQueryShouldUseExpectedString( string userSearchString, string expectedString) { @@ -162,12 +137,12 @@ namespace Flow.Launcher.Test.Plugins var resultString = queryConstructor.FileContent(userSearchString); // Then - Assert.IsTrue(resultString == expectedString, + ClassicAssert.IsTrue(resultString == expectedString, $"Expected query string: {expectedString}{Environment.NewLine} " + $"Actual string was: {resultString}{Environment.NewLine}"); } - public void GivenQuery_WhenActionKeywordForFileContentSearchExists_ThenFileContentSearchRequiredShouldReturnTrue() + public static void GivenQuery_WhenActionKeywordForFileContentSearchExists_ThenFileContentSearchRequiredShouldReturnTrue() { // Given var query = new Query @@ -181,7 +156,7 @@ namespace Flow.Launcher.Test.Plugins var result = searchManager.IsFileContentSearch(query.ActionKeyword); // Then - Assert.IsTrue(result, + ClassicAssert.IsTrue(result, $"Expected True for file content search. {Environment.NewLine} " + $"Actual result was: {result}{Environment.NewLine}"); } @@ -206,7 +181,7 @@ namespace Flow.Launcher.Test.Plugins var result = FilesFolders.IsLocationPathString(querySearchString); //Then - Assert.IsTrue(result == expectedResult, + ClassicAssert.IsTrue(result == expectedResult, $"Expected query search string check result is: {expectedResult} {Environment.NewLine} " + $"Actual check result is {result} {Environment.NewLine}"); @@ -233,7 +208,7 @@ namespace Flow.Launcher.Test.Plugins var previousDirectoryPath = FilesFolders.GetPreviousExistingDirectory(previousLocationExists, path); //Then - Assert.IsTrue(previousDirectoryPath == expectedString, + ClassicAssert.IsTrue(previousDirectoryPath == expectedString, $"Expected path string: {expectedString} {Environment.NewLine} " + $"Actual path string is {previousDirectoryPath} {Environment.NewLine}"); } @@ -246,7 +221,7 @@ namespace Flow.Launcher.Test.Plugins var returnedPath = FilesFolders.ReturnPreviousDirectoryIfIncompleteString(path); //Then - Assert.IsTrue(returnedPath == expectedString, + ClassicAssert.IsTrue(returnedPath == expectedString, $"Expected path string: {expectedString} {Environment.NewLine} " + $"Actual path string is {returnedPath} {Environment.NewLine}"); } @@ -260,7 +235,7 @@ namespace Flow.Launcher.Test.Plugins var resultString = QueryConstructor.RecursiveDirectoryConstraint(path); // Then - Assert.AreEqual(expectedString, resultString); + ClassicAssert.AreEqual(expectedString, resultString); } [SupportedOSPlatform("windows7.0")] @@ -274,7 +249,7 @@ namespace Flow.Launcher.Test.Plugins var resultString = DirectoryInfoSearch.ConstructSearchCriteria(path); // Then - Assert.AreEqual(expectedString, resultString); + ClassicAssert.AreEqual(expectedString, resultString); } [TestCase("c:\\somefolder\\someotherfolder", ResultType.Folder, "irrelevant", false, true, "c:\\somefolder\\someotherfolder\\")] @@ -305,7 +280,7 @@ namespace Flow.Launcher.Test.Plugins var result = ResultManager.GetPathWithActionKeyword(path, type, actionKeyword); // Then - Assert.AreEqual(result, expectedResult); + ClassicAssert.AreEqual(result, expectedResult); } [TestCase("c:\\somefolder\\somefile", ResultType.File, "irrelevant", false, true, "e c:\\somefolder\\somefile")] @@ -334,7 +309,7 @@ namespace Flow.Launcher.Test.Plugins var result = ResultManager.GetPathWithActionKeyword(path, type, actionKeyword); // Then - Assert.AreEqual(result, expectedResult); + ClassicAssert.AreEqual(result, expectedResult); } [TestCase("somefolder", "c:\\somefolder\\", ResultType.Folder, "q", false, false, "q somefolder")] @@ -366,7 +341,7 @@ namespace Flow.Launcher.Test.Plugins var result = ResultManager.GetAutoCompleteText(title, query, path, resultType); // Then - Assert.AreEqual(result, expectedResult); + ClassicAssert.AreEqual(result, expectedResult); } [TestCase("somefile", "c:\\somefolder\\somefile", ResultType.File, "q", false, false, "q somefile")] @@ -398,7 +373,7 @@ namespace Flow.Launcher.Test.Plugins var result = ResultManager.GetAutoCompleteText(title, query, path, resultType); // Then - Assert.AreEqual(result, expectedResult); + ClassicAssert.AreEqual(result, expectedResult); } [TestCase(@"c:\foo", @"c:\foo", true)] @@ -420,7 +395,7 @@ namespace Flow.Launcher.Test.Plugins }; // When, Then - Assert.AreEqual(expectedResult, comparator.Equals(result1, result2)); + ClassicAssert.AreEqual(expectedResult, comparator.Equals(result1, result2)); } [TestCase(@"c:\foo\", @"c:\foo\")] @@ -444,7 +419,7 @@ namespace Flow.Launcher.Test.Plugins var hash2 = comparator.GetHashCode(result2); // When, Then - Assert.IsTrue(hash1 == hash2); + ClassicAssert.IsTrue(hash1 == hash2); } [TestCase(@"%appdata%", true)] @@ -461,7 +436,7 @@ namespace Flow.Launcher.Test.Plugins var result = EnvironmentVariables.HasEnvironmentVar(path); // Then - Assert.AreEqual(result, expectedResult); + ClassicAssert.AreEqual(result, expectedResult); } } } diff --git a/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs b/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs index 3d05e5679..497f874e7 100644 --- a/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs @@ -1,12 +1,11 @@ -using NUnit.Framework; +using NUnit.Framework; +using NUnit.Framework.Legacy; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Plugin; using System.Threading.Tasks; using System.IO; using System.Threading; using System.Text; -using System.Text.Json; -using System.Linq; using System.Collections.Generic; namespace Flow.Launcher.Test.Plugins @@ -40,13 +39,13 @@ namespace Flow.Launcher.Test.Plugins Search = resultText }, default); - Assert.IsNotNull(results); + ClassicAssert.IsNotNull(results); foreach (var result in results) { - Assert.IsNotNull(result); - Assert.IsNotNull(result.AsyncAction); - Assert.IsNotNull(result.Title); + ClassicAssert.IsNotNull(result); + ClassicAssert.IsNotNull(result.AsyncAction); + ClassicAssert.IsNotNull(result.Title); } } @@ -56,35 +55,11 @@ namespace Flow.Launcher.Test.Plugins new JsonRPCQueryResponseModel(0, new List()), new JsonRPCQueryResponseModel(0, new List { - new JsonRPCResult + new() { Title = "Test1", SubTitle = "Test2" } }) }; - - [TestCaseSource(typeof(JsonRPCPluginTest), nameof(ResponseModelsSource))] - public async Task GivenModel_WhenSerializeWithDifferentNamingPolicy_ThenExpectSameResult_Async(JsonRPCQueryResponseModel reference) - { - var camelText = JsonSerializer.Serialize(reference, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - - var pascalText = JsonSerializer.Serialize(reference); - - var results1 = await QueryAsync(new Query { Search = camelText }, default); - var results2 = await QueryAsync(new Query { Search = pascalText }, default); - - Assert.IsNotNull(results1); - Assert.IsNotNull(results2); - - foreach (var ((result1, result2), referenceResult) in results1.Zip(results2).Zip(reference.Result)) - { - Assert.AreEqual(result1, result2); - Assert.AreEqual(result1, referenceResult); - - Assert.IsNotNull(result1); - Assert.IsNotNull(result1.AsyncAction); - } - } - } } diff --git a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs index 7ccac5bd5..0dd1fe489 100644 --- a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs @@ -1,7 +1,8 @@ using NUnit.Framework; +using NUnit.Framework.Legacy; using Flow.Launcher.Plugin.Url; -namespace Flow.Launcher.Test +namespace Flow.Launcher.Test.Plugins { [TestFixture] public class UrlPluginTest @@ -10,23 +11,23 @@ namespace Flow.Launcher.Test public void URLMatchTest() { var plugin = new Main(); - Assert.IsTrue(plugin.IsURL("http://www.google.com")); - Assert.IsTrue(plugin.IsURL("https://www.google.com")); - Assert.IsTrue(plugin.IsURL("http://google.com")); - Assert.IsTrue(plugin.IsURL("www.google.com")); - Assert.IsTrue(plugin.IsURL("google.com")); - Assert.IsTrue(plugin.IsURL("http://localhost")); - Assert.IsTrue(plugin.IsURL("https://localhost")); - Assert.IsTrue(plugin.IsURL("http://localhost:80")); - Assert.IsTrue(plugin.IsURL("https://localhost:80")); - Assert.IsTrue(plugin.IsURL("http://110.10.10.10")); - Assert.IsTrue(plugin.IsURL("110.10.10.10")); - Assert.IsTrue(plugin.IsURL("ftp://110.10.10.10")); + ClassicAssert.IsTrue(plugin.IsURL("http://www.google.com")); + ClassicAssert.IsTrue(plugin.IsURL("https://www.google.com")); + ClassicAssert.IsTrue(plugin.IsURL("http://google.com")); + ClassicAssert.IsTrue(plugin.IsURL("www.google.com")); + ClassicAssert.IsTrue(plugin.IsURL("google.com")); + ClassicAssert.IsTrue(plugin.IsURL("http://localhost")); + ClassicAssert.IsTrue(plugin.IsURL("https://localhost")); + ClassicAssert.IsTrue(plugin.IsURL("http://localhost:80")); + ClassicAssert.IsTrue(plugin.IsURL("https://localhost:80")); + ClassicAssert.IsTrue(plugin.IsURL("http://110.10.10.10")); + ClassicAssert.IsTrue(plugin.IsURL("110.10.10.10")); + ClassicAssert.IsTrue(plugin.IsURL("ftp://110.10.10.10")); - Assert.IsFalse(plugin.IsURL("wwww")); - Assert.IsFalse(plugin.IsURL("wwww.c")); - Assert.IsFalse(plugin.IsURL("wwww.c")); + ClassicAssert.IsFalse(plugin.IsURL("wwww")); + ClassicAssert.IsFalse(plugin.IsURL("wwww.c")); + ClassicAssert.IsFalse(plugin.IsURL("wwww.c")); } } } diff --git a/Flow.Launcher.Test/QueryBuilderTest.cs b/Flow.Launcher.Test/QueryBuilderTest.cs index aa0c8da12..c8ac17748 100644 --- a/Flow.Launcher.Test/QueryBuilderTest.cs +++ b/Flow.Launcher.Test/QueryBuilderTest.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using NUnit.Framework; +using NUnit.Framework.Legacy; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Plugin; @@ -17,17 +18,17 @@ namespace Flow.Launcher.Test Query q = QueryBuilder.Build("> ping google.com -n 20 -6", nonGlobalPlugins); - Assert.AreEqual("> ping google.com -n 20 -6", q.RawQuery); - Assert.AreEqual("ping google.com -n 20 -6", q.Search, "Search should not start with the ActionKeyword."); - Assert.AreEqual(">", q.ActionKeyword); + ClassicAssert.AreEqual("> ping google.com -n 20 -6", q.RawQuery); + ClassicAssert.AreEqual("ping google.com -n 20 -6", q.Search, "Search should not start with the ActionKeyword."); + ClassicAssert.AreEqual(">", q.ActionKeyword); - Assert.AreEqual(5, q.SearchTerms.Length, "The length of SearchTerms should match."); + ClassicAssert.AreEqual(5, q.SearchTerms.Length, "The length of SearchTerms should match."); - Assert.AreEqual("ping", q.FirstSearch); - Assert.AreEqual("google.com", q.SecondSearch); - Assert.AreEqual("-n", q.ThirdSearch); + ClassicAssert.AreEqual("ping", q.FirstSearch); + ClassicAssert.AreEqual("google.com", q.SecondSearch); + ClassicAssert.AreEqual("-n", q.ThirdSearch); - Assert.AreEqual("google.com -n 20 -6", q.SecondToEndSearch, "SecondToEndSearch should be trimmed of multiple whitespace characters"); + ClassicAssert.AreEqual("google.com -n 20 -6", q.SecondToEndSearch, "SecondToEndSearch should be trimmed of multiple whitespace characters"); } [Test] @@ -40,11 +41,11 @@ namespace Flow.Launcher.Test Query q = QueryBuilder.Build("> ping google.com -n 20 -6", nonGlobalPlugins); - Assert.AreEqual("> ping google.com -n 20 -6", q.Search); - Assert.AreEqual(q.Search, q.RawQuery, "RawQuery should be equal to Search."); - Assert.AreEqual(6, q.SearchTerms.Length, "The length of SearchTerms should match."); - Assert.AreNotEqual(">", q.ActionKeyword, "ActionKeyword should not match that of a disabled plugin."); - Assert.AreEqual("ping google.com -n 20 -6", q.SecondToEndSearch, "SecondToEndSearch should be trimmed of multiple whitespace characters"); + ClassicAssert.AreEqual("> ping google.com -n 20 -6", q.Search); + ClassicAssert.AreEqual(q.Search, q.RawQuery, "RawQuery should be equal to Search."); + ClassicAssert.AreEqual(6, q.SearchTerms.Length, "The length of SearchTerms should match."); + ClassicAssert.AreNotEqual(">", q.ActionKeyword, "ActionKeyword should not match that of a disabled plugin."); + ClassicAssert.AreEqual("ping google.com -n 20 -6", q.SecondToEndSearch, "SecondToEndSearch should be trimmed of multiple whitespace characters"); } [Test] @@ -52,13 +53,13 @@ namespace Flow.Launcher.Test { Query q = QueryBuilder.Build("file.txt file2 file3", new Dictionary()); - Assert.AreEqual("file.txt file2 file3", q.Search); - Assert.AreEqual("", q.ActionKeyword); + ClassicAssert.AreEqual("file.txt file2 file3", q.Search); + ClassicAssert.AreEqual("", q.ActionKeyword); - Assert.AreEqual("file.txt", q.FirstSearch); - Assert.AreEqual("file2", q.SecondSearch); - Assert.AreEqual("file3", q.ThirdSearch); - Assert.AreEqual("file2 file3", q.SecondToEndSearch); + ClassicAssert.AreEqual("file.txt", q.FirstSearch); + ClassicAssert.AreEqual("file2", q.SecondSearch); + ClassicAssert.AreEqual("file3", q.ThirdSearch); + ClassicAssert.AreEqual("file2 file3", q.SecondToEndSearch); } } } diff --git a/Flow.Launcher/ActionKeywords.xaml b/Flow.Launcher/ActionKeywords.xaml index 740b0d402..887b13126 100644 --- a/Flow.Launcher/ActionKeywords.xaml +++ b/Flow.Launcher/ActionKeywords.xaml @@ -53,11 +53,11 @@ - - + + - + - + @@ -112,20 +112,20 @@ Grid.Row="1" Background="{DynamicResource PopupButtonAreaBGColor}" BorderBrush="{DynamicResource PopupButtonAreaBorderColor}" - BorderThickness="0,1,0,0"> + BorderThickness="0 1 0 0"> + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/ReportWindow.xaml.cs b/Flow.Launcher/ReportWindow.xaml.cs index a535dfb3e..24801cf52 100644 --- a/Flow.Launcher/ReportWindow.xaml.cs +++ b/Flow.Launcher/ReportWindow.xaml.cs @@ -8,8 +8,8 @@ using System.Windows; using System.Windows.Documents; using Flow.Launcher.Helper; using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Plugin.SharedCommands; +using Flow.Launcher.Infrastructure.UserSettings; namespace Flow.Launcher { @@ -38,25 +38,26 @@ namespace Flow.Launcher private void SetException(Exception exception) { - string path = Log.CurrentLogDirectory; + var path = DataLocation.VersionLogDirectory; var directory = new DirectoryInfo(path); var log = directory.GetFiles().OrderByDescending(f => f.LastWriteTime).First(); var websiteUrl = exception switch - { - FlowPluginException pluginException =>GetIssuesUrl(pluginException.Metadata.Website), - _ => Constant.IssuesUrl - }; - + { + FlowPluginException pluginException =>GetIssuesUrl(pluginException.Metadata.Website), + _ => Constant.IssuesUrl + }; - var paragraph = Hyperlink("Please open new issue in: ", websiteUrl); - paragraph.Inlines.Add($"1. upload log file: {log.FullName}\n"); - paragraph.Inlines.Add($"2. copy below exception message"); + var paragraph = Hyperlink(App.API.GetTranslation("reportWindow_please_open_issue"), websiteUrl); + paragraph.Inlines.Add(string.Format(App.API.GetTranslation("reportWindow_upload_log"), log.FullName)); + paragraph.Inlines.Add("\n"); + paragraph.Inlines.Add(App.API.GetTranslation("reportWindow_copy_below")); ErrorTextbox.Document.Blocks.Add(paragraph); StringBuilder content = new StringBuilder(); content.AppendLine(ErrorReporting.RuntimeInfo()); content.AppendLine(ErrorReporting.DependenciesInfo()); + content.AppendLine(); content.AppendLine($"Date: {DateTime.Now.ToString(CultureInfo.InvariantCulture)}"); content.AppendLine("Exception:"); content.AppendLine(exception.ToString()); @@ -65,10 +66,12 @@ namespace Flow.Launcher ErrorTextbox.Document.Blocks.Add(paragraph); } - private Paragraph Hyperlink(string textBeforeUrl, string url) + private static Paragraph Hyperlink(string textBeforeUrl, string url) { - var paragraph = new Paragraph(); - paragraph.Margin = new Thickness(0); + var paragraph = new Paragraph + { + Margin = new Thickness(0) + }; var link = new Hyperlink { @@ -79,10 +82,16 @@ namespace Flow.Launcher link.Click += (s, e) => SearchWeb.OpenInBrowserTab(url); paragraph.Inlines.Add(textBeforeUrl); + paragraph.Inlines.Add(" "); paragraph.Inlines.Add(link); paragraph.Inlines.Add("\n"); return paragraph; } + + private void BtnCancel_OnClick(object sender, RoutedEventArgs e) + { + Close(); + } } } diff --git a/Flow.Launcher/Resources/Controls/Card.xaml b/Flow.Launcher/Resources/Controls/Card.xaml index c29a5f602..33c1299a9 100644 --- a/Flow.Launcher/Resources/Controls/Card.xaml +++ b/Flow.Launcher/Resources/Controls/Card.xaml @@ -20,21 +20,21 @@ - - + + - + - + - + - - + + @@ -73,7 +73,7 @@ @@ -91,7 +91,7 @@ @@ -107,8 +107,8 @@ - - + + @@ -120,11 +120,11 @@ diff --git a/Flow.Launcher/Resources/Controls/HotkeyDisplay.xaml.cs b/Flow.Launcher/Resources/Controls/HotkeyDisplay.xaml.cs index 9b19ffd86..bc167184b 100644 --- a/Flow.Launcher/Resources/Controls/HotkeyDisplay.xaml.cs +++ b/Flow.Launcher/Resources/Controls/HotkeyDisplay.xaml.cs @@ -42,14 +42,11 @@ namespace Flow.Launcher.Resources.Controls private static void keyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - var control = d as UserControl; - if (null == control) return; // This should not be possible + if (d is not UserControl) return; // This should not be possible - var newValue = e.NewValue as string; - if (null == newValue) return; + if (e.NewValue is not string newValue) return; - if (d is not HotkeyDisplay hotkeyDisplay) - return; + if (d is not HotkeyDisplay hotkeyDisplay) return; hotkeyDisplay.Values.Clear(); foreach (var key in newValue.Split('+')) diff --git a/Flow.Launcher/Resources/Controls/InfoBar.xaml b/Flow.Launcher/Resources/Controls/InfoBar.xaml new file mode 100644 index 000000000..2ddcbdd0c --- /dev/null +++ b/Flow.Launcher/Resources/Controls/InfoBar.xaml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + IsEnabled="{Binding HomeEnabled}" + IsOn="{Binding PluginHomeState}" + OffContent="{DynamicResource disable}" + OnContent="{DynamicResource enable}" + ToolTip="{DynamicResource homeToggleBoxToolTip}" + Visibility="{Binding DataContext.IsHomeOnOffSelected, RelativeSource={RelativeSource AncestorType=ListBox}, Converter={StaticResource BooleanToVisibilityConverter}}" /> + OnContent="{DynamicResource enable}" + Visibility="{Binding DataContext.IsOnOffSelected, RelativeSource={RelativeSource AncestorType=ListBox}, Converter={StaticResource BooleanToVisibilityConverter}}" /> @@ -96,9 +129,9 @@ + BorderThickness="0 1 0 0"> + + + + + {DynamicResource SettingWindowFont} + + @@ -755,17 +773,33 @@ - + + + + + + + + @@ -773,7 +807,7 @@ - + @@ -792,7 +826,8 @@ - + + @@ -802,7 +837,8 @@ - + + @@ -822,7 +858,7 @@ - + @@ -1103,7 +1139,8 @@ IsOpen="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}}" Placement="Bottom" PlacementTarget="{Binding ElementName=Background}" - PopupAnimation="None"> + PopupAnimation="None" + VerticalOffset="-1"> @@ -1123,11 +1160,11 @@ + Background="{DynamicResource CustomPopUpBorderBG}" + CornerRadius="5"> @@ -1157,8 +1194,8 @@ - - + + @@ -1285,7 +1322,6 @@ BasedOn="{StaticResource DefaultComboBoxStyle}" TargetType="ComboBox"> - @@ -1503,7 +1539,7 @@ - + @@ -1550,6 +1586,7 @@ + + + 70,13.5,18,13.5 + + 9,0,0,0 + 0,0,9,0 + 0,4.5,0,4.5 + + 9,4.5,0,4.5 + 0,4.5,9,4.5 + + + + 180 + 240 + 150 + + diff --git a/Flow.Launcher/Resources/Dark.xaml b/Flow.Launcher/Resources/Dark.xaml index ed031c939..3fd66d623 100644 --- a/Flow.Launcher/Resources/Dark.xaml +++ b/Flow.Launcher/Resources/Dark.xaml @@ -7,19 +7,20 @@ xmlns:sys="clr-namespace:System;assembly=mscorlib"> - + - - - - - + + + + + - - - + + + #198F8F8F + @@ -58,6 +59,9 @@ + + + @@ -103,6 +107,7 @@ #f5f5f5 #464646 #ffffff + #272727 @@ -110,11 +115,22 @@ - - - - + + + + + + + + + + + + @@ -145,8 +161,14 @@ + + + + + + diff --git a/Flow.Launcher/Resources/Light.xaml b/Flow.Launcher/Resources/Light.xaml index 8fe84588f..112815ed0 100644 --- a/Flow.Launcher/Resources/Light.xaml +++ b/Flow.Launcher/Resources/Light.xaml @@ -7,20 +7,22 @@ xmlns:sys="clr-namespace:System;assembly=mscorlib"> - + - - - + + + - - - - #198F8F8F + + + + #7EFFFFFF - + + + @@ -51,6 +53,9 @@ + + + @@ -94,19 +99,33 @@ #f5f5f5 #878787 #1b1b1b + #f6f6f6 + + - + + + + + - + + + + + @@ -136,8 +155,17 @@ + + + + + + @@ -148,7 +176,7 @@ 1,1,1,0 0,0,0,2 - 1,1,1,1 + 1,1,1,0 1,1,1,1 diff --git a/Flow.Launcher/Resources/Pages/WelcomePage1.xaml b/Flow.Launcher/Resources/Pages/WelcomePage1.xaml index 1728195bd..b6a99d9e9 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage1.xaml +++ b/Flow.Launcher/Resources/Pages/WelcomePage1.xaml @@ -110,8 +110,8 @@ - - + + @@ -127,7 +127,7 @@ Style="{DynamicResource StyleImageFadeIn}" /> - - + + (); + private readonly WelcomeViewModel _viewModel = Ioc.Default.GetRequiredService(); + protected override void OnNavigatedTo(NavigationEventArgs e) { - if (e.ExtraData is Settings settings) - Settings = settings; - else - throw new ArgumentException("Unexpected Navigation Parameter for Settings"); - InitializeComponent(); - } - private Internationalization _translater => InternationalizationManager.Instance; - public List Languages => _translater.LoadAvailableLanguages(); + // Sometimes the navigation is not triggered by button click, + // so we need to reset the page number + _viewModel.PageNum = 1; - public Settings Settings { get; set; } + if (!IsInitialized) + { + InitializeComponent(); + } + base.OnNavigatedTo(e); + } + + private readonly Internationalization _translater = Ioc.Default.GetRequiredService(); + + public List Languages => _translater.LoadAvailableLanguages(); public string CustomLanguage { @@ -29,12 +37,11 @@ namespace Flow.Launcher.Resources.Pages } set { - InternationalizationManager.Instance.ChangeLanguage(value); + _translater.ChangeLanguage(value); - if (InternationalizationManager.Instance.PromptShouldUsePinyin(value)) + if (_translater.PromptShouldUsePinyin(value)) Settings.ShouldUsePinyin = true; } } - } -} \ No newline at end of file +} diff --git a/Flow.Launcher/Resources/Pages/WelcomePage2.xaml b/Flow.Launcher/Resources/Pages/WelcomePage2.xaml index 6c6fcbb62..ca00091f5 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage2.xaml +++ b/Flow.Launcher/Resources/Pages/WelcomePage2.xaml @@ -56,11 +56,11 @@ Margin="0" Background="{Binding PreviewBackground}"> - + @@ -89,33 +89,32 @@ - + diff --git a/Flow.Launcher/Resources/Pages/WelcomePage2.xaml.cs b/Flow.Launcher/Resources/Pages/WelcomePage2.xaml.cs index 7dfb85a83..37767f128 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage2.xaml.cs +++ b/Flow.Launcher/Resources/Pages/WelcomePage2.xaml.cs @@ -1,27 +1,30 @@ -using Flow.Launcher.Helper; -using Flow.Launcher.Infrastructure.Hotkey; -using Flow.Launcher.Infrastructure.UserSettings; -using System; -using System.Windows; -using System.Windows.Media; +using System.Windows.Media; using System.Windows.Navigation; using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Helper; +using Flow.Launcher.Infrastructure.Hotkey; +using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.ViewModel; namespace Flow.Launcher.Resources.Pages { public partial class WelcomePage2 { - public Settings Settings { get; set; } + public Settings Settings { get; } = Ioc.Default.GetRequiredService(); + private readonly WelcomeViewModel _viewModel = Ioc.Default.GetRequiredService(); protected override void OnNavigatedTo(NavigationEventArgs e) { - if (e.ExtraData is Settings settings) - Settings = settings; - else - throw new ArgumentException("Unexpected Parameter setting."); + // Sometimes the navigation is not triggered by button click, + // so we need to reset the page number + _viewModel.PageNum = 2; - InitializeComponent(); + if (!IsInitialized) + { + InitializeComponent(); + } + base.OnNavigatedTo(e); } [RelayCommand] @@ -29,5 +32,10 @@ namespace Flow.Launcher.Resources.Pages { HotKeyMapper.SetHotkey(hotkey, HotKeyMapper.OnToggleHotkey); } + + public Brush PreviewBackground + { + get => WallpaperPathRetrieval.GetWallpaperBrush(); + } } } diff --git a/Flow.Launcher/Resources/Pages/WelcomePage3.xaml b/Flow.Launcher/Resources/Pages/WelcomePage3.xaml index a9e3fa696..0c1dcfea0 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage3.xaml +++ b/Flow.Launcher/Resources/Pages/WelcomePage3.xaml @@ -13,15 +13,15 @@ @@ -81,26 +81,26 @@ Canvas.Left="0" Width="450" Height="280" - Margin="0,0,0,0" + Margin="0 0 0 0" Source="../../images/page_img01.png" Style="{DynamicResource StyleImageFadeIn}" /> - + diff --git a/Flow.Launcher/Resources/Pages/WelcomePage4.xaml.cs b/Flow.Launcher/Resources/Pages/WelcomePage4.xaml.cs index 11bbcd6ed..63c9b9a7a 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage4.xaml.cs +++ b/Flow.Launcher/Resources/Pages/WelcomePage4.xaml.cs @@ -1,20 +1,26 @@ -using Flow.Launcher.Infrastructure.UserSettings; -using System; -using System.Windows.Navigation; +using System.Windows.Navigation; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.ViewModel; namespace Flow.Launcher.Resources.Pages { public partial class WelcomePage4 { + public Settings Settings { get; } = Ioc.Default.GetRequiredService(); + private readonly WelcomeViewModel _viewModel = Ioc.Default.GetRequiredService(); + protected override void OnNavigatedTo(NavigationEventArgs e) { - if (e.ExtraData is Settings settings) - Settings = settings; - else - throw new ArgumentException("Unexpected Navigation Parameter for Settings"); - InitializeComponent(); - } + // Sometimes the navigation is not triggered by button click, + // so we need to reset the page number + _viewModel.PageNum = 4; - public Settings Settings { get; set; } + if (!IsInitialized) + { + InitializeComponent(); + } + base.OnNavigatedTo(e); + } } } diff --git a/Flow.Launcher/Resources/Pages/WelcomePage5.xaml b/Flow.Launcher/Resources/Pages/WelcomePage5.xaml index c898ac9a0..7495231ae 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage5.xaml +++ b/Flow.Launcher/Resources/Pages/WelcomePage5.xaml @@ -58,10 +58,10 @@ - + - - + + @@ -79,18 +79,18 @@ - + - + - + (); + private readonly WelcomeViewModel _viewModel = Ioc.Default.GetRequiredService(); protected override void OnNavigatedTo(NavigationEventArgs e) { - if (e.ExtraData is Settings settings) - Settings = settings; - else - throw new ArgumentException("Unexpected Navigation Parameter for Settings"); - InitializeComponent(); + // Sometimes the navigation is not triggered by button click, + // so we need to reset the page number + _viewModel.PageNum = 5; + + if (!IsInitialized) + { + InitializeComponent(); + } + base.OnNavigatedTo(e); } private void OnAutoStartupChecked(object sender, RoutedEventArgs e) { - SetStartup(); - } - private void OnAutoStartupUncheck(object sender, RoutedEventArgs e) - { - RemoveStartup(); + ChangeAutoStartup(true); } - private void RemoveStartup() + private void OnAutoStartupUncheck(object sender, RoutedEventArgs e) { - using var key = Registry.CurrentUser.OpenSubKey(StartupPath, true); - key?.DeleteValue(Constant.FlowLauncher, false); - Settings.StartFlowLauncherOnSystemStartup = false; + ChangeAutoStartup(false); } - private void SetStartup() + + private void ChangeAutoStartup(bool value) { - using var key = Registry.CurrentUser.OpenSubKey(StartupPath, true); - key?.SetValue(Constant.FlowLauncher, Constant.ExecutablePath); - Settings.StartFlowLauncherOnSystemStartup = true; + Settings.StartFlowLauncherOnSystemStartup = value; + try + { + if (value) + { + if (Settings.UseLogonTaskForStartup) + { + AutoStartup.ChangeToViaLogonTask(); + } + else + { + AutoStartup.ChangeToViaRegistry(); + } + } + else + { + AutoStartup.DisableViaLogonTaskAndRegistry(); + } + } + catch (Exception e) + { + App.API.ShowMsg(App.API.GetTranslation("setAutoStartFailed"), e.Message); + } } private void OnHideOnStartupChecked(object sender, RoutedEventArgs e) { Settings.HideOnStartup = true; } + private void OnHideOnStartupUnchecked(object sender, RoutedEventArgs e) { Settings.HideOnStartup = false; @@ -58,6 +78,5 @@ namespace Flow.Launcher.Resources.Pages var window = Window.GetWindow(this); window.Close(); } - } } diff --git a/Flow.Launcher/ResultListBox.xaml b/Flow.Launcher/ResultListBox.xaml index e26340c4f..8cb15400f 100644 --- a/Flow.Launcher/ResultListBox.xaml +++ b/Flow.Launcher/ResultListBox.xaml @@ -32,6 +32,8 @@ + + @@ -65,23 +67,24 @@ Margin="0 0 10 0" VerticalAlignment="Center" Visibility="{Binding ShowOpenResultHotkey}"> - - + + - - - - - - - - + + + + + + + + + + @@ -89,60 +92,64 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/SelectBrowserWindow.xaml b/Flow.Launcher/SelectBrowserWindow.xaml index d8807dbef..d51d597b7 100644 --- a/Flow.Launcher/SelectBrowserWindow.xaml +++ b/Flow.Launcher/SelectBrowserWindow.xaml @@ -6,10 +6,11 @@ xmlns:local="clr-namespace:Flow.Launcher" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.modernwpf.com/2019" + xmlns:vm="clr-namespace:Flow.Launcher.ViewModel" Title="{DynamicResource defaultBrowserTitle}" Width="550" + d:DataContext="{d:DesignInstance vm:SelectBrowserViewModel}" Background="{DynamicResource PopuBGColor}" - DataContext="{Binding RelativeSource={RelativeSource Self}}" Foreground="{DynamicResource PopupTextColor}" ResizeMode="NoResize" SizeToContent="Height" @@ -28,14 +29,11 @@ - - - + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs b/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs index dfb4a7eaf..c0a77957a 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs +++ b/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs @@ -1,9 +1,8 @@ -using System; -using System.ComponentModel; +using System.ComponentModel; using System.Windows.Data; using System.Windows.Input; using System.Windows.Navigation; -using Flow.Launcher.Core.Plugin; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.SettingPages.ViewModels; using Flow.Launcher.ViewModel; @@ -12,15 +11,22 @@ namespace Flow.Launcher.SettingPages.Views; public partial class SettingsPanePluginStore { private SettingsPanePluginStoreViewModel _viewModel = null!; + private readonly SettingWindowViewModel _settingViewModel = Ioc.Default.GetRequiredService(); protected override void OnNavigatedTo(NavigationEventArgs e) { + // Sometimes the navigation is not triggered by button click, + // so we need to reset the page type + _settingViewModel.PageType = typeof(SettingsPanePluginStore); + + // If the navigation is not triggered by button click, view model will be null again + if (_viewModel == null) + { + _viewModel = Ioc.Default.GetRequiredService(); + DataContext = _viewModel; + } if (!IsInitialized) { - if (e.ExtraData is not SettingWindow.PaneData { Settings: { } settings }) - throw new ArgumentException($"Settings are required for {nameof(SettingsPanePluginStore)}."); - _viewModel = new SettingsPanePluginStoreViewModel(); - DataContext = _viewModel; InitializeComponent(); } _viewModel.PropertyChanged += ViewModel_PropertyChanged; @@ -29,9 +35,15 @@ public partial class SettingsPanePluginStore private void ViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(SettingsPanePluginStoreViewModel.FilterText)) + switch (e.PropertyName) { - ((CollectionViewSource)FindResource("PluginStoreCollectionView")).View.Refresh(); + case nameof(SettingsPanePluginStoreViewModel.FilterText): + case nameof(SettingsPanePluginStoreViewModel.ShowDotNet): + case nameof(SettingsPanePluginStoreViewModel.ShowPython): + case nameof(SettingsPanePluginStoreViewModel.ShowNodeJs): + case nameof(SettingsPanePluginStoreViewModel.ShowExecutable): + ((CollectionViewSource)FindResource("PluginStoreCollectionView")).View.Refresh(); + break; } } @@ -49,7 +61,7 @@ public partial class SettingsPanePluginStore private void Hyperlink_OnRequestNavigate(object sender, RequestNavigateEventArgs e) { - PluginManager.API.OpenUrl(e.Uri.AbsoluteUri); + App.API.OpenUrl(e.Uri.AbsoluteUri); e.Handled = true; } diff --git a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml index 37079a46f..52d77f914 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml +++ b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml @@ -2,21 +2,24 @@ x:Class="Flow.Launcher.SettingPages.Views.SettingsPanePlugins" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:cc="clr-namespace:Flow.Launcher.Resources.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:sys="clr-namespace:System;assembly=mscorlib" xmlns:ui="http://schemas.modernwpf.com/2019" xmlns:viewModels="clr-namespace:Flow.Launcher.SettingPages.ViewModels" - xmlns:cc="clr-namespace:Flow.Launcher.Resources.Controls" Title="Plugins" - FocusManager.FocusedElement="{Binding ElementName=PluginFilterTextbox}" - KeyDown="SettingsPanePlugins_OnKeyDown" d:DataContext="{d:DesignInstance viewModels:SettingsPanePluginsViewModel}" d:DesignHeight="450" d:DesignWidth="800" + FocusManager.FocusedElement="{Binding ElementName=PluginFilterTextbox}" + KeyDown="SettingsPanePlugins_OnKeyDown" mc:Ignorable="d"> - + @@ -31,61 +34,96 @@ Style="{StaticResource PageTitle}" Text="{DynamicResource plugins}" TextAlignment="Left" /> - - - - - + Orientation="Horizontal"> + + + + + + + + + - + + (); protected override void OnNavigatedTo(NavigationEventArgs e) { + // Sometimes the navigation is not triggered by button click, + // so we need to reset the page type + _settingViewModel.PageType = typeof(SettingsPanePlugins); + + // If the navigation is not triggered by button click, view model will be null again + if (_viewModel == null) + { + _viewModel = Ioc.Default.GetRequiredService(); + DataContext = _viewModel; + } if (!IsInitialized) { - if (e.ExtraData is not SettingWindow.PaneData { Settings: { } settings }) - throw new ArgumentException("Settings are required for SettingsPaneHotkey."); - _viewModel = new SettingsPanePluginsViewModel(settings); - DataContext = _viewModel; InitializeComponent(); } + _viewModel.PropertyChanged += ViewModel_PropertyChanged; base.OnNavigatedTo(e); } + private void ViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SettingsPanePluginsViewModel.FilterText)) + { + ((CollectionViewSource)FindResource("PluginCollectionView")).View.Refresh(); + } + } + + protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) + { + _viewModel.PropertyChanged -= ViewModel_PropertyChanged; + base.OnNavigatingFrom(e); + } + private void SettingsPanePlugins_OnKeyDown(object sender, KeyEventArgs e) { if (e.Key is not Key.F || Keyboard.Modifiers is not ModifierKeys.Control) return; PluginFilterTextbox.Focus(); } + + private void PluginCollectionView_OnFilter(object sender, FilterEventArgs e) + { + if (e.Item is not PluginViewModel plugin) + { + e.Accepted = false; + return; + } + + e.Accepted = _viewModel.SatisfiesFilter(plugin); + } } diff --git a/Flow.Launcher/SettingPages/Views/SettingsPaneProxy.xaml b/Flow.Launcher/SettingPages/Views/SettingsPaneProxy.xaml index 768abbf97..f429a6e29 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPaneProxy.xaml +++ b/Flow.Launcher/SettingPages/Views/SettingsPaneProxy.xaml @@ -2,11 +2,11 @@ x:Class="Flow.Launcher.SettingPages.Views.SettingsPaneProxy" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:cc="clr-namespace:Flow.Launcher.Resources.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:sys="clr-namespace:System;assembly=mscorlib" xmlns:ui="http://schemas.modernwpf.com/2019" - xmlns:cc="clr-namespace:Flow.Launcher.Resources.Controls" xmlns:viewModels="clr-namespace:Flow.Launcher.SettingPages.ViewModels" Title="Proxy" d:DataContext="{d:DesignInstance viewModels:SettingsPaneProxyViewModel}" @@ -18,8 +18,8 @@ @@ -71,9 +71,9 @@ - +