diff --git a/.cm/gitstream.cm b/.cm/gitstream.cm new file mode 100644 index 000000000..767982e3b --- /dev/null +++ b/.cm/gitstream.cm @@ -0,0 +1,80 @@ +# -*- mode: yaml -*- +# This example configuration for provides basic automations to get started with gitStream. +# View the gitStream quickstart for more examples: https://docs.gitstream.cm/examples/ +manifest: + version: 1.0 + + +triggers: + exclude: + branch: + - l10n_dev + - dev + - r/([Dd]ependabot|[Rr]enovate)/ + + +automations: + # Add a label that indicates how many minutes it will take to review the PR. + estimated_time_to_review: + on: + - commit + if: + - true + run: + - action: add-label@v1 + args: + label: "{{ calc.etr }} min review" + color: {{ colors.red if (calc.etr >= 20) else ( colors.yellow if (calc.etr >= 5) else colors.green ) }} + # Post a comment that lists the best experts for the files that were modified. + explain_code_experts: + if: + - true + run: + - action: explain-code-experts@v1 + args: + gt: 10 + # Post a comment notifying that the PR contains a TODO statement. + review_todo_comments: + if: + - {{ source.diff.files | matchDiffLines(regex=r/^[+].*\b(TODO|todo)\b/) | some }} + run: + - action: add-comment@v1 + args: + comment: | + This PR contains a TODO statement. Please check to see if they should be removed. + # Post a comment that request a before and after screenshot + request_screenshot: + # Triggered for PRs that lack an image file or link to an image in the PR description + if: + - {{ not (has.screenshot_link or has.image_uploaded) }} + run: + - action: add-comment@v1 + args: + comment: | + Be a legend :trophy: by adding a before and after screenshot of the changes you made, especially if they are around UI/UX. + + +# +----------------------------------------------------------------------------+ +# | Custom Expressions | +# | https://docs.gitstream.cm/how-it-works/#custom-expressions | +# +----------------------------------------------------------------------------+ + +calc: + etr: {{ branch | estimatedReviewTime }} + +colors: + red: 'b60205' + yellow: 'fbca04' + green: '0e8a16' + +changes: + # Sum all the lines added/edited in the PR + additions: {{ branch.diff.files_metadata | map(attr='additions') | sum }} + # Sum all the line removed in the PR + deletions: {{ branch.diff.files_metadata | map(attr='deletions') | sum }} + # Calculate the ratio of new code + ratio: {{ (changes.additions / (changes.additions + changes.deletions)) * 100 | round(2) }} + +has: + screenshot_link: {{ pr.description | includes(regex=r/!\[.*\]\(.*(jpg|svg|png|gif|psd).*\)/) }} + image_uploaded: {{ pr.description | includes(regex=r/()|!\[image\]\(.*github\.com.*\)/) }} diff --git a/.editorconfig b/.editorconfig index 11a0bcdf6..e1b8ed79e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -58,7 +58,7 @@ dotnet_style_prefer_conditional_expression_over_return = true:silent ############################### # Style Definitions dotnet_naming_style.pascal_case_style.capitalization = pascal_case -# Use PascalCase for constant fields +# Use PascalCase for constant fields dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style @@ -134,7 +134,7 @@ csharp_preserve_single_line_statements = true csharp_preserve_single_line_blocks = true csharp_using_directive_placement = outside_namespace:silent csharp_prefer_simple_using_statement = true:suggestion -csharp_style_namespace_declarations = block_scoped:silent +csharp_style_namespace_declarations = file_scoped:silent csharp_style_prefer_method_group_conversion = true:silent csharp_style_expression_bodied_lambdas = true:silent csharp_style_expression_bodied_local_functions = false:silent diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml index 87bb5045a..294c06fc1 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -14,7 +14,9 @@ body: options: - label: > I have checked that this issue has not already been reported. - + - label: > + I am using the latest version of Flow Launcher. + - type: textarea attributes: label: Problem Description @@ -41,25 +43,25 @@ body: - type: input attributes: label: Flow Launcher Version - description: Go to "Settings" => "About". - value: v1.8.3 + description: Go to "Settings" => "About". If you are using a prerelease version please append the build number. + placeholder: "Example: 1.11.0" + validations: + required: true - type: input attributes: label: Windows Build Number - description: Run "ver" at CMD (command prompt). - value: 10.0.19043.1288 + description: Run "ver" at CMD (command prompt) or go to Windows Settings -> Systems -> About. + placeholder: "Example: 10.0.19043.1288" + validations: + required: true - type: textarea id: logs attributes: label: Error Log description: > - Log file place: - - - The latest version place: `%AppData%\FlowLauncher\Logs\\.txt` - - - For portable mode: `%LocalAppData%\FlowLauncher\\UserData\Logs\\.txt` + From flow type 'open log location' and find log file with the corresponding date containing the error info. value: >
diff --git a/.github/actions/spelling/README.md b/.github/actions/spelling/README.md new file mode 100644 index 000000000..1f699f3de --- /dev/null +++ b/.github/actions/spelling/README.md @@ -0,0 +1,17 @@ +# check-spelling/check-spelling configuration + +File | Purpose | Format | Info +-|-|-|- +[dictionary.txt](dictionary.txt) | Replacement dictionary (creating this file will override the default dictionary) | one word per line | [dictionary](https://github.com/check-spelling/check-spelling/wiki/Configuration#dictionary) +[allow.txt](allow.txt) | Add words to the dictionary | one word per line (only letters and `'`s allowed) | [allow](https://github.com/check-spelling/check-spelling/wiki/Configuration#allow) +[reject.txt](reject.txt) | Remove words from the dictionary (after allow) | grep pattern matching whole dictionary words | [reject](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-reject) +[excludes.txt](excludes.txt) | Files to ignore entirely | perl regular expression | [excludes](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-excludes) +[only.txt](only.txt) | Only check matching files (applied after excludes) | perl regular expression | [only](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-only) +[patterns.txt](patterns.txt) | Patterns to ignore from checked lines | perl regular expression (order matters, first match wins) | [patterns](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-patterns) +[candidate.patterns](candidate.patterns) | Patterns that might be worth adding to [patterns.txt](patterns.txt) | perl regular expression with optional comment block introductions (all matches will be suggested) | [candidates](https://github.com/check-spelling/check-spelling/wiki/Feature:-Suggest-patterns) +[line_forbidden.patterns](line_forbidden.patterns) | Patterns to flag in checked lines | perl regular expression (order matters, first match wins) | [patterns](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-patterns) +[expect.txt](expect.txt) | Expected words that aren't in the dictionary | one word per line (sorted, alphabetically) | [expect](https://github.com/check-spelling/check-spelling/wiki/Configuration#expect) +[advice.md](advice.md) | Supplement for GitHub comment when unrecognized words are found | GitHub Markdown | [advice](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-advice) + +Note: you can replace any of these files with a directory by the same name (minus the suffix) +and then include multiple files inside that directory (with that suffix) to merge multiple files together. diff --git a/.github/actions/spelling/advice.md b/.github/actions/spelling/advice.md new file mode 100644 index 000000000..1004eeaa6 --- /dev/null +++ b/.github/actions/spelling/advice.md @@ -0,0 +1,25 @@ + +
If the flagged items are :exploding_head: false positives + +If items relate to a ... +* binary file (or some other file you wouldn't want to check at all). + + Please add a file path to the `excludes.txt` file matching the containing file. + + File paths are Perl 5 Regular Expressions - you can [test]( +https://www.regexplanet.com/advanced/perl/) yours before committing to verify it will match your files. + + `^` refers to the file's path from the root of the repository, so `^README\.md$` would exclude [README.md]( +../tree/HEAD/README.md) (on whichever branch you're using). + +* well-formed pattern. + + If you can write a [pattern](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns) that would match it, + try adding it to the `patterns.txt` file. + + Patterns are Perl 5 Regular Expressions - you can [test]( +https://www.regexplanet.com/advanced/perl/) yours before committing to verify it will match your lines. + + Note that patterns can't match multiline strings. + +
diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt new file mode 100644 index 000000000..a36a6af3e --- /dev/null +++ b/.github/actions/spelling/allow.txt @@ -0,0 +1,8 @@ +github +https +ssh +ubuntu +runcount +Firefox +Português +Português (Brasil) diff --git a/.github/actions/spelling/candidate.patterns b/.github/actions/spelling/candidate.patterns new file mode 100644 index 000000000..d244bb891 --- /dev/null +++ b/.github/actions/spelling/candidate.patterns @@ -0,0 +1,522 @@ +# marker to ignore all code on line +^.*/\* #no-spell-check-line \*/.*$ +# marker for ignoring a comment to the end of the line +// #no-spell-check.*$ + +# patch hunk comments +^\@\@ -\d+(?:,\d+|) \+\d+(?:,\d+|) \@\@ .* +# git index header +index [0-9a-z]{7,40}\.\.[0-9a-z]{7,40} + +# cid urls +(['"])cid:.*?\g{-1} + +# data url in parens +\(data:[^)]*?(?:[A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,})[^)]*\) +# data url in quotes +([`'"])data:.*?(?:[A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,}).*\g{-1} +# data url +data:[-a-zA-Z=;:/0-9+]*,\S* + +# mailto urls +mailto:[-a-zA-Z=;:/?%&0-9+@.]{3,} + +# magnet urls +magnet:[?=:\w]+ + +# magnet urls +"magnet:[^"]+" + +# obs: +"obs:[^"]*" + +# The `\b` here means a break, it's the fancy way to handle urls, but it makes things harder to read +# In this examples content, I'm using a number of different ways to match things to show various approaches +# asciinema +\basciinema\.org/a/[0-9a-zA-Z]+ + +# apple +\bdeveloper\.apple\.com/[-\w?=/]+ +# Apple music +\bembed\.music\.apple\.com/fr/playlist/usr-share/[-\w.]+ + +# appveyor api +\bci\.appveyor\.com/api/projects/status/[0-9a-z]+ +# appveyor project +\bci\.appveyor\.com/project/(?:[^/\s"]*/){2}builds?/\d+/job/[0-9a-z]+ + +# Amazon + +# Amazon +\bamazon\.com/[-\w]+/(?:dp/[0-9A-Z]+|) +# AWS S3 +\b\w*\.s3[^.]*\.amazonaws\.com/[-\w/&#%_?:=]* +# AWS execute-api +\b[0-9a-z]{10}\.execute-api\.[-0-9a-z]+\.amazonaws\.com\b +# AWS ELB +\b\w+\.[-0-9a-z]+\.elb\.amazonaws\.com\b +# AWS SNS +\bsns\.[-0-9a-z]+.amazonaws\.com/[-\w/&#%_?:=]* +# AWS VPC +vpc-\w+ + +# While you could try to match `http://` and `https://` by using `s?` in `https?://`, sometimes there +# YouTube url +\b(?:(?:www\.|)youtube\.com|youtu.be)/(?:channel/|embed/|user/|playlist\?list=|watch\?v=|v/|)[-a-zA-Z0-9?&=_%]* +# YouTube music +\bmusic\.youtube\.com/youtubei/v1/browse(?:[?&]\w+=[-a-zA-Z0-9?&=_]*) +# YouTube tag +<\s*youtube\s+id=['"][-a-zA-Z0-9?_]*['"] +# YouTube image +\bimg\.youtube\.com/vi/[-a-zA-Z0-9?&=_]* +# Google Accounts +\baccounts.google.com/[-_/?=.:;+%&0-9a-zA-Z]* +# Google Analytics +\bgoogle-analytics\.com/collect.[-0-9a-zA-Z?%=&_.~]* +# Google APIs +\bgoogleapis\.(?:com|dev)/[a-z]+/(?:v\d+/|)[a-z]+/[-@:./?=\w+|&]+ +# Google Storage +\b[-a-zA-Z0-9.]*\bstorage\d*\.googleapis\.com(?:/\S*|) +# Google Calendar +\bcalendar\.google\.com/calendar(?:/u/\d+|)/embed\?src=[@./?=\w&%]+ +\w+\@group\.calendar\.google\.com\b +# Google DataStudio +\bdatastudio\.google\.com/(?:(?:c/|)u/\d+/|)(?:embed/|)(?:open|reporting|datasources|s)/[-0-9a-zA-Z]+(?:/page/[-0-9a-zA-Z]+|) +# The leading `/` here is as opposed to the `\b` above +# ... a short way to match `https://` or `http://` since most urls have one of those prefixes +# Google Docs +/docs\.google\.com/[a-z]+/(?:ccc\?key=\w+|(?:u/\d+|d/(?:e/|)[0-9a-zA-Z_-]+/)?(?:edit\?[-\w=#.]*|/\?[\w=&]*|)) +# Google Drive +\bdrive\.google\.com/(?:file/d/|open)[-0-9a-zA-Z_?=]* +# Google Groups +\bgroups\.google\.com/(?:(?:forum/#!|d/)(?:msg|topics?|searchin)|a)/[^/\s"]+/[-a-zA-Z0-9$]+(?:/[-a-zA-Z0-9]+)* +# Google Maps +\bmaps\.google\.com/maps\?[\w&;=]* +# Google themes +themes\.googleusercontent\.com/static/fonts/[^/\s"]+/v\d+/[^.]+. +# Google CDN +\bclients2\.google(?:usercontent|)\.com[-0-9a-zA-Z/.]* +# Goo.gl +/goo\.gl/[a-zA-Z0-9]+ +# Google Chrome Store +\bchrome\.google\.com/webstore/detail/[-\w]*(?:/\w*|) +# Google Books +\bgoogle\.(?:\w{2,4})/books(?:/\w+)*\?[-\w\d=&#.]* +# Google Fonts +\bfonts\.(?:googleapis|gstatic)\.com/[-/?=:;+&0-9a-zA-Z]* +# Google Forms +\bforms\.gle/\w+ +# Google Scholar +\bscholar\.google\.com/citations\?user=[A-Za-z0-9_]+ +# Google Colab Research Drive +\bcolab\.research\.google\.com/drive/[-0-9a-zA-Z_?=]* + +# GitHub SHAs (api) +\bapi.github\.com/repos(?:/[^/\s"]+){3}/[0-9a-f]+\b +# GitHub SHAs (markdown) +(?:\[`?[0-9a-f]+`?\]\(https:/|)/(?:www\.|)github\.com(?:/[^/\s"]+){2,}(?:/[^/\s")]+)(?:[0-9a-f]+(?:[-0-9a-zA-Z/#.]*|)\b|) +# GitHub SHAs +\bgithub\.com(?:/[^/\s"]+){2}[@#][0-9a-f]+\b +# GitHub wiki +\bgithub\.com/(?:[^/]+/){2}wiki/(?:(?:[^/]+/|)_history|[^/]+(?:/_compare|)/[0-9a-f.]{40,})\b +# githubusercontent +/[-a-z0-9]+\.githubusercontent\.com/[-a-zA-Z0-9?&=_\/.]* +# githubassets +\bgithubassets.com/[0-9a-f]+(?:[-/\w.]+) +# gist github +\bgist\.github\.com/[^/\s"]+/[0-9a-f]+ +# git.io +\bgit\.io/[0-9a-zA-Z]+ +# GitHub JSON +"node_id": "[-a-zA-Z=;:/0-9+]*" +# Contributor +\[[^\]]+\]\(https://github\.com/[^/\s"]+\) +# GHSA +GHSA(?:-[0-9a-z]{4}){3} + +# GitLab commit +\bgitlab\.[^/\s"]*/\S+/\S+/commit/[0-9a-f]{7,16}#[0-9a-f]{40}\b +# GitLab merge requests +\bgitlab\.[^/\s"]*/\S+/\S+/-/merge_requests/\d+/diffs#[0-9a-f]{40}\b +# GitLab uploads +\bgitlab\.[^/\s"]*/uploads/[-a-zA-Z=;:/0-9+]* +# GitLab commits +\bgitlab\.[^/\s"]*/(?:[^/\s"]+/){2}commits?/[0-9a-f]+\b + +# binanace +accounts.binance.com/[a-z/]*oauth/authorize\?[-0-9a-zA-Z&%]* + +# bitbucket diff +\bapi\.bitbucket\.org/\d+\.\d+/repositories/(?:[^/\s"]+/){2}diff(?:stat|)(?:/[^/\s"]+){2}:[0-9a-f]+ +# bitbucket repositories commits +\bapi\.bitbucket\.org/\d+\.\d+/repositories/(?:[^/\s"]+/){2}commits?/[0-9a-f]+ +# bitbucket commits +\bbitbucket\.org/(?:[^/\s"]+/){2}commits?/[0-9a-f]+ + +# bit.ly +\bbit\.ly/\w+ + +# bitrise +\bapp\.bitrise\.io/app/[0-9a-f]*/[\w.?=&]* + +# bootstrapcdn.com +\bbootstrapcdn\.com/[-./\w]+ + +# cdn.cloudflare.com +\bcdnjs\.cloudflare\.com/[./\w]+ + +# circleci +\bcircleci\.com/gh(?:/[^/\s"]+){1,5}.[a-z]+\?[-0-9a-zA-Z=&]+ + +# gitter +\bgitter\.im(?:/[^/\s"]+){2}\?at=[0-9a-f]+ + +# gravatar +\bgravatar\.com/avatar/[0-9a-f]+ + +# ibm +[a-z.]*ibm\.com/[-_#=:%!?~.\\/\d\w]* + +# imgur +\bimgur\.com/[^.]+ + +# Internet Archive +\barchive\.org/web/\d+/(?:[-\w.?,'/\\+&%$#_:]*) + +# discord +/discord(?:app\.com|\.gg)/(?:invite/)?[a-zA-Z0-9]{7,} + +# Disqus +\bdisqus\.com/[-\w/%.()!?&=_]* + +# medium link +\blink\.medium\.com/[a-zA-Z0-9]+ +# medium +\bmedium\.com/\@?[^/\s"]+/[-\w]+ + +# microsoft +\b(?:https?://|)(?:(?:download\.visualstudio|docs|msdn2?|research)\.microsoft|blogs\.msdn)\.com/[-_a-zA-Z0-9()=./%]* +# powerbi +\bapp\.powerbi\.com/reportEmbed/[^"' ]* +# vs devops +\bvisualstudio.com(?::443|)/[-\w/?=%&.]* +# microsoft store +\bmicrosoft\.com/store/apps/\w+ + +# mvnrepository.com +\bmvnrepository\.com/[-0-9a-z./]+ + +# now.sh +/[0-9a-z-.]+\.now\.sh\b + +# oracle +\bdocs\.oracle\.com/[-0-9a-zA-Z./_?#&=]* + +# chromatic.com +/\S+.chromatic.com\S*[")] + +# codacy +\bapi\.codacy\.com/project/badge/Grade/[0-9a-f]+ + +# compai +\bcompai\.pub/v1/png/[0-9a-f]+ + +# mailgun api +\.api\.mailgun\.net/v3/domains/[0-9a-z]+\.mailgun.org/messages/[0-9a-zA-Z=@]* +# mailgun +\b[0-9a-z]+.mailgun.org + +# /message-id/ +/message-id/[-\w@./%]+ + +# Reddit +\breddit\.com/r/[/\w_]* + +# requestb.in +\brequestb\.in/[0-9a-z]+ + +# sched +\b[a-z0-9]+\.sched\.com\b + +# Slack url +slack://[a-zA-Z0-9?&=]+ +# Slack +\bslack\.com/[-0-9a-zA-Z/_~?&=.]* +# Slack edge +\bslack-edge\.com/[-a-zA-Z0-9?&=%./]+ +# Slack images +\bslack-imgs\.com/[-a-zA-Z0-9?&=%.]+ + +# shields.io +\bshields\.io/[-\w/%?=&.:+;,]* + +# stackexchange -- https://stackexchange.com/feeds/sites +\b(?:askubuntu|serverfault|stack(?:exchange|overflow)|superuser).com/(?:questions/\w+/[-\w]+|a/) + +# Sentry +[0-9a-f]{32}\@o\d+\.ingest\.sentry\.io\b + +# Twitter markdown +\[\@[^[/\]:]*?\]\(https://twitter.com/[^/\s"')]*(?:/status/\d+(?:\?[-_0-9a-zA-Z&=]*|)|)\) +# Twitter hashtag +\btwitter\.com/hashtag/[\w?_=&]* +# Twitter status +\btwitter\.com/[^/\s"')]*(?:/status/\d+(?:\?[-_0-9a-zA-Z&=]*|)|) +# Twitter profile images +\btwimg\.com/profile_images/[_\w./]* +# Twitter media +\btwimg\.com/media/[-_\w./?=]* +# Twitter link shortened +\bt\.co/\w+ + +# facebook +\bfburl\.com/[0-9a-z_]+ +# facebook CDN +\bfbcdn\.net/[\w/.,]* +# facebook watch +\bfb\.watch/[0-9A-Za-z]+ + +# dropbox +\bdropbox\.com/sh?/[^/\s"]+/[-0-9A-Za-z_.%?=&;]+ + +# ipfs protocol +ipfs://[0-9a-z]* +# ipfs url +/ipfs/[0-9a-z]* + +# w3 +\bw3\.org/[-0-9a-zA-Z/#.]+ + +# loom +\bloom\.com/embed/[0-9a-f]+ + +# regex101 +\bregex101\.com/r/[^/\s"]+/\d+ + +# figma +\bfigma\.com/file(?:/[0-9a-zA-Z]+/)+ + +# freecodecamp.org +\bfreecodecamp\.org/[-\w/.]+ + +# image.tmdb.org +\bimage\.tmdb\.org/[/\w.]+ + +# mermaid +\bmermaid\.ink/img/[-\w]+|\bmermaid-js\.github\.io/mermaid-live-editor/#/edit/[-\w]+ + +# Wikipedia +\ben\.wikipedia\.org/wiki/[-\w%.#]+ + +# gitweb +[^"\s]+/gitweb/\S+;h=[0-9a-f]+ + +# HyperKitty lists +/archives/list/[^@/]+\@[^/\s"]*/message/[^/\s"]*/ + +# lists +/thread\.html/[^"\s]+ + +# list-management +\blist-manage\.com/subscribe(?:[?&](?:u|id)=[0-9a-f]+)+ + +# kubectl.kubernetes.io/last-applied-configuration +"kubectl.kubernetes.io/last-applied-configuration": ".*" + +# pgp +\bgnupg\.net/pks/lookup[?&=0-9a-zA-Z]* + +# Spotify +\bopen\.spotify\.com/embed/playlist/\w+ + +# Mastodon +\bmastodon\.[-a-z.]*/(?:media/|\@)[?&=0-9a-zA-Z_]* + +# scastie +\bscastie\.scala-lang\.org/[^/]+/\w+ + +# images.unsplash.com +\bimages\.unsplash\.com/(?:(?:flagged|reserve)/|)[-\w./%?=%&.;]+ + +# pastebin +\bpastebin\.com/[\w/]+ + +# heroku +\b\w+\.heroku\.com/source/archive/\w+ + +# quip +\b\w+\.quip\.com/\w+(?:(?:#|/issues/)\w+)? + +# badgen.net +\bbadgen\.net/badge/[^")\]'\s]+ + +# statuspage.io +\w+\.statuspage\.io\b + +# media.giphy.com +\bmedia\.giphy\.com/media/[^/]+/[\w.?&=]+ + +# tinyurl +\btinyurl\.com/\w+ + +# getopts +\bgetopts\s+(?:"[^"]+"|'[^']+') + +# ANSI color codes +(?:\\(?:u00|x)1b|\x1b)\[\d+(?:;\d+|)m + +# URL escaped characters +\%[0-9A-F][A-F] +# IPv6 +\b(?:[0-9a-fA-F]{0,4}:){3,7}[0-9a-fA-F]{0,4}\b +# c99 hex digits (not the full format, just one I've seen) +0x[0-9a-fA-F](?:\.[0-9a-fA-F]*|)[pP] +# Punycode +\bxn--[-0-9a-z]+ +# sha +sha\d+:[0-9]*[a-f]{3,}[0-9a-f]* +# sha-... -- uses a fancy capture +(['"]|")[0-9a-f]{40,}\g{-1} +# hex runs +\b[0-9a-fA-F]{16,}\b +# hex in url queries +=[0-9a-fA-F]*?(?:[A-F]{3,}|[a-f]{3,})[0-9a-fA-F]*?& +# ssh +(?:ssh-\S+|-nistp256) [-a-zA-Z=;:/0-9+]{12,} + +# PGP +\b(?:[0-9A-F]{4} ){9}[0-9A-F]{4}\b +# GPG keys +\b(?:[0-9A-F]{4} ){5}(?: [0-9A-F]{4}){5}\b +# Well known gpg keys +.well-known/openpgpkey/[\w./]+ + +# uuid: +\b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\b +# hex digits including css/html color classes: +(?:[\\0][xX]|\\u|[uU]\+|#x?|\%23)[0-9_a-fA-FgGrR]*?[a-fA-FgGrR]{2,}[0-9_a-fA-FgGrR]*(?:[uUlL]{0,3}|u\d+)\b +# integrity +integrity="sha\d+-[-a-zA-Z=;:/0-9+]{40,}" + +# https://www.gnu.org/software/groff/manual/groff.html +# man troff content +\\f[BCIPR] +# ' +\\\(aq + +# .desktop mime types +^MimeTypes?=.*$ +# .desktop localized entries +^[A-Z][a-z]+\[[a-z]+\]=.*$ +# Localized .desktop content +Name\[[^\]]+\]=.* + +# IServiceProvider +\bI(?=(?:[A-Z][a-z]{2,})+\b) + +# crypt +"\$2[ayb]\$.{56}" + +# scrypt / argon +\$(?:scrypt|argon\d+[di]*)\$\S+ + +# Input to GitHub JSON +content: "[-a-zA-Z=;:/0-9+]*=" + +# Python stringprefix / binaryprefix +# Note that there's a high false positive rate, remove the `?=` and search for the regex to see if the matches seem like reasonable strings +(?v# +(?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\d+(?:\b|(?=[a-zA-Z_])) +# Compiler flags (Scala) +(?:^|[\t ,>"'`=(])-J-[DPWXY](?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,}) +# Compiler flags +(?:^|[\t ,"'`=(])-[DPWXYLlf](?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,}) +# Compiler flags (linker) +,-B +# curl arguments +\b(?:\\n|)curl(?:\s+-[a-zA-Z]{1,2}\b)*(?:\s+-[a-zA-Z]{3,})(?:\s+-[a-zA-Z]+)* +# set arguments +\bset(?:\s+-[abefimouxE]{1,2})*\s+-[abefimouxE]{3,}(?:\s+-[abefimouxE]+)* +# tar arguments +\b(?:\\n|)g?tar(?:\.exe|)(?:(?:\s+--[-a-zA-Z]+|\s+-[a-zA-Z]+|\s[ABGJMOPRSUWZacdfh-pr-xz]+\b)(?:=[^ ]*|))+ +# tput arguments -- https://man7.org/linux/man-pages/man5/terminfo.5.html -- technically they can be more than 5 chars long... +\btput\s+(?:(?:-[SV]|-T\s*\w+)\s+)*\w{3,5}\b +# macOS temp folders +/var/folders/\w\w/[+\w]+/(?:T|-Caches-)/ diff --git a/.github/actions/spelling/excludes.txt b/.github/actions/spelling/excludes.txt new file mode 100644 index 000000000..224014eba --- /dev/null +++ b/.github/actions/spelling/excludes.txt @@ -0,0 +1,73 @@ +# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-excludes +(?:^|/)(?i)COPYRIGHT +(?:^|/)(?i)LICEN[CS]E +(?:^|/)3rdparty/ +(?:^|/)go\.sum$ +(?:^|/)package(?:-lock|)\.json$ +(?:^|/)vendor/ +\.a$ +\.ai$ +\.avi$ +\.bmp$ +\.bz2$ +\.class$ +\.crt$ +\.dll$ +\.docx?$ +\.drawio$ +\.DS_Store$ +\.eot$ +\.exe$ +\.gif$ +\.gitattributes$ +\.gitignore$ +\.graffle$ +\.gz$ +\.icns$ +\.ico$ +\.jar$ +\.jks$ +\.jpe?g$ +\.key$ +\.lib$ +\.lock$ +\.map$ +\.min\.. +\.mod$ +\.mp[34]$ +\.o$ +\.ocf$ +\.otf$ +\.pdf$ +\.pem$ +\.png$ +\.psd$ +\.pyc$ +\.s$ +\.svgz?$ +\.tar$ +\.tiff?$ +\.ttf$ +\.wav$ +\.webm$ +\.webp$ +\.woff2?$ +\.xlsx?$ +\.zip$ +^\.github/actions/spelling/ +^\Q.github/workflows/spelling.yml\E$ +# Custom +(?:^|/)Languages/(?!en\.xaml) +Scripts/ +\.resx$ +^\QPlugins/Flow.Launcher.Plugin.WindowsSettings/WindowsSettings.json\E$ +^\QPlugins/Flow.Launcher.Plugin.WebSearch/setting.json\E$ +(?:^|/)FodyWeavers\.xml +.editorconfig +ignore$ +\.ps1$ +\.yml$ +\.sln$ +\.csproj$ +\.DotSettings$ +\.targets$ diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt new file mode 100644 index 000000000..6ba41e410 --- /dev/null +++ b/.github/actions/spelling/expect.txt @@ -0,0 +1,112 @@ +crowdin +DWM +workflows +wpf +actionkeyword +stackoverflow +Wox +flowlauncher +Fody +stackoverflow +IContext +IShell +IPlugin +appveyor +netflix +youtube +appdata +Prioritise +Segoe +Google +Customise +uwp +Bokmal +Bokm +uninstallation +uninstalling +voidtools +fullscreen +hotkeys +totalcmd +lnk +amazonaws +mscorlib +pythonw +dotnet +winget +jjw24 +wolframalpha +gmail +duckduckgo +facebook +findicon +baidu +pls +websearch +qianlifeng +userdata +srchadmin +EWX +dlgtext +CMD +appref-ms +appref +TSource +runas +dpi +popup +ptr +pluginindicator +TobiasSekan +img +resx +bak +tmp +directx +mvvm +dlg +ddd +dddd +clearlogfolder +ACCENT_ENABLE_TRANSPARENTGRADIENT +ACCENT_ENABLE_BLURBEHIND +WCA_ACCENT_POLICY +HGlobal +dopusrt +firefox +Firefox +msedge +svgc +ime +zindex +txb +btn +otf +searchplugin +wpftk +mkv +flac +IPublic +keyevent +KListener +requery +vkcode +čeština +Polski +Srpski +Português +Português (Brasil) +Italiano +Slovenský +quicklook +Tiếng Việt +Droplex +Preinstalled +errormetadatafile +noresult +pluginsmanager +alreadyexists +JsonRPC +JsonRPCV2 +Softpedia +img diff --git a/.github/actions/spelling/line_forbidden.patterns b/.github/actions/spelling/line_forbidden.patterns new file mode 100644 index 000000000..7341d9b73 --- /dev/null +++ b/.github/actions/spelling/line_forbidden.patterns @@ -0,0 +1,62 @@ +# reject `m_data` as there's a certain OS which has evil defines that break things if it's used elsewhere +# \bm_data\b + +# If you have a framework that uses `it()` for testing and `fit()` for debugging a specific test, +# you might not want to check in code where you were debugging w/ `fit()`, in which case, you might want +# to use this: +#\bfit\( + +# s.b. GitHub +#\bGithub\b + +# s.b. GitLab +\bGitlab\b + +# s.b. JavaScript +\bJavascript\b + +# s.b. Microsoft +\bMicroSoft\b + +# s.b. another +\ban[- ]other\b + +# s.b. greater than +\bgreater then\b + +# s.b. into +\sin to\s + +# s.b. opt-in +\sopt in\s + +# s.b. less than +\bless then\b + +# s.b. otherwise +\bother[- ]wise\b + +# s.b. nonexistent +\bnon existing\b +\b[Nn]o[nt][- ]existent\b + +# s.b. preexisting +[Pp]re[- ]existing + +# s.b. preempt +[Pp]re[- ]empt\b + +# s.b. preemptively +[Pp]re[- ]emptively + +# s.b. reentrancy +[Rr]e[- ]entrancy + +# s.b. reentrant +[Rr]e[- ]entrant + +# s.b. workaround(s) +\bwork[- ]arounds?\b + +# Reject duplicate words +\s([A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,})\s\g{-1}\s diff --git a/.github/actions/spelling/patterns.txt b/.github/actions/spelling/patterns.txt new file mode 100644 index 000000000..f29f57ad5 --- /dev/null +++ b/.github/actions/spelling/patterns.txt @@ -0,0 +1,123 @@ +# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns + +# Questionably acceptable forms of `in to` +# Personally, I prefer `log into`, but people object +# https://www.tprteaching.com/log-into-log-in-to-login/ +\b[Ll]og in to\b + +# acceptable duplicates +# ls directory listings +[-bcdlpsw](?:[-r][-w][-sx]){3}\s+\d+\s+(\S+)\s+\g{-1}\s+\d+\s+ +# C types and repeated CSS values +\s(center|div|inherit|long|LONG|none|normal|solid|thin|transparent|very)(?: \g{-1})+\s +# go templates +\s(\w+)\s+\g{-1}\s+\`(?:graphql|json|yaml): +# javadoc / .net +(?:[\\@](?:groupname|param)|(?:public|private)(?:\s+static|\s+readonly)*)\s+(\w+)\s+\g{-1}\s + +# Commit message -- Signed-off-by and friends +^\s*(?:(?:Based-on-patch|Co-authored|Helped|Mentored|Reported|Reviewed|Signed-off)-by|Thanks-to): (?:[^<]*<[^>]*>|[^<]*)\s*$ + +# Autogenerated revert commit message +^This reverts commit [0-9a-f]{40}\.$ + +# ignore long runs of a single character: +\b([A-Za-z])\g{-1}{3,}\b + +# Automatically suggested patterns +# hit-count: 360 file-count: 108 +# IServiceProvider +\bI(?=(?:[A-Z][a-z]{2,})+\b) + +# hit-count: 297 file-count: 18 +# uuid: +\b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\b + +# hit-count: 138 file-count: 27 +# hex digits including css/html color classes: +(?:[\\0][xX]|\\u|[uU]\+|#x?|\%23)[0-9_a-fA-FgGrR]*?[a-fA-FgGrR]{2,}[0-9_a-fA-FgGrR]*(?:[uUlL]{0,3}|u\d+)\b + +# hit-count: 93 file-count: 28 +# hex runs +\b[0-9a-fA-F]{16,}\b + +# hit-count: 52 file-count: 3 +# githubusercontent +/[-a-z0-9]+\.githubusercontent\.com/[-a-zA-Z0-9?&=_\/.]* + +/[-a-z0-9]+\.github\.com/[-a-zA-Z0-9?&=_\/.]* + +# hit-count: 24 file-count: 12 +# GitHub SHAs (markdown) +(?:\[`?[0-9a-f]+`?\]\(https:/|)/(?:www\.|)github\.com(?:/[^/\s"]+){2,}(?:/[^/\s")]+)(?:[0-9a-f]+(?:[-0-9a-zA-Z/#.]*|)\b|) + +# hit-count: 11 file-count: 10 +# stackexchange -- https://stackexchange.com/feeds/sites +\b(?:askubuntu|serverfault|stack(?:exchange|overflow)|superuser).com/(?:questions/\w+/[-\w]+|a/) + +# hit-count: 11 file-count: 8 +# microsoft +\b(?:https?://|)(?:(?:download\.visualstudio|docs|msdn2?|research)\.microsoft|blogs\.msdn)\.com/[-_a-zA-Z0-9()=./%]* + +# hit-count: 2 file-count: 2 +# Twitter status +\btwitter\.com/[^/\s"')]*(?:/status/\d+(?:\?[-_0-9a-zA-Z&=]*|)|) + +# hit-count: 2 file-count: 1 +# discord +/discord(?:app\.com|\.gg)/(?:invite/)?[a-zA-Z0-9]{7,} + +# hit-count: 1 file-count: 1 +# appveyor api +\bci\.appveyor\.com/api/projects/status/[0-9a-z]+ + +# hit-count: 1 file-count: 1 +# gist github +\bgist\.github\.com/[^/\s"]+/[0-9a-f]+ + +# Automatically suggested patterns +# hit-count: 21 file-count: 21 +# w3 +\bw3\.org/[-0-9a-zA-Z/#.]+ + +# hit-count: 6 file-count: 1 +# shields.io +\bshields\.io/[-\w/%?=&.:+;,]* + +# hit-count: 3 file-count: 2 +# While you could try to match `http://` and `https://` by using `s?` in `https?://`, sometimes there +# YouTube url +\b(?:(?:www\.|)youtube\.com|youtu.be)/(?:channel/|embed/|user/|playlist\?list=|watch\?v=|v/|)[-a-zA-Z0-9?&=_%]* + +# hit-count: 2 file-count: 2 +# Google Maps +\bmaps\.google\.com/maps\?[\w&;=]* + +# hit-count: 2 file-count: 2 +# Contributor +\[[^\]]+\]\(https://github\.com/[^/\s"]+\) + +# hit-count: 2 file-count: 1 +# URL escaped characters +\%[0-9A-F][A-F] + +# hit-count: 1 file-count: 1 +# Compiler flags +(?:^|[\t ,"'`=(])-[DPWXYLlf](?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,}) + +# Localization keys +#x:Key="[^"]+" +#{DynamicResource [^"]+} + +# html tag +<\w+[^>]*> +]*> + +#http/https +(?:\b(?:https?|ftp|file)://)[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|] + +# UWP +[Uu][Ww][Pp] + +# version suffix v# +(?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\d+(?:\b|(?=[a-zA-Z_])) diff --git a/.github/actions/spelling/reject.txt b/.github/actions/spelling/reject.txt new file mode 100644 index 000000000..b5a6d3680 --- /dev/null +++ b/.github/actions/spelling/reject.txt @@ -0,0 +1,10 @@ +^attache$ +benefitting +occurences? +^dependan.* +^oer$ +Sorce +^[Ss]pae.* +^untill$ +^untilling$ +^wether.* diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 454c4e976..da4231f74 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,10 +8,15 @@ 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: - "jjw24" - "taooceros" - "JohnTheGr8" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml new file mode 100644 index 000000000..5556c5ea8 --- /dev/null +++ b/.github/pr-labeler.yml @@ -0,0 +1,31 @@ +# The bot always updates the labels, add/remove as necessary [default: false] +alwaysReplace: false +# Treats the text and labels as case sensitive [default: true] +caseSensitive: false +# Array of labels to be applied to the PR [default: []] +customLabels: + # Finds the `text` within the PR title and body and applies the `label` + - text: 'bug' + label: 'bug' + - text: 'fix' + label: 'bug' + - text: 'dependabot' + label: 'bug' + - text: 'New Crowdin updates' + label: 'bug' + - text: 'New Crowdin updates' + label: 'kind/i18n' + - text: 'feature' + label: 'enhancement' + - text: 'add new' + label: 'enhancement' + - text: 'refactor' + label: 'enhancement' + - text: 'refactor' + label: 'Code Refactor' +# Search the body of the PR for the `text` [default: true] +searchBody: true +# Search the title of the PR for the `text` [default: true] +searchTitle: true +# Search for whole words only [default: false] +wholeWords: false diff --git a/.github/workflows/default_plugins.yml b/.github/workflows/default_plugins.yml new file mode 100644 index 000000000..85acafae1 --- /dev/null +++ b/.github/workflows/default_plugins.yml @@ -0,0 +1,372 @@ +name: Publish Default Plugins + +on: + push: + branches: ['master'] + paths: ['Plugins/**'] + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 7.0.x + + - name: Determine New Plugin Updates + uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + browserbookmark: + - 'Plugins/Flow.Launcher.Plugin.BrowserBookmark/plugin.json' + calculator: + - 'Plugins/Flow.Launcher.Plugin.Calculator/plugin.json' + explorer: + - 'Plugins/Flow.Launcher.Plugin.Explorer/plugin.json' + pluginindicator: + - 'Plugins/Flow.Launcher.Plugin.PluginIndicator/plugin.json' + pluginsmanager: + - 'Plugins/Flow.Launcher.Plugin.PluginsManager/plugin.json' + processkiller: + - 'Plugins/Flow.Launcher.Plugin.ProcessKiller/plugin.json' + program: + - 'Plugins/Flow.Launcher.Plugin.Program/plugin.json' + shell: + - 'Plugins/Flow.Launcher.Plugin.Shell/plugin.json' + sys: + - 'Plugins/Flow.Launcher.Plugin.Sys/plugin.json' + url: + - 'Plugins/Flow.Launcher.Plugin.Url/plugin.json' + websearch: + - 'Plugins/Flow.Launcher.Plugin.WebSearch/plugin.json' + windowssettings: + - 'Plugins/Flow.Launcher.Plugin.WindowsSettings/plugin.json' + base: 'master' + + - name: Get BrowserBookmark Version + if: steps.changes.outputs.browserbookmark == 'true' + id: updated-version-browserbookmark + uses: notiz-dev/github-action-json-property@release + with: + path: 'Plugins/Flow.Launcher.Plugin.BrowserBookmark/plugin.json' + prop_path: 'Version' + + - name: Build BrowserBookmark + if: steps.changes.outputs.browserbookmark == 'true' + run: | + dotnet publish 'Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.BrowserBookmark" + 7z a -tzip "Flow.Launcher.Plugin.BrowserBookmark.zip" "./Flow.Launcher.Plugin.BrowserBookmark/*" + rm -r "Flow.Launcher.Plugin.BrowserBookmark" + + - name: Publish BrowserBookmark + if: steps.changes.outputs.browserbookmark == 'true' + uses: softprops/action-gh-release@v2 + with: + repository: "Flow-Launcher/Flow.Launcher.Plugin.BrowserBookmark" + files: "Flow.Launcher.Plugin.BrowserBookmark.zip" + tag_name: "v${{steps.updated-version-browserbookmark.outputs.prop}}" + body: Visit Flow's [release notes](https://github.com/Flow-Launcher/Flow.Launcher/releases) for changes. + env: + GITHUB_TOKEN: ${{ secrets.PUBLISH_PLUGINS }} + + + - name: Get Calculator Version + if: steps.changes.outputs.calculator == 'true' + id: updated-version-calculator + uses: notiz-dev/github-action-json-property@release + with: + path: 'Plugins/Flow.Launcher.Plugin.Calculator/plugin.json' + prop_path: 'Version' + + - name: Build Calculator + if: steps.changes.outputs.calculator == 'true' + run: | + dotnet publish 'Plugins/Flow.Launcher.Plugin.Calculator/Flow.Launcher.Plugin.Calculator.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Calculator" + 7z a -tzip "Flow.Launcher.Plugin.Calculator.zip" "./Flow.Launcher.Plugin.Calculator/*" + rm -r "Flow.Launcher.Plugin.Calculator" + + - name: Publish Calculator + if: steps.changes.outputs.calculator == 'true' + uses: softprops/action-gh-release@v2 + with: + repository: "Flow-Launcher/Flow.Launcher.Plugin.Calculator" + files: "Flow.Launcher.Plugin.Calculator.zip" + tag_name: "v${{steps.updated-version-calculator.outputs.prop}}" + body: Visit Flow's [release notes](https://github.com/Flow-Launcher/Flow.Launcher/releases) for changes. + env: + GITHUB_TOKEN: ${{ secrets.PUBLISH_PLUGINS }} + + + - name: Get Explorer Version + if: steps.changes.outputs.explorer == 'true' + id: updated-version-explorer + uses: notiz-dev/github-action-json-property@release + with: + path: 'Plugins/Flow.Launcher.Plugin.Explorer/plugin.json' + prop_path: 'Version' + + - name: Build Explorer + if: steps.changes.outputs.explorer == 'true' + run: | + dotnet publish 'Plugins/Flow.Launcher.Plugin.Explorer/Flow.Launcher.Plugin.Explorer.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Explorer" + 7z a -tzip "Flow.Launcher.Plugin.Explorer.zip" "./Flow.Launcher.Plugin.Explorer/*" + rm -r "Flow.Launcher.Plugin.Explorer" + + - name: Publish Explorer + if: steps.changes.outputs.explorer == 'true' + uses: softprops/action-gh-release@v2 + with: + repository: "Flow-Launcher/Flow.Launcher.Plugin.Explorer" + files: "Flow.Launcher.Plugin.Explorer.zip" + tag_name: "v${{steps.updated-version-explorer.outputs.prop}}" + body: Visit Flow's [release notes](https://github.com/Flow-Launcher/Flow.Launcher/releases) for changes. + env: + GITHUB_TOKEN: ${{ secrets.PUBLISH_PLUGINS }} + + + - name: Get PluginIndicator Version + if: steps.changes.outputs.pluginindicator == 'true' + id: updated-version-pluginindicator + uses: notiz-dev/github-action-json-property@release + with: + path: 'Plugins/Flow.Launcher.Plugin.PluginIndicator/plugin.json' + prop_path: 'Version' + + - name: Build PluginIndicator + if: steps.changes.outputs.pluginindicator == 'true' + run: | + dotnet publish 'Plugins/Flow.Launcher.Plugin.PluginIndicator/Flow.Launcher.Plugin.PluginIndicator.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.PluginIndicator" + 7z a -tzip "Flow.Launcher.Plugin.PluginIndicator.zip" "./Flow.Launcher.Plugin.PluginIndicator/*" + rm -r "Flow.Launcher.Plugin.PluginIndicator" + + - name: Publish PluginIndicator + if: steps.changes.outputs.pluginindicator == 'true' + uses: softprops/action-gh-release@v2 + with: + repository: "Flow-Launcher/Flow.Launcher.Plugin.PluginIndicator" + files: "Flow.Launcher.Plugin.PluginIndicator.zip" + tag_name: "v${{steps.updated-version-pluginindicator.outputs.prop}}" + body: Visit Flow's [release notes](https://github.com/Flow-Launcher/Flow.Launcher/releases) for changes. + env: + GITHUB_TOKEN: ${{ secrets.PUBLISH_PLUGINS }} + + + - name: Get PluginsManager Version + if: steps.changes.outputs.pluginsmanager == 'true' + id: updated-version-pluginsmanager + uses: notiz-dev/github-action-json-property@release + with: + path: 'Plugins/Flow.Launcher.Plugin.PluginsManager/plugin.json' + prop_path: 'Version' + + - name: Build PluginsManager + if: steps.changes.outputs.pluginsmanager == 'true' + run: | + dotnet publish 'Plugins/Flow.Launcher.Plugin.PluginsManager/Flow.Launcher.Plugin.PluginsManager.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.PluginsManager" + 7z a -tzip "Flow.Launcher.Plugin.PluginsManager.zip" "./Flow.Launcher.Plugin.PluginsManager/*" + rm -r "Flow.Launcher.Plugin.PluginsManager" + + - name: Publish PluginsManager + if: steps.changes.outputs.pluginsmanager == 'true' + uses: softprops/action-gh-release@v2 + with: + repository: "Flow-Launcher/Flow.Launcher.Plugin.PluginsManager" + files: "Flow.Launcher.Plugin.PluginsManager.zip" + tag_name: "v${{steps.updated-version-pluginsmanager.outputs.prop}}" + body: Visit Flow's [release notes](https://github.com/Flow-Launcher/Flow.Launcher/releases) for changes. + env: + GITHUB_TOKEN: ${{ secrets.PUBLISH_PLUGINS }} + + + - name: Get ProcessKiller Version + if: steps.changes.outputs.processkiller == 'true' + id: updated-version-processkiller + uses: notiz-dev/github-action-json-property@release + with: + path: 'Plugins/Flow.Launcher.Plugin.ProcessKiller/plugin.json' + prop_path: 'Version' + + - name: Build ProcessKiller + if: steps.changes.outputs.processkiller == 'true' + run: | + dotnet publish 'Plugins/Flow.Launcher.Plugin.ProcessKiller/Flow.Launcher.Plugin.ProcessKiller.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.ProcessKiller" + 7z a -tzip "Flow.Launcher.Plugin.ProcessKiller.zip" "./Flow.Launcher.Plugin.ProcessKiller/*" + rm -r "Flow.Launcher.Plugin.ProcessKiller" + + - name: Publish ProcessKiller + if: steps.changes.outputs.processkiller == 'true' + uses: softprops/action-gh-release@v2 + with: + repository: "Flow-Launcher/Flow.Launcher.Plugin.ProcessKiller" + files: "Flow.Launcher.Plugin.ProcessKiller.zip" + tag_name: "v${{steps.updated-version-processkiller.outputs.prop}}" + body: Visit Flow's [release notes](https://github.com/Flow-Launcher/Flow.Launcher/releases) for changes. + env: + GITHUB_TOKEN: ${{ secrets.PUBLISH_PLUGINS }} + + + - name: Get Program Version + if: steps.changes.outputs.program == 'true' + id: updated-version-program + uses: notiz-dev/github-action-json-property@release + with: + path: 'Plugins/Flow.Launcher.Plugin.Program/plugin.json' + prop_path: 'Version' + + - name: Build Program + if: steps.changes.outputs.program == 'true' + run: | + dotnet publish 'Plugins/Flow.Launcher.Plugin.Program/Flow.Launcher.Plugin.Program.csproj' --framework net7.0-windows10.0.19041.0 -c Release -o "Flow.Launcher.Plugin.Program" + 7z a -tzip "Flow.Launcher.Plugin.Program.zip" "./Flow.Launcher.Plugin.Program/*" + rm -r "Flow.Launcher.Plugin.Program" + + - name: Publish Program + if: steps.changes.outputs.program == 'true' + uses: softprops/action-gh-release@v2 + with: + repository: "Flow-Launcher/Flow.Launcher.Plugin.Program" + files: "Flow.Launcher.Plugin.Program.zip" + tag_name: "v${{steps.updated-version-program.outputs.prop}}" + body: Visit Flow's [release notes](https://github.com/Flow-Launcher/Flow.Launcher/releases) for changes. + env: + GITHUB_TOKEN: ${{ secrets.PUBLISH_PLUGINS }} + + + - name: Get Shell Version + if: steps.changes.outputs.shell == 'true' + id: updated-version-shell + uses: notiz-dev/github-action-json-property@release + with: + path: 'Plugins/Flow.Launcher.Plugin.Shell/plugin.json' + prop_path: 'Version' + + - name: Build Shell + if: steps.changes.outputs.shell == 'true' + run: | + dotnet publish 'Plugins/Flow.Launcher.Plugin.Shell/Flow.Launcher.Plugin.Shell.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Shell" + 7z a -tzip "Flow.Launcher.Plugin.Shell.zip" "./Flow.Launcher.Plugin.Shell/*" + rm -r "Flow.Launcher.Plugin.Shell" + + - name: Publish Shell + if: steps.changes.outputs.shell == 'true' + uses: softprops/action-gh-release@v2 + with: + repository: "Flow-Launcher/Flow.Launcher.Plugin.Shell" + files: "Flow.Launcher.Plugin.Shell.zip" + tag_name: "v${{steps.updated-version-shell.outputs.prop}}" + body: Visit Flow's [release notes](https://github.com/Flow-Launcher/Flow.Launcher/releases) for changes. + env: + GITHUB_TOKEN: ${{ secrets.PUBLISH_PLUGINS }} + + + - name: Get Sys Version + if: steps.changes.outputs.sys == 'true' + id: updated-version-sys + uses: notiz-dev/github-action-json-property@release + with: + path: 'Plugins/Flow.Launcher.Plugin.Sys/plugin.json' + prop_path: 'Version' + + - name: Build Sys + if: steps.changes.outputs.sys == 'true' + run: | + dotnet publish 'Plugins/Flow.Launcher.Plugin.Sys/Flow.Launcher.Plugin.Sys.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Sys" + 7z a -tzip "Flow.Launcher.Plugin.Sys.zip" "./Flow.Launcher.Plugin.Sys/*" + rm -r "Flow.Launcher.Plugin.Sys" + + - name: Publish Sys + if: steps.changes.outputs.sys == 'true' + uses: softprops/action-gh-release@v2 + with: + repository: "Flow-Launcher/Flow.Launcher.Plugin.Sys" + files: "Flow.Launcher.Plugin.Sys.zip" + tag_name: "v${{steps.updated-version-sys.outputs.prop}}" + body: Visit Flow's [release notes](https://github.com/Flow-Launcher/Flow.Launcher/releases) for changes. + env: + GITHUB_TOKEN: ${{ secrets.PUBLISH_PLUGINS }} + + + - name: Get Url Version + if: steps.changes.outputs.url == 'true' + id: updated-version-url + uses: notiz-dev/github-action-json-property@release + with: + path: 'Plugins/Flow.Launcher.Plugin.Url/plugin.json' + prop_path: 'Version' + + - name: Build Url + if: steps.changes.outputs.url == 'true' + run: | + dotnet publish 'Plugins/Flow.Launcher.Plugin.Url/Flow.Launcher.Plugin.Url.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Url" + 7z a -tzip "Flow.Launcher.Plugin.Url.zip" "./Flow.Launcher.Plugin.Url/*" + rm -r "Flow.Launcher.Plugin.Url" + + - name: Publish Url + if: steps.changes.outputs.url == 'true' + uses: softprops/action-gh-release@v2 + with: + repository: "Flow-Launcher/Flow.Launcher.Plugin.Url" + files: "Flow.Launcher.Plugin.Url.zip" + tag_name: "v${{steps.updated-version-url.outputs.prop}}" + body: Visit Flow's [release notes](https://github.com/Flow-Launcher/Flow.Launcher/releases) for changes. + env: + GITHUB_TOKEN: ${{ secrets.PUBLISH_PLUGINS }} + + + - name: Get WebSearch Version + if: steps.changes.outputs.websearch == 'true' + id: updated-version-websearch + uses: notiz-dev/github-action-json-property@release + with: + path: 'Plugins/Flow.Launcher.Plugin.WebSearch/plugin.json' + prop_path: 'Version' + + - name: Build WebSearch + if: steps.changes.outputs.websearch == 'true' + run: | + dotnet publish 'Plugins/Flow.Launcher.Plugin.WebSearch/Flow.Launcher.Plugin.WebSearch.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.WebSearch" + 7z a -tzip "Flow.Launcher.Plugin.WebSearch.zip" "./Flow.Launcher.Plugin.WebSearch/*" + rm -r "Flow.Launcher.Plugin.WebSearch" + + - name: Publish WebSearch + if: steps.changes.outputs.websearch == 'true' + uses: softprops/action-gh-release@v2 + with: + repository: "Flow-Launcher/Flow.Launcher.Plugin.WebSearch" + files: "Flow.Launcher.Plugin.WebSearch.zip" + tag_name: "v${{steps.updated-version-websearch.outputs.prop}}" + body: Visit Flow's [release notes](https://github.com/Flow-Launcher/Flow.Launcher/releases) for changes. + env: + GITHUB_TOKEN: ${{ secrets.PUBLISH_PLUGINS }} + + + - name: Get WindowsSettings Version + if: steps.changes.outputs.windowssettings == 'true' + id: updated-version-windowssettings + uses: notiz-dev/github-action-json-property@release + with: + path: 'Plugins/Flow.Launcher.Plugin.WindowsSettings/plugin.json' + prop_path: 'Version' + + - name: Build WindowsSettings + if: steps.changes.outputs.windowssettings == 'true' + run: | + dotnet publish 'Plugins/Flow.Launcher.Plugin.WindowsSettings/Flow.Launcher.Plugin.WindowsSettings.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.WindowsSettings" + 7z a -tzip "Flow.Launcher.Plugin.WindowsSettings.zip" "./Flow.Launcher.Plugin.WindowsSettings/*" + rm -r "Flow.Launcher.Plugin.WindowsSettings" + + - name: Publish WindowsSettings + if: steps.changes.outputs.windowssettings == 'true' + uses: softprops/action-gh-release@v2 + with: + repository: "Flow-Launcher/Flow.Launcher.Plugin.WindowsSettings" + files: "Flow.Launcher.Plugin.WindowsSettings.zip" + tag_name: "v${{steps.updated-version-windowssettings.outputs.prop}}" + body: Visit Flow's [release notes](https://github.com/Flow-Launcher/Flow.Launcher/releases) for changes. + env: + GITHUB_TOKEN: ${{ secrets.PUBLISH_PLUGINS }} diff --git a/.github/workflows/gitstream.yml b/.github/workflows/gitstream.yml new file mode 100644 index 000000000..0916572df --- /dev/null +++ b/.github/workflows/gitstream.yml @@ -0,0 +1,49 @@ +# Code generated by gitStream GitHub app - DO NOT EDIT + +name: gitStream workflow automation +run-name: | + /:\ gitStream: PR #${{ fromJSON(fromJSON(github.event.inputs.client_payload)).pullRequestNumber }} from ${{ github.event.inputs.full_repository }} + +on: + workflow_dispatch: + inputs: + client_payload: + description: The Client payload + required: true + full_repository: + description: the repository name include the owner in `owner/repo_name` format + required: true + head_ref: + description: the head sha + required: true + base_ref: + description: the base ref + required: true + installation_id: + description: the installation id + required: false + resolver_url: + description: the resolver url to pass results to + required: true + resolver_token: + description: Optional resolver token for resolver service + required: false + default: '' + +jobs: + gitStream: + timeout-minutes: 5 + runs-on: ubuntu-latest + name: gitStream workflow automation + steps: + - name: Evaluate Rules + uses: linear-b/gitstream-github-action@v2 + id: rules-engine + with: + full_repository: ${{ github.event.inputs.full_repository }} + head_ref: ${{ github.event.inputs.head_ref }} + base_ref: ${{ github.event.inputs.base_ref }} + client_payload: ${{ github.event.inputs.client_payload }} + installation_id: ${{ github.event.inputs.installation_id }} + resolver_url: ${{ github.event.inputs.resolver_url }} + resolver_token: ${{ github.event.inputs.resolver_token }} diff --git a/.github/workflows/pr_assignee.yml b/.github/workflows/pr_assignee.yml new file mode 100644 index 000000000..5be603df6 --- /dev/null +++ b/.github/workflows/pr_assignee.yml @@ -0,0 +1,17 @@ +name: Assign PR to creator + +on: + 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: toshimaru/auto-author-assign@v2.1.1 diff --git a/.github/workflows/pr_milestone.yml b/.github/workflows/pr_milestone.yml new file mode 100644 index 000000000..e2365f554 --- /dev/null +++ b/.github/workflows/pr_milestone.yml @@ -0,0 +1,22 @@ +name: Set Milestone + +# Assigns the earliest created milestone that matches the below glob pattern. + +on: + pull_request_target: + types: [opened] + +permissions: + pull-requests: write + +jobs: + automation: + runs-on: ubuntu-latest + + steps: + - name: set-milestone + uses: andrefcdias/add-to-milestone@v1.3.0 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + milestone: "+([0-9]).+([0-9]).+([0-9])" + use-expression: true diff --git a/.github/workflows/spelling.yml b/.github/workflows/spelling.yml new file mode 100644 index 000000000..7aaa9296a --- /dev/null +++ b/.github/workflows/spelling.yml @@ -0,0 +1,160 @@ +name: Check Spelling + +# Comment management is handled through a secondary job, for details see: +# https://github.com/check-spelling/check-spelling/wiki/Feature%3A-Restricted-Permissions +# +# `jobs.comment-push` runs when a push is made to a repository and the `jobs.spelling` job needs to make a comment +# (in odd cases, it might actually run just to collapse a comment, but that's fairly rare) +# it needs `contents: write` in order to add a comment. +# +# `jobs.comment-pr` runs when a pull_request is made to a repository and the `jobs.spelling` job needs to make a comment +# or collapse a comment (in the case where it had previously made a comment and now no longer needs to show a comment) +# it needs `pull-requests: write` in order to manipulate those comments. + +# Updating pull request branches is managed via comment handling. +# For details, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-Update-expect-list +# +# These elements work together to make it happen: +# +# `on.issue_comment` +# This event listens to comments by users asking to update the metadata. +# +# `jobs.update` +# This job runs in response to an issue_comment and will push a new commit +# to update the spelling metadata. +# +# `with.experimental_apply_changes_via_bot` +# Tells the action to support and generate messages that enable it +# to make a commit to update the spelling metadata. +# +# `with.ssh_key` +# In order to trigger workflows when the commit is made, you can provide a +# secret (typically, a write-enabled github deploy key). +# +# For background, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-Update-with-deploy-key + +on: +# push: +# branches: +# - '**' +# - '!l10n_dev' +# tags-ignore: +# - "**" + pull_request_target: + branches: + - '**' + # - '!l10n_dev' + tags-ignore: + - "**" + types: + - 'opened' + - 'reopened' + - 'synchronize' + # issue_comment: + # types: + # - 'created' + +jobs: + spelling: + name: Check Spelling + permissions: + contents: read + pull-requests: read + actions: read + security-events: write + outputs: + followup: ${{ steps.spelling.outputs.followup }} + runs-on: ubuntu-latest + if: (contains(github.event_name, 'pull_request') && github.head_ref != 'l10n_dev') + concurrency: + group: spelling-${{ github.event.pull_request.number || github.ref }} + # note: If you use only_check_changed_files, you do not want cancel-in-progress + cancel-in-progress: false + steps: + - name: check-spelling + id: spelling + uses: check-spelling/check-spelling@prerelease + with: + suppress_push_for_open_pull_request: 1 + checkout: true + check_file_names: 1 + spell_check_this: check-spelling/spell-check-this@main + post_comment: 0 + use_magic_file: 1 + experimental_apply_changes_via_bot: 1 + use_sarif: 0 # to show in pr page + extra_dictionary_limit: 10 + check_commit_messages: commits title description + only_check_changed_files: 1 + check_extra_dictionaries: '' + quit_without_error: true + extra_dictionaries: + cspell:software-terms/dict/softwareTerms.txt + cspell:win32/src/win32.txt + cspell:filetypes/filetypes.txt + cspell:csharp/csharp.txt + cspell:dotnet/dict/dotnet.txt + cspell:python/src/common/extra.txt + cspell:python/src/python/python-lib.txt + cspell:aws/aws.txt + cspell:companies/src/companies.txt + warnings: + binary-file,deprecated-feature,large-file,limited-references,noisy-file,non-alpha-in-dictionary,unexpected-line-ending,whitespace-in-dictionary,minified-file,unsupported-configuration,unrecognized-spelling,no-newline-at-eof + + + +# comment-push: +# name: Report (Push) +# # If your workflow isn't running on push, you can remove this job +# runs-on: ubuntu-latest +# needs: spelling +# permissions: +# contents: write +# if: (success() || failure()) && needs.spelling.outputs.followup && github.event_name == 'push' +# steps: +# - name: comment +# uses: check-spelling/check-spelling@@v0.0.22 +# with: +# checkout: true +# spell_check_this: check-spelling/spell-check-this@main +# task: ${{ needs.spelling.outputs.followup }} + + comment-pr: + name: Report (PR) + # If you workflow isn't running on pull_request*, you can remove this job + runs-on: ubuntu-latest + needs: spelling + permissions: + pull-requests: write + if: (success() || failure()) && needs.spelling.outputs.followup && contains(github.event_name, 'pull_request') + steps: + - name: comment + uses: check-spelling/check-spelling@prerelease + with: + checkout: true + spell_check_this: check-spelling/spell-check-this@main + task: ${{ needs.spelling.outputs.followup }} + experimental_apply_changes_via_bot: 1 + + # update: + # name: Update PR + # permissions: + # contents: write + # pull-requests: write + # actions: read + # runs-on: ubuntu-latest + # if: ${{ + # github.event_name == 'issue_comment' && + # github.event.issue.pull_request && + # contains(github.event.comment.body, '@check-spelling-bot apply') + # }} + # concurrency: + # group: spelling-update-${{ github.event.issue.number }} + # cancel-in-progress: false + # steps: + # - name: apply spelling updates + # uses: check-spelling/check-spelling@v0.0.22 + # with: + # experimental_apply_changes_via_bot: 1 + # checkout: true + # ssh_key: "${{ secrets.CHECK_SPELLING }}" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 052e1d225..719f2556e 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -6,6 +6,11 @@ on: schedule: - cron: '30 1 * * *' +env: + days-before-stale: 60 + days-before-close: 7 + exempt-issue-labels: 'keep-fresh' + jobs: stale: runs-on: ubuntu-latest @@ -13,14 +18,14 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@v4 + - uses: actions/stale@v9 with: - stale-issue-message: 'This issue is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 5 days.' - days-before-stale: 45 - days-before-close: 7 + stale-issue-message: 'This issue is stale because it has been open ${{ env.days-before-stale }} days with no activity. Remove stale label or comment or this will be closed in ${{ env.days-before-stale }} days.\n\nAlternatively this issue can be kept open by adding one of the following labels:\n${{ env.exempt-issue-labels }}' + days-before-stale: ${{ env.days-before-stale }} + days-before-close: ${{ env.days-before-close }} days-before-pr-close: -1 exempt-all-milestones: true close-issue-message: 'This issue was closed because it has been stale for 7 days with no activity. If you feel this issue still needs attention please feel free to reopen.' stale-pr-label: 'no-pr-activity' - exempt-issue-labels: 'keep-fresh' + exempt-issue-labels: ${{ env.exempt-issue-labels }} exempt-pr-labels: 'keep-fresh,awaiting-approval,work-in-progress' diff --git a/.github/workflows/top-ranking-issues.yml b/.github/workflows/top-ranking-issues.yml new file mode 100644 index 000000000..bddca4741 --- /dev/null +++ b/.github/workflows/top-ranking-issues.yml @@ -0,0 +1,25 @@ +name: Top-Ranking Issues +on: + schedule: + - cron: '0 0 */1 * *' + workflow_dispatch: + +jobs: + ShowAndLabelTopIssues: + name: Display and label top issues. + runs-on: ubuntu-latest + steps: + - name: Top Issues action + uses: rickstaa/top-issues-action@v1.3.101 + env: + github_token: ${{ secrets.GITHUB_TOKEN }} + with: + dashboard: true + dashboard_show_total_reactions: true + top_list_size: 10 + top_features: true + top_bugs: true + dashboard_title: Top-Ranking Issues 📈 + dashboard_label: ⭐ Dashboard + hide_dashboard_footer: true + top_issues: false diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml new file mode 100644 index 000000000..7040ee606 --- /dev/null +++ b/.github/workflows/winget.yml @@ -0,0 +1,13 @@ +name: Publish to Winget + +on: + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: vedantmgoyal2009/winget-releaser@v2 + with: + identifier: Flow-Launcher.Flow-Launcher + token: ${{ secrets.WINGET_TOKEN }} diff --git a/Deploy/local_build.ps1 b/Deploy/local_build.ps1 deleted file mode 100644 index 9dd7582b1..000000000 --- a/Deploy/local_build.ps1 +++ /dev/null @@ -1,4 +0,0 @@ -New-Alias nuget.exe ".\packages\NuGet.CommandLine.*\tools\NuGet.exe" -$env:APPVEYOR_BUILD_FOLDER = Convert-Path . -$env:APPVEYOR_BUILD_VERSION = "1.2.0" -& .\Deploy\squirrel_installer.ps1 \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 000000000..fa499273c --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,5 @@ + + + true + + \ No newline at end of file diff --git a/Flow.Launcher.Core/Configuration/Portable.cs b/Flow.Launcher.Core/Configuration/Portable.cs index bd77ea7cf..069154364 100644 --- a/Flow.Launcher.Core/Configuration/Portable.cs +++ b/Flow.Launcher.Core/Configuration/Portable.cs @@ -9,11 +9,15 @@ using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin.SharedCommands; using System.Linq; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.Configuration { public class Portable : IPortable { + private readonly IPublicAPI API = Ioc.Default.GetRequiredService(); + /// /// As at Squirrel.Windows version 1.5.2, UpdateManager needs to be disposed after finish /// @@ -40,14 +44,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 occured while disabling portable mode", e); + Log.Exception("|Portable.DisablePortableMode|Error occurred while disabling portable mode", e); } } @@ -64,14 +68,14 @@ 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 occured while enabling portable mode", e); + Log.Exception("|Portable.EnablePortableMode|Error occurred while enabling portable mode", e); } } @@ -95,13 +99,13 @@ namespace Flow.Launcher.Core.Configuration 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() @@ -157,13 +161,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 +176,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,8 +190,8 @@ namespace Flow.Launcher.Core.Configuration if (roamingLocationExists && portableLocationExists) { - MessageBox.Show(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 occured.", + 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)); return false; diff --git a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs new file mode 100644 index 000000000..68be746f2 --- /dev/null +++ b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs @@ -0,0 +1,68 @@ +using Flow.Launcher.Infrastructure.Http; +using Flow.Launcher.Infrastructure.Logger; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Core.ExternalPlugins +{ + public record CommunityPluginSource(string ManifestFileUrl) + { + private string latestEtag = ""; + + private List plugins = new(); + + private static JsonSerializerOptions PluginStoreItemSerializationOption = new JsonSerializerOptions() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault + }; + + /// + /// Fetch and deserialize the contents of a plugins.json file found at . + /// We use conditional http requests to keep repeat requests fast. + /// + /// + /// This method will only return plugin details when the underlying http request is successful (200 or 304). + /// In any other case, an exception is raised + /// + public async Task> FetchAsync(CancellationToken token) + { + Log.Info(nameof(CommunityPluginSource), $"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) + .ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.OK) + { + this.plugins = await response.Content + .ReadFromJsonAsync>(PluginStoreItemSerializationOption, cancellationToken: token) + .ConfigureAwait(false); + this.latestEtag = response.Headers.ETag?.Tag; + + Log.Info(nameof(CommunityPluginSource), $"Loaded {this.plugins.Count} plugins from {ManifestFileUrl}"); + return this.plugins; + } + else if (response.StatusCode == HttpStatusCode.NotModified) + { + 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}"); + } + } + } +} diff --git a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs new file mode 100644 index 000000000..affd7c312 --- /dev/null +++ b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Core.ExternalPlugins +{ + /// + /// Describes a store of community-made plugins. + /// The provided URLs should point to a json file, whose content + /// is deserializable as a array. + /// + /// Primary URL to the manifest json file. + /// Secondary URLs to access the , for example CDN links + public record CommunityPluginStore(string primaryUrl, params string[] secondaryUrls) + { + private readonly List pluginSources = + secondaryUrls + .Append(primaryUrl) + .Select(url => new CommunityPluginSource(url)) + .ToList(); + + public async Task> FetchAsync(CancellationToken token, bool onlyFromPrimaryUrl = false) + { + // we create a new cancellation token source linked to the given token. + // Once any of the http requests completes successfully, we call cancel + // to stop the rest of the running http requests. + var cts = CancellationTokenSource.CreateLinkedTokenSource(token); + + var tasks = onlyFromPrimaryUrl + ? new() { pluginSources.Last().FetchAsync(cts.Token) } + : pluginSources.Select(pluginSource => pluginSource.FetchAsync(cts.Token)).ToList(); + + var pluginResults = new List(); + + // keep going until all tasks have completed + while (tasks.Any()) + { + 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(); + } + tasks.Remove(completedTask); + } + + // all tasks have finished + return pluginResults; + } + } +} diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs new file mode 100644 index 000000000..451df6147 --- /dev/null +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs @@ -0,0 +1,220 @@ +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Forms; +using Flow.Launcher.Core.Resource; +using CommunityToolkit.Mvvm.DependencyInjection; + +namespace Flow.Launcher.Core.ExternalPlugins.Environments +{ + public abstract class AbstractPluginEnvironment + { + protected readonly IPublicAPI API = Ioc.Default.GetRequiredService(); + + internal abstract string Language { get; } + + internal abstract string EnvName { get; } + + internal abstract string EnvPath { get; } + + internal abstract string InstallPath { get; } + + internal abstract string ExecutablePath { get; } + + internal virtual string FileDialogFilter => string.Empty; + + internal abstract string PluginsSettingsFilePath { get; set; } + + internal List PluginMetadataList; + + internal PluginsSettings PluginSettings; + + internal AbstractPluginEnvironment(List pluginMetadataList, PluginsSettings pluginSettings) + { + PluginMetadataList = pluginMetadataList; + PluginSettings = pluginSettings; + } + + internal IEnumerable Setup() + { + if (!PluginMetadataList.Any(o => o.Language.Equals(Language, StringComparison.OrdinalIgnoreCase))) + return new List(); + + if (!string.IsNullOrEmpty(PluginsSettingsFilePath) && FilesFolders.FileExists(PluginsSettingsFilePath)) + { + // Ensure latest only if user is using Flow's environment setup. + if (PluginsSettingsFilePath.StartsWith(EnvPath, StringComparison.OrdinalIgnoreCase)) + EnsureLatestInstalled(ExecutablePath, PluginsSettingsFilePath, EnvPath); + + return SetPathForPluginPairs(PluginsSettingsFilePath, Language); + } + + var noRuntimeMessage = string.Format( + InternationalizationManager.Instance.GetTranslation("runtimePluginInstalledChooseRuntimePrompt"), + Language, + EnvName, + Environment.NewLine + ); + if (API.ShowMsgBox(noRuntimeMessage, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.No) + { + var msg = string.Format(InternationalizationManager.Instance.GetTranslation("runtimePluginChooseRuntimeExecutable"), EnvName); + string selectedFile; + + 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 + { + InstallEnvironment(); + } + + if (FilesFolders.FileExists(PluginsSettingsFilePath)) + { + return SetPathForPluginPairs(PluginsSettingsFilePath, Language); + } + else + { + API.ShowMsgBox(string.Format(InternationalizationManager.Instance.GetTranslation("runtimePluginUnableToSetExecutablePath"), Language)); + Log.Error("PluginsLoader", + $"Not able to successfully set {EnvName} path, setting's plugin executable path variable is still an empty string.", + $"{Language}Environment"); + + return new List(); + } + } + + internal abstract void InstallEnvironment(); + + private void EnsureLatestInstalled(string expectedPath, string currentPath, string installedDirPath) + { + if (expectedPath == currentPath) + return; + + FilesFolders.RemoveFolderIfExists(installedDirPath, (s) => API.ShowMsgBox(s)); + + InstallEnvironment(); + + } + + internal abstract PluginPair CreatePluginPair(string filePath, PluginMetadata metadata); + + private IEnumerable SetPathForPluginPairs(string filePath, string languageToSet) + { + var pluginPairs = new List(); + + foreach (var metadata in PluginMetadataList) + { + if (metadata.Language.Equals(languageToSet, StringComparison.OrdinalIgnoreCase)) + pluginPairs.Add(CreatePluginPair(filePath, metadata)); + } + + return pluginPairs; + } + + private string GetFileFromDialog(string title, string filter = "") + { + var dlg = new OpenFileDialog + { + InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Multiselect = false, + CheckFileExists = true, + CheckPathExists = true, + Title = title, + Filter = filter + }; + + var result = dlg.ShowDialog(); + return result == DialogResult.OK ? dlg.FileName : string.Empty; + + } + + /// + /// After app updated while in portable mode or switched between portable/roaming mode, + /// need to update each plugin's executable path so user will not be prompted again to reinstall the environments. + /// + /// + public static void PreStartPluginExecutablePathUpdate(Settings settings) + { + if (DataLocation.PortableDataLocationInUse()) + { + // When user is using portable but has moved flow to a different location + if (IsUsingPortablePath(settings.PluginSettings.PythonExecutablePath, DataLocation.PythonEnvironmentName) + && !settings.PluginSettings.PythonExecutablePath.StartsWith(DataLocation.PortableDataPath)) + { + settings.PluginSettings.PythonExecutablePath + = GetUpdatedEnvironmentPath(settings.PluginSettings.PythonExecutablePath); + } + + if (IsUsingPortablePath(settings.PluginSettings.NodeExecutablePath, DataLocation.NodeEnvironmentName) + && !settings.PluginSettings.NodeExecutablePath.StartsWith(DataLocation.PortableDataPath)) + { + settings.PluginSettings.NodeExecutablePath + = GetUpdatedEnvironmentPath(settings.PluginSettings.NodeExecutablePath); + } + + // When user has switched from roaming to portable + if (IsUsingRoamingPath(settings.PluginSettings.PythonExecutablePath)) + { + settings.PluginSettings.PythonExecutablePath + = settings.PluginSettings.PythonExecutablePath.Replace(DataLocation.RoamingDataPath, DataLocation.PortableDataPath); + } + + if (IsUsingRoamingPath(settings.PluginSettings.NodeExecutablePath)) + { + settings.PluginSettings.NodeExecutablePath + = settings.PluginSettings.NodeExecutablePath.Replace(DataLocation.RoamingDataPath, DataLocation.PortableDataPath); + } + } + 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; + + // 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}"; + + return filePath.Contains(portableAppEnvLocation); + } + + private static bool IsUsingRoamingPath(string filePath) + { + if (string.IsNullOrEmpty(filePath)) + return false; + + return filePath.StartsWith(DataLocation.RoamingDataPath); + } + + private static string GetUpdatedEnvironmentPath(string filePath) + { + 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}"; + } + } +} diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptEnvironment.cs new file mode 100644 index 000000000..b67059b1b --- /dev/null +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptEnvironment.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Core.ExternalPlugins.Environments +{ + + internal class JavaScriptEnvironment : TypeScriptEnvironment + { + internal override string Language => AllowedLanguage.JavaScript; + + internal JavaScriptEnvironment(List pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { } + } +} diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptV2Environment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptV2Environment.cs new file mode 100644 index 000000000..6c8c5aa57 --- /dev/null +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptV2Environment.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Core.ExternalPlugins.Environments +{ + + internal class JavaScriptV2Environment : TypeScriptV2Environment + { + internal override string Language => AllowedLanguage.JavaScriptV2; + + internal JavaScriptV2Environment(List pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { } + } +} diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs new file mode 100644 index 000000000..607c19062 --- /dev/null +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs @@ -0,0 +1,49 @@ +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; + +namespace Flow.Launcher.Core.ExternalPlugins.Environments +{ + internal class PythonEnvironment : AbstractPluginEnvironment + { + internal override string Language => AllowedLanguage.Python; + + internal override string EnvName => DataLocation.PythonEnvironmentName; + + internal override string EnvPath => Path.Combine(DataLocation.PluginEnvironmentsPath, EnvName); + + internal override string InstallPath => Path.Combine(EnvPath, "PythonEmbeddable-v3.11.4"); + + internal override string ExecutablePath => Path.Combine(InstallPath, "pythonw.exe"); + + internal override string FileDialogFilter => "Python|pythonw.exe"; + + internal override string PluginsSettingsFilePath { get => PluginSettings.PythonExecutablePath; set => PluginSettings.PythonExecutablePath = value; } + + internal PythonEnvironment(List pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { } + + internal override void InstallEnvironment() + { + 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(); + + PluginsSettingsFilePath = ExecutablePath; + } + + internal override PluginPair CreatePluginPair(string filePath, PluginMetadata metadata) + { + return new PluginPair + { + Plugin = new PythonPlugin(filePath), + Metadata = metadata + }; + } + } +} diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/PythonV2Environment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/PythonV2Environment.cs new file mode 100644 index 000000000..4d75e1b8f --- /dev/null +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/PythonV2Environment.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Core.ExternalPlugins.Environments +{ + internal class PythonV2Environment : PythonEnvironment + { + internal override string Language => AllowedLanguage.PythonV2; + + internal override PluginPair CreatePluginPair(string filePath, PluginMetadata metadata) + { + return new PluginPair + { + Plugin = new PythonPluginV2(filePath), + Metadata = metadata + }; + } + + internal PythonV2Environment(List pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { } + } +} diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs new file mode 100644 index 000000000..399f7cc03 --- /dev/null +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using Droplex; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin.SharedCommands; +using Flow.Launcher.Plugin; +using System.IO; +using Flow.Launcher.Core.Plugin; + +namespace Flow.Launcher.Core.ExternalPlugins.Environments +{ + internal class TypeScriptEnvironment : AbstractPluginEnvironment + { + internal override string Language => AllowedLanguage.TypeScript; + + internal override string EnvName => DataLocation.NodeEnvironmentName; + + internal override string EnvPath => Path.Combine(DataLocation.PluginEnvironmentsPath, EnvName); + + 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 TypeScriptEnvironment(List pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { } + + internal override void InstallEnvironment() + { + FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s)); + + DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath).Wait(); + + PluginsSettingsFilePath = ExecutablePath; + } + + internal override PluginPair CreatePluginPair(string filePath, PluginMetadata metadata) + { + return new PluginPair + { + Plugin = new NodePlugin(filePath), + Metadata = metadata + }; + } + } +} diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs new file mode 100644 index 000000000..e8cb72e11 --- /dev/null +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using Droplex; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin.SharedCommands; +using Flow.Launcher.Plugin; +using System.IO; +using Flow.Launcher.Core.Plugin; + +namespace Flow.Launcher.Core.ExternalPlugins.Environments +{ + internal class TypeScriptV2Environment : AbstractPluginEnvironment + { + internal override string Language => AllowedLanguage.TypeScriptV2; + + internal override string EnvName => DataLocation.NodeEnvironmentName; + + internal override string EnvPath => Path.Combine(DataLocation.PluginEnvironmentsPath, EnvName); + + 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 TypeScriptV2Environment(List pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { } + + internal override void InstallEnvironment() + { + FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s)); + + DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath).Wait(); + + PluginsSettingsFilePath = ExecutablePath; + } + + internal override PluginPair CreatePluginPair(string filePath, PluginMetadata metadata) + { + return new PluginPair + { + Plugin = new NodePluginV2(filePath), + Metadata = metadata + }; + } + } +} diff --git a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs index fab1b3e8f..ac8abcdcc 100644 --- a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs +++ b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs @@ -1,10 +1,6 @@ -using Flow.Launcher.Infrastructure.Http; -using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.Logger; using System; using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -12,38 +8,37 @@ namespace Flow.Launcher.Core.ExternalPlugins { public static class PluginsManifest { - private const string manifestFileUrl = "https://cdn.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json"; + 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", + "https://gcore.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json", + "https://cdn.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json"); private static readonly SemaphoreSlim manifestUpdateLock = new(1); - private static string latestEtag = ""; + private static DateTime lastFetchedAt = DateTime.MinValue; + private static TimeSpan fetchTimeout = TimeSpan.FromMinutes(2); - public static List UserPlugins { get; private set; } = new List(); + public static List UserPlugins { get; private set; } - public static async Task UpdateManifestAsync(CancellationToken token = default) + public static async Task UpdateManifestAsync(CancellationToken token = default, bool usePrimaryUrlOnly = false) { try { await manifestUpdateLock.WaitAsync(token).ConfigureAwait(false); - var request = new HttpRequestMessage(HttpMethod.Get, manifestFileUrl); - request.Headers.Add("If-None-Match", latestEtag); - - var response = await Http.SendAsync(request, token).ConfigureAwait(false); - - if (response.StatusCode == HttpStatusCode.OK) + if (UserPlugins == null || usePrimaryUrlOnly || DateTime.Now.Subtract(lastFetchedAt) >= fetchTimeout) { - Log.Info($"|PluginsManifest.{nameof(UpdateManifestAsync)}|Fetched plugins from manifest repo"); + var results = await mainPluginStore.FetchAsync(token, usePrimaryUrlOnly).ConfigureAwait(false); - var json = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false); + // 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; - UserPlugins = await JsonSerializer.DeserializeAsync>(json, cancellationToken: token).ConfigureAwait(false); - - latestEtag = response.Headers.ETag.Tag; - } - else if (response.StatusCode != HttpStatusCode.NotModified) - { - Log.Warn($"|PluginsManifest.{nameof(UpdateManifestAsync)}|Http response for manifest file was {response.StatusCode}"); + return true; + } } } catch (Exception e) @@ -54,6 +49,8 @@ namespace Flow.Launcher.Core.ExternalPlugins { manifestUpdateLock.Release(); } + + return false; } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Core/ExternalPlugins/UserPlugin.cs b/Flow.Launcher.Core/ExternalPlugins/UserPlugin.cs index bb1279b2c..79d6d7605 100644 --- a/Flow.Launcher.Core/ExternalPlugins/UserPlugin.cs +++ b/Flow.Launcher.Core/ExternalPlugins/UserPlugin.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace Flow.Launcher.Core.ExternalPlugins { @@ -13,9 +13,11 @@ namespace Flow.Launcher.Core.ExternalPlugins 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 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 7d18c467b..e9f199d00 100644 --- a/Flow.Launcher.Core/Flow.Launcher.Core.csproj +++ b/Flow.Launcher.Core/Flow.Launcher.Core.csproj @@ -1,7 +1,7 @@ - + - net6.0-windows + net7.0-windows true true Library @@ -53,10 +53,12 @@ - - - + + + + + diff --git a/Flow.Launcher.Core/Plugin/ExecutablePlugin.cs b/Flow.Launcher.Core/Plugin/ExecutablePlugin.cs index 049d1c583..857122aa6 100644 --- a/Flow.Launcher.Core/Plugin/ExecutablePlugin.cs +++ b/Flow.Launcher.Core/Plugin/ExecutablePlugin.cs @@ -1,15 +1,14 @@ using System.Diagnostics; using System.IO; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.Plugin { internal class ExecutablePlugin : JsonRPCPlugin { private readonly ProcessStartInfo _startInfo; - public override string SupportedLanguage { get; set; } = AllowedLanguage.Executable; public ExecutablePlugin(string filename) { @@ -29,14 +28,14 @@ namespace Flow.Launcher.Core.Plugin protected override Task RequestAsync(JsonRPCRequestModel request, CancellationToken token = default) { // since this is not static, request strings will build up in ArgumentList if index is not specified - _startInfo.ArgumentList[0] = request.ToString(); + _startInfo.ArgumentList[0] = JsonSerializer.Serialize(request, RequestSerializeOption); return ExecuteAsync(_startInfo, token); } protected override string Request(JsonRPCRequestModel rpcRequest, CancellationToken token = default) { // since this is not static, request strings will build up in ArgumentList if index is not specified - _startInfo.ArgumentList[0] = rpcRequest.ToString(); + _startInfo.ArgumentList[0] = JsonSerializer.Serialize(rpcRequest, RequestSerializeOption); return Execute(_startInfo); } } diff --git a/Flow.Launcher.Core/Plugin/ExecutablePluginV2.cs b/Flow.Launcher.Core/Plugin/ExecutablePluginV2.cs new file mode 100644 index 000000000..852f57b9f --- /dev/null +++ b/Flow.Launcher.Core/Plugin/ExecutablePluginV2.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Core.Plugin +{ + internal sealed class ExecutablePluginV2 : ProcessStreamPluginV2 + { + protected override ProcessStartInfo StartInfo { get; set; } + + public ExecutablePluginV2(string filename) + { + StartInfo = new ProcessStartInfo { FileName = filename }; + } + + protected override MessageHandlerType MessageHandler { get; } = MessageHandlerType.NewLineDelimited; + } +} diff --git a/Flow.Launcher.Core/Plugin/JsonPRCModel.cs b/Flow.Launcher.Core/Plugin/JsonPRCModel.cs index e937779a1..5b2a9f6cb 100644 --- a/Flow.Launcher.Core/Plugin/JsonPRCModel.cs +++ b/Flow.Launcher.Core/Plugin/JsonPRCModel.cs @@ -12,76 +12,42 @@ * */ -using Flow.Launcher.Core.Resource; using System.Collections.Generic; -using System.Linq; using System.Text.Json.Serialization; using Flow.Launcher.Plugin; using System.Text.Json; namespace Flow.Launcher.Core.Plugin { - public class JsonRPCErrorModel - { - public int Code { get; set; } + public record JsonRPCBase(int Id, JsonRPCErrorModel Error = default); + public record JsonRPCErrorModel(int Code, string Message, string Data); - public string Message { get; set; } + public record JsonRPCResponseModel(int Id, JsonRPCErrorModel Error = default) : JsonRPCBase(Id, Error); + public record JsonRPCQueryResponseModel(int Id, + [property: JsonPropertyName("result")] List Result, + IReadOnlyDictionary SettingsChange = null, + string DebugMessage = "", + JsonRPCErrorModel Error = default) : JsonRPCResponseModel(Id, Error); - public string Data { get; set; } - } + public record JsonRPCRequestModel(int Id, + string Method, + object[] Parameters, + IReadOnlyDictionary Settings = default, + JsonRPCErrorModel Error = default) : JsonRPCBase(Id, Error); - public class JsonRPCResponseModel - { - public string Result { get; set; } - - public JsonRPCErrorModel Error { get; set; } - } - - public class JsonRPCQueryResponseModel : JsonRPCResponseModel - { - [JsonPropertyName("result")] - public new List Result { get; set; } - - public Dictionary SettingsChange { get; set; } - - public string DebugMessage { get; set; } - } - - public class JsonRPCRequestModel - { - public string Method { get; set; } - - public object[] Parameters { get; set; } - - public Dictionary Settings { get; set; } - - private static readonly JsonSerializerOptions options = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - public override string ToString() - { - return JsonSerializer.Serialize(this, options); - } - } - - /// - /// Json RPC Request that Flow Launcher sent to client - /// - public class JsonRPCServerRequestModel : JsonRPCRequestModel - { - - } - /// /// Json RPC Request(in query response) that client sent to Flow Launcher /// - public class JsonRPCClientRequestModel : JsonRPCRequestModel - { - public bool DontHideAfterAction { get; set; } - } - + public record JsonRPCClientRequestModel( + int Id, + string Method, + object[] Parameters, + IReadOnlyDictionary Settings, + bool DontHideAfterAction = false, + JsonRPCErrorModel Error = default) : JsonRPCRequestModel(Id, Method, Parameters, Settings, Error); + + /// /// Represent the json-rpc result item that client send to Flow Launcher /// Typically, we will send back this request model to client after user select the result item diff --git a/Flow.Launcher.Core/Plugin/JsonRPCConfigurationModel.cs b/Flow.Launcher.Core/Plugin/JsonRPCConfigurationModel.cs index 1f63f85a8..6dc4be881 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCConfigurationModel.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCConfigurationModel.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; namespace Flow.Launcher.Core.Plugin { @@ -26,6 +27,8 @@ namespace Flow.Launcher.Core.Plugin public string Name { get; set; } public string Label { get; set; } public string Description { get; set; } + public string urlLabel { get; set; } + public Uri url { get; set; } public bool Validation { get; set; } public List Options { get; set; } public string DefaultValue { get; set; } @@ -40,4 +43,4 @@ namespace Flow.Launcher.Core.Plugin DefaultValue = this.DefaultValue; } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs b/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs index e3efcd296..97c3c8981 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs @@ -22,6 +22,7 @@ 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 { @@ -29,33 +30,25 @@ namespace Flow.Launcher.Core.Plugin /// Represent the plugin that using JsonPRC /// every JsonRPC plugin should has its own plugin instance /// - internal abstract class JsonRPCPlugin : IAsyncPlugin, IContextMenu, ISettingProvider, ISavable + internal abstract class JsonRPCPlugin : JsonRPCPluginBase { - protected PluginInitContext context; public const string JsonRPC = "JsonRPC"; - /// - /// The language this JsonRPCPlugin support - /// - public abstract string SupportedLanguage { get; set; } protected abstract Task RequestAsync(JsonRPCRequestModel rpcRequest, CancellationToken token = default); protected abstract string Request(JsonRPCRequestModel rpcRequest, CancellationToken token = default); private static readonly RecyclableMemoryStreamManager BufferManager = new(); - private string SettingConfigurationPath => Path.Combine(context.CurrentPluginMetadata.PluginDirectory, "SettingsTemplate.yaml"); - private string SettingPath => Path.Combine(DataLocation.PluginSettingsDirectory, context.CurrentPluginMetadata.Name, "Settings.json"); + private int RequestId { get; set; } - public List LoadContextMenus(Result selectedResult) + 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 - { - Method = "context_menu", - Parameters = new[] - { - selectedResult.ContextData - } - }; + var request = new JsonRPCRequestModel(RequestId++, + "context_menu", + new[] { selectedResult.ContextData }); var output = Request(request); return DeserializedResult(output); } @@ -80,7 +73,6 @@ namespace Flow.Launcher.Core.Plugin { WriteIndented = true }; - private Dictionary Settings { get; set; } private readonly Dictionary _settingControls = new(); @@ -106,85 +98,42 @@ namespace Flow.Launcher.Core.Plugin return ParseResults(queryResponseModel); } - - private List ParseResults(JsonRPCQueryResponseModel queryResponseModel) + protected override async Task ExecuteResultAsync(JsonRPCResult result) { - if (queryResponseModel.Result == null) return null; + if (result.JsonRPCAction == null) return false; - if (!string.IsNullOrEmpty(queryResponseModel.DebugMessage)) + if (string.IsNullOrEmpty(result.JsonRPCAction.Method)) { - context.API.ShowMsg(queryResponseModel.DebugMessage); + return !result.JsonRPCAction.DontHideAfterAction; } - foreach (var result in queryResponseModel.Result) + if (result.JsonRPCAction.Method.StartsWith("Flow.Launcher.")) { - result.AsyncAction = async c => + ExecuteFlowLauncherAPI(result.JsonRPCAction.Method["Flow.Launcher.".Length..], + result.JsonRPCAction.Parameters); + } + else + { + await using var actionResponse = await RequestAsync(result.JsonRPCAction); + + if (actionResponse.Length == 0) { - UpdateSettings(result.SettingsChange); - - if (result.JsonRPCAction == null) return false; - - if (string.IsNullOrEmpty(result.JsonRPCAction.Method)) - { - return !result.JsonRPCAction.DontHideAfterAction; - } - - if (result.JsonRPCAction.Method.StartsWith("Flow.Launcher.")) - { - ExecuteFlowLauncherAPI(result.JsonRPCAction.Method["Flow.Launcher.".Length..], - result.JsonRPCAction.Parameters); - } - else - { - await using var actionResponse = await RequestAsync(result.JsonRPCAction); - - if (actionResponse.Length == 0) - { - return !result.JsonRPCAction.DontHideAfterAction; - } - - var jsonRpcRequestModel = await - JsonSerializer.DeserializeAsync(actionResponse, options); - - if (jsonRpcRequestModel?.Method?.StartsWith("Flow.Launcher.") ?? false) - { - ExecuteFlowLauncherAPI(jsonRpcRequestModel.Method["Flow.Launcher.".Length..], - jsonRpcRequestModel.Parameters); - } - } - return !result.JsonRPCAction.DontHideAfterAction; - }; + } + + var jsonRpcRequestModel = await + JsonSerializer.DeserializeAsync(actionResponse, options); + + if (jsonRpcRequestModel?.Method?.StartsWith("Flow.Launcher.") ?? false) + { + ExecuteFlowLauncherAPI(jsonRpcRequestModel.Method["Flow.Launcher.".Length..], + jsonRpcRequestModel.Parameters); + } } - var results = new List(); - - results.AddRange(queryResponseModel.Result); - - UpdateSettings(queryResponseModel.SettingsChange); - - return results; + return !result.JsonRPCAction.DontHideAfterAction; } - private void ExecuteFlowLauncherAPI(string method, object[] parameters) - { - var parametersTypeArray = parameters.Select(param => param.GetType()).ToArray(); - var methodInfo = typeof(IPublicAPI).GetMethod(method, parametersTypeArray); - if (methodInfo == null) - { - return; - } - try - { - methodInfo.Invoke(PluginManager.API, parameters); - } - catch (Exception) - { -#if (DEBUG) - throw; -#endif - } - } /// /// Execute external program and return the output @@ -259,9 +208,16 @@ namespace Flow.Launcher.Core.Plugin await using var registeredEvent = token.Register(() => { - if (!process.HasExited) - process.Kill(); - sourceBuffer.Dispose(); + try + { + if (!process.HasExited) + process.Kill(); + sourceBuffer.Dispose(); + } + catch (Exception e) + { + Log.Exception("|JsonRPCPlugin.ExecuteAsync|Exception when kill process", e); + } }); try @@ -288,238 +244,23 @@ namespace Flow.Launcher.Core.Plugin } sourceBuffer.Seek(0, SeekOrigin.Begin); - + return sourceBuffer; } - - public async Task> QueryAsync(Query query, CancellationToken token) + public override async Task> QueryAsync(Query query, CancellationToken token) { - var request = new JsonRPCRequestModel - { - Method = "query", - Parameters = new object[] + var request = new JsonRPCRequestModel(RequestId++, + "query", + new object[] { query.Search }, - Settings = Settings - }; + Settings?.Inner); + var output = await RequestAsync(request, token); + return await DeserializedResultAsync(output); } - - public async Task InitSettingAsync() - { - if (!File.Exists(SettingConfigurationPath)) - return; - - if (File.Exists(SettingPath)) - { - await using var fileStream = File.OpenRead(SettingPath); - Settings = await JsonSerializer.DeserializeAsync>(fileStream, options); - } - - var deserializer = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance).Build(); - _settingsTemplate = deserializer.Deserialize(await File.ReadAllTextAsync(SettingConfigurationPath)); - - Settings ??= new Dictionary(); - - foreach (var (type, attribute) in _settingsTemplate.Body) - { - if (type == "textBlock") - continue; - if (!Settings.ContainsKey(attribute.Name)) - { - Settings[attribute.Name] = attribute.DefaultValue; - } - } - } - - public virtual async Task InitAsync(PluginInitContext context) - { - this.context = context; - await InitSettingAsync(); - } - private static readonly Thickness settingControlMargin = new(10, 4, 10, 4); - private static readonly Thickness settingPanelMargin = new(15, 20, 15, 20); - private static readonly Thickness settingTextBlockMargin = new(10, 4, 10, 4); - private JsonRpcConfigurationModel _settingsTemplate; - - public Control CreateSettingPanel() - { - if (Settings == null) - return new(); - var settingWindow = new UserControl(); - var mainPanel = new StackPanel - { - Margin = settingPanelMargin, Orientation = Orientation.Vertical - }; - settingWindow.Content = mainPanel; - - foreach (var (type, attribute) in _settingsTemplate.Body) - { - var panel = new StackPanel - { - Orientation = Orientation.Horizontal, Margin = settingControlMargin - }; - var name = new TextBlock() - { - Text = attribute.Label, - Width = 120, - VerticalAlignment = VerticalAlignment.Center, - Margin = settingControlMargin, - TextWrapping = TextWrapping.WrapWithOverflow - }; - - FrameworkElement contentControl; - - switch (type) - { - case "textBlock": - { - contentControl = new TextBlock - { - Text = attribute.Description.Replace("\\r\\n", "\r\n"), - Margin = settingTextBlockMargin, - MaxWidth = 500, - TextWrapping = TextWrapping.WrapWithOverflow - }; - break; - } - case "input": - { - var textBox = new TextBox() - { - Width = 300, - Text = Settings[attribute.Name] as string ?? string.Empty, - Margin = settingControlMargin, - ToolTip = attribute.Description - }; - textBox.TextChanged += (_, _) => - { - Settings[attribute.Name] = textBox.Text; - }; - contentControl = textBox; - break; - } - case "textarea": - { - var textBox = new TextBox() - { - Width = 300, - Height = 120, - Margin = settingControlMargin, - TextWrapping = TextWrapping.WrapWithOverflow, - AcceptsReturn = true, - Text = Settings[attribute.Name] as string ?? string.Empty, - ToolTip = attribute.Description - }; - textBox.TextChanged += (sender, _) => - { - Settings[attribute.Name] = ((TextBox)sender).Text; - }; - contentControl = textBox; - break; - } - case "passwordBox": - { - var passwordBox = new PasswordBox() - { - Width = 300, - Margin = settingControlMargin, - Password = Settings[attribute.Name] as string ?? string.Empty, - PasswordChar = attribute.passwordChar == default ? '*' : attribute.passwordChar, - ToolTip = attribute.Description - }; - passwordBox.PasswordChanged += (sender, _) => - { - Settings[attribute.Name] = ((PasswordBox)sender).Password; - }; - contentControl = passwordBox; - break; - } - case "dropdown": - { - var comboBox = new ComboBox() - { - ItemsSource = attribute.Options, - SelectedItem = Settings[attribute.Name], - Margin = settingControlMargin, - ToolTip = attribute.Description - }; - comboBox.SelectionChanged += (sender, _) => - { - Settings[attribute.Name] = (string)((ComboBox)sender).SelectedItem; - }; - contentControl = comboBox; - break; - } - case "checkbox": - var checkBox = new CheckBox - { - IsChecked = Settings[attribute.Name] is bool isChecked ? isChecked : bool.Parse(attribute.DefaultValue), - Margin = settingControlMargin, - ToolTip = attribute.Description - }; - checkBox.Click += (sender, _) => - { - Settings[attribute.Name] = ((CheckBox)sender).IsChecked; - }; - contentControl = checkBox; - break; - default: - continue; - } - if (type != "textBlock") - _settingControls[attribute.Name] = contentControl; - panel.Children.Add(name); - panel.Children.Add(contentControl); - mainPanel.Children.Add(panel); - } - return settingWindow; - } - - public void Save() - { - if (Settings != null) - { - Helper.ValidateDirectory(Path.Combine(DataLocation.PluginSettingsDirectory, context.CurrentPluginMetadata.Name)); - File.WriteAllText(SettingPath, JsonSerializer.Serialize(Settings, settingSerializeOption)); - } - } - - public void UpdateSettings(Dictionary settings) - { - if (settings == null || settings.Count == 0) - return; - - foreach (var (key, value) in settings) - { - if (Settings.ContainsKey(key)) - { - Settings[key] = value; - } - if (_settingControls.ContainsKey(key)) - { - - switch (_settingControls[key]) - { - case TextBox textBox: - textBox.Dispatcher.Invoke(() => textBox.Text = value as string); - break; - case PasswordBox passwordBox: - passwordBox.Dispatcher.Invoke(() => passwordBox.Password = value as string); - 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)); - break; - } - } - } - } } - } diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs new file mode 100644 index 000000000..7248c6259 --- /dev/null +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs @@ -0,0 +1,178 @@ +using Flow.Launcher.Core.Resource; +using Flow.Launcher.Infrastructure; +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.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 +{ + /// + /// Represent the plugin that using JsonPRC + /// every JsonRPC plugin should has its own plugin instance + /// + public abstract class JsonRPCPluginBase : IAsyncPlugin, IContextMenu, ISettingProvider, ISavable + { + protected PluginInitContext Context; + public const string JsonRPC = "JsonRPC"; + + private int RequestId { get; set; } + + private string SettingConfigurationPath => + Path.Combine(Context.CurrentPluginMetadata.PluginDirectory, "SettingsTemplate.yaml"); + + private string SettingDirectory => Path.Combine(DataLocation.PluginSettingsDirectory, + Context.CurrentPluginMetadata.Name); + + private string SettingPath => Path.Combine(SettingDirectory, "Settings.json"); + + public abstract List LoadContextMenus(Result selectedResult); + + protected static readonly JsonSerializerOptions DeserializeOption = new() + { + PropertyNameCaseInsensitive = true, +#pragma warning disable SYSLIB0020 + // IgnoreNullValues is obsolete, but the replacement JsonIgnoreCondition.WhenWritingNull still + // deserializes null, instead of ignoring it and leaving the default (empty list). We can change the behaviour + // to accept null and fallback to a default etc, or just keep IgnoreNullValues for now + // see: https://github.com/dotnet/runtime/issues/39152 + IgnoreNullValues = true, +#pragma warning restore SYSLIB0020 // Type or member is obsolete + Converters = { new JsonObjectConverter() } + }; + + protected static readonly JsonSerializerOptions RequestSerializeOption = new() + { + PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + protected abstract Task ExecuteResultAsync(JsonRPCResult result); + + protected JsonRPCPluginSettings Settings { get; set; } + + protected List ParseResults(JsonRPCQueryResponseModel queryResponseModel) + { + if (queryResponseModel.Result == null) return null; + + if (!string.IsNullOrEmpty(queryResponseModel.DebugMessage)) + { + Context.API.ShowMsg(queryResponseModel.DebugMessage); + } + + foreach (var result in queryResponseModel.Result) + { + result.AsyncAction = async _ => + { + Settings?.UpdateSettings(result.SettingsChange); + + return await ExecuteResultAsync(result); + }; + } + + var results = new List(); + + results.AddRange(queryResponseModel.Result); + + Settings?.UpdateSettings(queryResponseModel.SettingsChange); + + return results; + } + + protected void ExecuteFlowLauncherAPI(string method, object[] parameters) + { + var parametersTypeArray = parameters.Select(param => param.GetType()).ToArray(); + var methodInfo = typeof(IPublicAPI).GetMethod(method, parametersTypeArray); + + if (methodInfo == null) + { + return; + } + + try + { + methodInfo.Invoke(Context.API, parameters); + } + catch (Exception) + { +#if (DEBUG) + throw; +#endif + } + } + + public abstract Task> QueryAsync(Query query, CancellationToken token); + + + private async Task InitSettingAsync() + { + JsonRpcConfigurationModel configuration = null; + if (File.Exists(SettingConfigurationPath)) + { + var deserializer = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance).Build(); + configuration = + deserializer.Deserialize( + await File.ReadAllTextAsync(SettingConfigurationPath)); + } + + + Settings ??= new JsonRPCPluginSettings + { + Configuration = configuration, SettingPath = SettingPath, API = Context.API + }; + + await Settings.InitializeAsync(); + } + + public virtual async Task InitAsync(PluginInitContext context) + { + this.Context = context; + await InitSettingAsync(); + } + + public void Save() + { + Settings?.Save(); + } + + public bool NeedCreateSettingPanel() + { + return Settings.NeedCreateSettingPanel(); + } + + public Control CreateSettingPanel() + { + return Settings.CreateSettingPanel(); + } + + public void DeletePluginSettingsDirectory() + { + if (Directory.Exists(SettingDirectory)) + { + Directory.Delete(SettingDirectory, true); + } + } + } +} diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs new file mode 100644 index 000000000..8412ba7e8 --- /dev/null +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs @@ -0,0 +1,446 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +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; + +namespace Flow.Launcher.Core.Plugin +{ + public class JsonRPCPluginSettings + { + public required JsonRpcConfigurationModel? Configuration { get; init; } + + public required string SettingPath { get; init; } + public Dictionary SettingControls { get; } = new(); + + public IReadOnlyDictionary Inner => Settings; + protected ConcurrentDictionary Settings { get; set; } + public required IPublicAPI API { get; init; } + + private JsonStorage> _storage; + + // 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); + + public async Task InitializeAsync() + { + _storage = new JsonStorage>(SettingPath); + Settings = await _storage.LoadAsync(); + + if (Configuration == null) + { + return; + } + + foreach (var (type, attributes) in Configuration.Body) + { + if (attributes.Name == null) + { + continue; + } + + if (!Settings.ContainsKey(attributes.Name)) + { + Settings[attributes.Name] = attributes.DefaultValue; + } + } + } + + + public void UpdateSettings(IReadOnlyDictionary settings) + { + if (settings == null || settings.Count == 0) + return; + + foreach (var (key, value) in settings) + { + Settings[key] = value; + + if (SettingControls.TryGetValue(key, out var control)) + { + switch (control) + { + case TextBox textBox: + textBox.Dispatcher.Invoke(() => textBox.Text = value as string ?? string.Empty); + break; + case PasswordBox passwordBox: + passwordBox.Dispatcher.Invoke(() => passwordBox.Password = value as string ?? string.Empty); + 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)); + break; + } + } + } + + Save(); + } + + public async Task SaveAsync() + { + await _storage.SaveAsync(); + } + + public void Save() + { + _storage.Save(); + } + + 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() + { + // No need to check if NeedCreateSettingPanel is true because CreateSettingPanel will only be called if it's true + + 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) + { + Separator sep = new Separator(); + sep.VerticalAlignment = VerticalAlignment.Top; + sep.Margin = settingSepMargin; + sep.SetResourceReference(Separator.BackgroundProperty, "Color03B"); /* for theme change */ + var panel = new StackPanel + { + 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); + + FrameworkElement contentControl; + + 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 + }; + + 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; + } + case "input": + { + var textBox = new TextBox() + { + Text = Settings[attribute.Name] as string ?? string.Empty, + Margin = settingControlMargin, + HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, + ToolTip = attribute.Description + }; + + textBox.TextChanged += (_, _) => + { + Settings[attribute.Name] = textBox.Text; + }; + + contentControl = textBox; + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + if (rowCount != 0) + mainPanel.Children.Add(sep); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + 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 + { + "inputWithFolderBtn" => new FolderBrowserDialog(), + _ => new OpenFileDialog(), + }; + if (dialog.ShowDialog() != DialogResult.OK) return; + + var path = dialog switch + { + FolderBrowserDialog folderDialog => folderDialog.SelectedPath, + OpenFileDialog fileDialog => fileDialog.FileName, + }; + textBox.Text = path; + Settings[attribute.Name] = path; + }; + + var dockPanel = new DockPanel() { Margin = settingControlMargin }; + + 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); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + 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 + }; + + textBox.TextChanged += (sender, _) => + { + Settings[attribute.Name] = ((TextBox)sender).Text; + }; + + contentControl = textBox; + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + if (rowCount != 0) + mainPanel.Children.Add(sep); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + 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 + }; + + passwordBox.PasswordChanged += (sender, _) => + { + Settings[attribute.Name] = ((PasswordBox)sender).Password; + }; + + contentControl = passwordBox; + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + if (rowCount != 0) + mainPanel.Children.Add(sep); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + 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 + }; + + comboBox.SelectionChanged += (sender, _) => + { + Settings[attribute.Name] = (string)((System.Windows.Controls.ComboBox)sender).SelectedItem; + }; + + contentControl = comboBox; + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + if (rowCount != 0) + mainPanel.Children.Add(sep); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + break; + } + case "checkbox": + var checkBox = new CheckBox + { + IsChecked = + Settings[attribute.Name] is bool isChecked + ? isChecked + : bool.Parse(attribute.DefaultValue), + Margin = settingCheckboxMargin, + HorizontalAlignment = System.Windows.HorizontalAlignment.Right, + ToolTip = attribute.Description + }; + + checkBox.Click += (sender, _) => + { + Settings[attribute.Name] = ((CheckBox)sender).IsChecked; + }; + + contentControl = checkBox; + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + if (rowCount != 0) + mainPanel.Children.Add(sep); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + 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 + }; + + linkbtn.Content = attribute.urlLabel; + + contentControl = linkbtn; + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + if (rowCount != 0) + mainPanel.Children.Add(sep); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + break; + default: + continue; + } + + if (type != "textBlock") + SettingControls[attribute.Name] = contentControl; + + mainPanel.Children.Add(panel); + mainPanel.Children.Add(contentControl); + rowCount++; + } + + return settingWindow; + } + } +} diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs new file mode 100644 index 000000000..abe563c14 --- /dev/null +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; +using Flow.Launcher.Core.Plugin.JsonRPCV2Models; +using Flow.Launcher.Plugin; +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"; + + protected abstract IDuplexPipe ClientPipe { get; set; } + + protected StreamReader ErrorStream { get; set; } + + private JsonRpc RPC { get; set; } + + + protected override async Task ExecuteResultAsync(JsonRPCResult result) + { + var res = await RPC.InvokeAsync(result.JsonRPCAction.Method, + argument: result.JsonRPCAction.Parameters); + + return res.Hide; + } + + private JoinableTaskFactory JTF { get; } = new JoinableTaskFactory(new JoinableTaskContext()); + + public override List LoadContextMenus(Result selectedResult) + { + var res = JTF.Run(() => RPC.InvokeWithCancellationAsync("context_menu", + new object[] { selectedResult.ContextData })); + + var results = ParseResults(res); + + return results; + } + + public override async Task> QueryAsync(Query query, CancellationToken token) + { + var res = await RPC.InvokeWithCancellationAsync("query", + new object[] { query, Settings.Inner }, + token); + + var results = ParseResults(res); + + return results; + } + + + public override async Task InitAsync(PluginInitContext context) + { + await base.InitAsync(context); + + SetupJsonRPC(); + + _ = ReadErrorAsync(); + + await RPC.InvokeAsync("initialize", context); + + async Task ReadErrorAsync() + { + var error = await ErrorStream.ReadToEndAsync(); + + if (!string.IsNullOrEmpty(error)) + { + throw new Exception(error); + } + } + } + + public event ResultUpdatedEventHandler ResultsUpdated; + + protected enum MessageHandlerType + { + HeaderDelimited, + LengthHeaderDelimited, + NewLineDelimited + } + + protected abstract MessageHandlerType MessageHandler { get; } + + + private void SetupJsonRPC() + { + var formatter = new SystemTextJsonFormatter { JsonSerializerOptions = RequestSerializeOption }; + IJsonRpcMessageHandler handler = MessageHandler switch + { + MessageHandlerType.HeaderDelimited => new HeaderDelimitedMessageHandler(ClientPipe, formatter), + MessageHandlerType.LengthHeaderDelimited => new LengthHeaderMessageHandler(ClientPipe, formatter), + MessageHandlerType.NewLineDelimited => new NewLineDelimitedMessageHandler(ClientPipe, formatter), + _ => throw new ArgumentOutOfRangeException() + }; + + RPC = new JsonRpc(handler, new JsonRPCPublicAPI(Context.API)); + + RPC.AddLocalRpcMethod("UpdateResults", new Action((rawQuery, response) => + { + var results = ParseResults(response); + ResultsUpdated?.Invoke(this, + new ResultUpdatedEventArgs { Query = new Query() { RawQuery = rawQuery }, Results = results }); + })); + RPC.SynchronizationContext = null; + RPC.StartListening(); + } + + public virtual async Task ReloadDataAsync() + { + try + { + await RPC.InvokeAsync("reload_data", Context); + } + catch (RemoteMethodNotFoundException e) + { + } + } + + public virtual async ValueTask DisposeAsync() + { + try + { + await RPC.InvokeAsync("close"); + } + catch (RemoteMethodNotFoundException e) + { + } + finally + { + RPC?.Dispose(); + ErrorStream?.Dispose(); + } + } + } +} diff --git a/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCExecuteResponse.cs b/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCExecuteResponse.cs new file mode 100644 index 000000000..6a130f70f --- /dev/null +++ b/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCExecuteResponse.cs @@ -0,0 +1,4 @@ +namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models +{ + public record JsonRPCExecuteResponse(bool Hide = true); +} diff --git a/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs b/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs new file mode 100644 index 000000000..8df2ce9ed --- /dev/null +++ b/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedModels; + +namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models +{ + public class JsonRPCPublicAPI + { + private IPublicAPI _api; + + public JsonRPCPublicAPI(IPublicAPI api) + { + _api = api; + } + + public void ChangeQuery(string query, bool requery = false) + { + _api.ChangeQuery(query, requery); + } + + public void RestartApp() + { + _api.RestartApp(); + } + + public void ShellRun(string cmd, string filename = "cmd.exe") + { + _api.ShellRun(cmd, filename); + } + + public void CopyToClipboard(string text, bool directCopy = false, bool showDefaultNotification = true) + { + _api.CopyToClipboard(text, directCopy, showDefaultNotification); + } + + public void SaveAppAllSettings() + { + _api.SaveAppAllSettings(); + } + + public void SavePluginSettings() + { + _api.SavePluginSettings(); + } + + public Task ReloadAllPluginDataAsync() + { + return _api.ReloadAllPluginData(); + } + + public void CheckForNewUpdate() + { + _api.CheckForNewUpdate(); + } + + public void ShowMsgError(string title, string subTitle = "") + { + _api.ShowMsgError(title, subTitle); + } + + public void ShowMainWindow() + { + _api.ShowMainWindow(); + } + + public void HideMainWindow() + { + _api.HideMainWindow(); + } + + public bool IsMainWindowVisible() + { + return _api.IsMainWindowVisible(); + } + + public void ShowMsg(string title, string subTitle = "", string iconPath = "") + { + _api.ShowMsg(title, subTitle, iconPath); + } + + public void ShowMsg(string title, string subTitle, string iconPath, bool useMainWindowAsOwner = true) + { + _api.ShowMsg(title, subTitle, iconPath, useMainWindowAsOwner); + } + + public void OpenSettingDialog() + { + _api.OpenSettingDialog(); + } + + public string GetTranslation(string key) + { + return _api.GetTranslation(key); + } + + public List GetAllPlugins() + { + return _api.GetAllPlugins(); + } + + + public MatchResult FuzzySearch(string query, string stringToCompare) + { + return _api.FuzzySearch(query, stringToCompare); + } + + public Task HttpGetStringAsync(string url, CancellationToken token = default) + { + return _api.HttpGetStringAsync(url, token); + } + + public Task HttpGetStreamAsync(string url, CancellationToken token = default) + { + return _api.HttpGetStreamAsync(url, token); + } + + public Task HttpDownloadAsync([NotNull] string url, [NotNull] string filePath, Action reportProgress = null, + CancellationToken token = default) + { + return _api.HttpDownloadAsync(url, filePath, reportProgress, token); + } + + public void AddActionKeyword(string pluginId, string newActionKeyword) + { + _api.AddActionKeyword(pluginId, newActionKeyword); + } + + public void RemoveActionKeyword(string pluginId, string oldActionKeyword) + { + _api.RemoveActionKeyword(pluginId, oldActionKeyword); + } + + public bool ActionKeywordAssigned(string actionKeyword) + { + return _api.ActionKeywordAssigned(actionKeyword); + } + + public void LogDebug(string className, string message, [CallerMemberName] string methodName = "") + { + _api.LogDebug(className, message, methodName); + } + + public void LogInfo(string className, string message, [CallerMemberName] string methodName = "") + { + _api.LogInfo(className, message, methodName); + } + + public void LogWarn(string className, string message, [CallerMemberName] string methodName = "") + { + _api.LogWarn(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(); + } + } +} diff --git a/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCQueryRequest.cs b/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCQueryRequest.cs new file mode 100644 index 000000000..003724a23 --- /dev/null +++ b/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCQueryRequest.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models +{ + public record JsonRPCQueryRequest( + List Results + ); +} diff --git a/Flow.Launcher.Core/Plugin/NodePlugin.cs b/Flow.Launcher.Core/Plugin/NodePlugin.cs new file mode 100644 index 000000000..aeac18ca9 --- /dev/null +++ b/Flow.Launcher.Core/Plugin/NodePlugin.cs @@ -0,0 +1,50 @@ +using System.Diagnostics; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Core.Plugin +{ + /// + /// Execution of JavaScript & TypeScript plugins + /// + internal class NodePlugin : JsonRPCPlugin + { + private readonly ProcessStartInfo _startInfo; + + public NodePlugin(string filename) + { + _startInfo = new ProcessStartInfo + { + FileName = filename, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + } + + protected override Task RequestAsync(JsonRPCRequestModel request, CancellationToken token = default) + { + _startInfo.ArgumentList[1] = JsonSerializer.Serialize(request, RequestSerializeOption); + return ExecuteAsync(_startInfo, token); + } + + protected override string Request(JsonRPCRequestModel rpcRequest, CancellationToken token = default) + { + // since this is not static, request strings will build up in ArgumentList if index is not specified + _startInfo.ArgumentList[1] = JsonSerializer.Serialize(rpcRequest, RequestSerializeOption); + return Execute(_startInfo); + } + + public override async Task InitAsync(PluginInitContext context) + { + _startInfo.ArgumentList.Add(context.CurrentPluginMetadata.ExecuteFilePath); + _startInfo.ArgumentList.Add(string.Empty); + _startInfo.WorkingDirectory = context.CurrentPluginMetadata.PluginDirectory; + await base.InitAsync(context); + } + } +} diff --git a/Flow.Launcher.Core/Plugin/NodePluginV2.cs b/Flow.Launcher.Core/Plugin/NodePluginV2.cs new file mode 100644 index 000000000..c8cc37c57 --- /dev/null +++ b/Flow.Launcher.Core/Plugin/NodePluginV2.cs @@ -0,0 +1,30 @@ +using System.Diagnostics; +using System.IO; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Core.Plugin +{ + /// + /// Execution of JavaScript & TypeScript plugins + /// + internal sealed class NodePluginV2 : ProcessStreamPluginV2 + { + public NodePluginV2(string filename) + { + StartInfo = new ProcessStartInfo { FileName = filename, }; + } + + protected override ProcessStartInfo StartInfo { get; set; } + + public override async Task InitAsync(PluginInitContext context) + { + StartInfo.ArgumentList.Add(context.CurrentPluginMetadata.ExecuteFilePath); + await base.InitAsync(context); + } + + protected override MessageHandlerType MessageHandler { get; } = MessageHandlerType.HeaderDelimited; + } +} diff --git a/Flow.Launcher.Core/Plugin/PluginAssemblyLoader.cs b/Flow.Launcher.Core/Plugin/PluginAssemblyLoader.cs index 9d76b6be0..87bf8e6e8 100644 --- a/Flow.Launcher.Core/Plugin/PluginAssemblyLoader.cs +++ b/Flow.Launcher.Core/Plugin/PluginAssemblyLoader.cs @@ -1,7 +1,4 @@ -using Flow.Launcher.Infrastructure; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; +using System; using System.IO; using System.Linq; using System.Reflection; @@ -37,6 +34,17 @@ namespace Flow.Launcher.Core.Plugin return existAssembly ?? (assemblyPath == null ? null : LoadFromAssemblyPath(assemblyPath)); } + + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + var path = dependencyResolver.ResolveUnmanagedDllToPath(unmanagedDllName); + if (!string.IsNullOrEmpty(path)) + { + return LoadUnmanagedDllFromPath(path); + } + + return IntPtr.Zero; + } internal Type FromAssemblyGetTypeOfInterface(Assembly assembly, Type type) { @@ -44,4 +52,4 @@ namespace Flow.Launcher.Core.Plugin return allTypes.First(o => o.IsClass && !o.IsAbstract && o.GetInterfaces().Any(t => t == type)); } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 3b4a6e445..09711051e 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -11,6 +11,10 @@ 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 CommunityToolkit.Mvvm.DependencyInjection; namespace Flow.Launcher.Core.Plugin { @@ -25,11 +29,13 @@ namespace Flow.Launcher.Core.Plugin 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(); - // todo happlebao, this should not be public, the indicator function should be embeded - public static PluginsSettings Settings; + private static PluginsSettings Settings; private static List _metadatas; + private static List _modifiedPlugins = new List(); /// /// Directories that will hold Flow Launcher plugin directory @@ -48,6 +54,9 @@ namespace Flow.Launcher.Core.Plugin } } + /// + /// Save json and ISavable + /// public static void Save() { foreach (var plugin in AllPlugins) @@ -85,6 +94,48 @@ namespace Flow.Launcher.Core.Plugin }).ToArray()); } + public static async Task OpenExternalPreviewAsync(string path, bool sendFailToast = true) + { + await Task.WhenAll(AllPlugins.Select(plugin => plugin.Plugin switch + { + IAsyncExternalPreview p => p.OpenPreviewAsync(path, sendFailToast), + _ => Task.CompletedTask, + }).ToArray()); + } + + public static async Task CloseExternalPreviewAsync() + { + await Task.WhenAll(AllPlugins.Select(plugin => plugin.Plugin switch + { + IAsyncExternalPreview p => p.ClosePreviewAsync(), + _ => Task.CompletedTask, + }).ToArray()); + } + + public static async Task SwitchExternalPreviewAsync(string path, bool sendFailToast = true) + { + await Task.WhenAll(AllPlugins.Select(plugin => plugin.Plugin switch + { + IAsyncExternalPreview p => p.SwitchPreviewAsync(path, sendFailToast), + _ => Task.CompletedTask, + }).ToArray()); + } + + public static bool UseExternalPreview() + { + return GetPluginsForInterface().Any(x => !x.Metadata.Disabled); + } + + public static bool AllowAlwaysPreview() + { + var plugin = GetPluginsForInterface().FirstOrDefault(x => !x.Metadata.Disabled); + + if (plugin is null) + return false; + + return ((IAsyncExternalPreview)plugin.Plugin).AllowAlwaysPreview(); + } + static PluginManager() { // validate user directory @@ -110,9 +161,8 @@ namespace Flow.Launcher.Core.Plugin /// 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 @@ -155,12 +205,21 @@ namespace Flow.Launcher.Core.Plugin } } + InternationalizationManager.Instance.AddPluginLanguageDirectories(GetPluginsForInterface()); + InternationalizationManager.Instance.ChangeLanguage(Ioc.Default.GetRequiredService().Language); + if (failedPlugins.Any()) { var failed = string.Join(",", failedPlugins.Select(x => x.Metadata.Name)); - API.ShowMsg($"Fail to Init Plugins", - $"Plugins: {failed} - fail to load and would be disabled, please contact plugin creator for help", - "", false); + API.ShowMsg( + API.GetTranslation("failedToInitializePluginsTitle"), + string.Format( + API.GetTranslation("failedToInitializePluginsMessage"), + failed + ), + "", + false + ); } } @@ -168,11 +227,11 @@ namespace Flow.Launcher.Core.Plugin { if (query is null) return Array.Empty(); - + if (!NonGlobalPlugins.ContainsKey(query.ActionKeyword)) return GlobalPlugins; - - + + var plugin = NonGlobalPlugins[query.ActionKeyword]; return new List { @@ -207,12 +266,24 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - throw new FlowPluginException(metadata, e); + Result r = new() + { + Title = $"{metadata.Name}: Failed to respond!", + SubTitle = "Select this result for more info", + IcoPath = Flow.Launcher.Infrastructure.Constant.ErrorIcon, + PluginDirectory = metadata.PluginDirectory, + ActionKeywordAssigned = query.ActionKeyword, + PluginID = metadata.ID, + OriginQuery = query, + Action = _ => { throw new FlowPluginException(metadata, e);}, + Score = -100 + }; + results.Add(r); } return results; } - public static void UpdatePluginMetadata(List results, PluginMetadata metadata, Query query) + public static void UpdatePluginMetadata(IReadOnlyList results, PluginMetadata metadata, Query query) { foreach (var r in results) { @@ -220,8 +291,8 @@ namespace Flow.Launcher.Core.Plugin r.PluginID = metadata.ID; r.OriginQuery = query; - // ActionKeywordAssigned is used for constructing MainViewModel's query text auto-complete suggestions - // Plugins may have multi-actionkeywords eg. WebSearches. In this scenario it needs to be overriden on the plugin level + // ActionKeywordAssigned is used for constructing MainViewModel's query text auto-complete suggestions + // Plugins may have multi-actionkeywords eg. WebSearches. In this scenario it needs to be overriden on the plugin level if (metadata.ActionKeywords.Count == 1) r.ActionKeywordAssigned = query.ActionKeyword; } @@ -239,7 +310,8 @@ namespace Flow.Launcher.Core.Plugin public static IEnumerable GetPluginsForInterface() where T : IFeatures { - return AllPlugins.Where(p => p.Plugin is T); + // Handle scenario where this is called before all plugins are instantiated, e.g. language change on startup + return AllPlugins?.Where(p => p.Plugin is T) ?? Array.Empty(); } public static List GetContextMenusForPlugin(Result result) @@ -328,5 +400,215 @@ namespace Flow.Launcher.Core.Plugin RemoveActionKeyword(id, oldActionKeyword); } } + + private static string GetContainingFolderPathAfterUnzip(string unzippedParentFolderPath) + { + var unzippedFolderCount = Directory.GetDirectories(unzippedParentFolderPath).Length; + var unzippedFilesCount = Directory.GetFiles(unzippedParentFolderPath).Length; + + // adjust path depending on how the plugin is zipped up + // the recommended should be to zip up the folder not the contents + if (unzippedFolderCount == 1 && unzippedFilesCount == 0) + // folder is zipped up, unzipped plugin directory structure: tempPath/unzippedParentPluginFolder/pluginFolderName/ + return Directory.GetDirectories(unzippedParentFolderPath)[0]; + + if (unzippedFilesCount > 1) + // content is zipped up, unzipped plugin directory structure: tempPath/unzippedParentPluginFolder/ + return unzippedParentFolderPath; + + return string.Empty; + } + + private static bool SameOrLesserPluginVersionExists(string metadataPath) + { + var newMetadata = JsonSerializer.Deserialize(File.ReadAllText(metadataPath)); + return AllPlugins.Any(x => x.Metadata.ID == newMetadata.ID + && newMetadata.Version.CompareTo(x.Metadata.Version) <= 0); + } + + #region Public functions + + public static bool PluginModified(string uuid) + { + return _modifiedPlugins.Contains(uuid); + } + + + /// + /// 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) + { + InstallPlugin(newVersion, zipFilePath, checkModified:false); + UninstallPlugin(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 removePluginFromSettings = true, bool removePluginSettings = false) + { + UninstallPlugin(plugin, removePluginFromSettings, removePluginSettings, true); + } + + #endregion + + #region Internal functions + + internal static void InstallPlugin(UserPlugin plugin, string zipFilePath, bool checkModified) + { + if (checkModified && PluginModified(plugin.ID)) + { + // Distinguish exception from installing same or less version + throw new ArgumentException($"Plugin {plugin.Name} {plugin.ID} has been modified.", nameof(plugin)); + } + + // Unzip plugin files to temp folder + var tempFolderPluginPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + System.IO.Compression.ZipFile.ExtractToDirectory(zipFilePath, tempFolderPluginPath); + + if(!plugin.IsFromLocalInstallPath) + File.Delete(zipFilePath); + + var pluginFolderPath = GetContainingFolderPathAfterUnzip(tempFolderPluginPath); + + var metadataJsonFilePath = string.Empty; + if (File.Exists(Path.Combine(pluginFolderPath, Constant.PluginMetadataFileName))) + metadataJsonFilePath = Path.Combine(pluginFolderPath, Constant.PluginMetadataFileName); + + if (string.IsNullOrEmpty(metadataJsonFilePath) || string.IsNullOrEmpty(pluginFolderPath)) + { + throw new FileNotFoundException($"Unable to find plugin.json from the extracted zip file, or this path {pluginFolderPath} does not exist"); + } + + if (SameOrLesserPluginVersionExists(metadataJsonFilePath)) + { + throw new InvalidOperationException($"A plugin with the same ID and version already exists, or the version is greater than this downloaded plugin {plugin.Name}"); + } + + 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 + }; + + // Treat default plugin differently, it needs to be removable along with each flow release + var installDirectory = !defaultPluginIDs.Any(x => x == plugin.ID) + ? DataLocation.PluginsDirectory + : Constant.PreinstalledDirectory; + + var newPluginPath = Path.Combine(installDirectory, folderName); + + FilesFolders.CopyAll(pluginFolderPath, newPluginPath, (s) => API.ShowMsgBox(s)); + + try + { + if (Directory.Exists(tempFolderPluginPath)) + Directory.Delete(tempFolderPluginPath, true); + } + catch (Exception e) + { + Log.Exception($"|PluginManager.InstallPlugin|Failed to delete temp folder {tempFolderPluginPath}", e); + } + + if (checkModified) + { + _modifiedPlugins.Add(plugin.ID); + } + } + + internal static void UninstallPlugin(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified) + { + if (checkModified && PluginModified(plugin.ID)) + { + throw new ArgumentException($"Plugin {plugin.Name} has been modified"); + } + + if (removePluginSettings) + { + if (AllowedLanguage.IsDotNet(plugin.Language)) // for the plugin in .NET, we can use assembly loader + { + var assemblyLoader = new PluginAssemblyLoader(plugin.ExecuteFilePath); + var assembly = assemblyLoader.LoadAssemblyAndDependencies(); + var assemblyName = assembly.GetName().Name; + + // if user want to remove the plugin settings, we cannot call save method for the plugin json storage instance of this plugin + // so we need to remove it from the api instance + var method = API.GetType().GetMethod("RemovePluginSettings"); + var pluginJsonStorage = method?.Invoke(API, new object[] { assemblyName }); + + // if there exists a json storage for current plugin, we need to delete the directory path + if (pluginJsonStorage != null) + { + var deleteMethod = pluginJsonStorage.GetType().GetMethod("DeleteDirectory"); + try + { + deleteMethod?.Invoke(pluginJsonStorage, null); + } + catch (Exception e) + { + Log.Exception($"|PluginManager.UninstallPlugin|Failed to delete plugin json folder for {plugin.Name}", e); + API.ShowMsg(API.GetTranslation("failedToRemovePluginSettingsTitle"), + string.Format(API.GetTranslation("failedToRemovePluginSettingsMessage"), plugin.Name)); + } + } + } + else // the plugin with json prc interface + { + var pluginPair = AllPlugins.FirstOrDefault(p => p.Metadata.ID == plugin.ID); + if (pluginPair != null && pluginPair.Plugin is JsonRPCPlugin jsonRpcPlugin) + { + try + { + jsonRpcPlugin.DeletePluginSettingsDirectory(); + } + catch (Exception e) + { + Log.Exception($"|PluginManager.UninstallPlugin|Failed to delete plugin json folder for {plugin.Name}", e); + API.ShowMsg(API.GetTranslation("failedToRemovePluginSettingsTitle"), + string.Format(API.GetTranslation("failedToRemovePluginSettingsMessage"), plugin.Name)); + } + } + } + } + + if (removePluginFromSettings) + { + Settings.Plugins.Remove(plugin.ID); + AllPlugins.RemoveAll(p => p.Metadata.ID == plugin.ID); + } + + // Marked for deletion. Will be deleted on next start up + using var _ = File.CreateText(Path.Combine(plugin.PluginDirectory, "NeedDelete.txt")); + + if (checkModified) + { + _modifiedPlugins.Add(plugin.ID); + } + } + + #endregion } } diff --git a/Flow.Launcher.Core/Plugin/PluginsLoader.cs b/Flow.Launcher.Core/Plugin/PluginsLoader.cs index 752174263..4827cf69d 100644 --- a/Flow.Launcher.Core/Plugin/PluginsLoader.cs +++ b/Flow.Launcher.Core/Plugin/PluginsLoader.cs @@ -1,30 +1,52 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; -using System.Windows.Forms; -using Droplex; -using Flow.Launcher.Infrastructure; +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 Flow.Launcher.Plugin.SharedCommands; using Stopwatch = Flow.Launcher.Infrastructure.Stopwatch; namespace Flow.Launcher.Core.Plugin { public static class PluginsLoader { - public const string PythonExecutable = "pythonw.exe"; - public static List Plugins(List metadatas, PluginsSettings settings) { var dotnetPlugins = DotNetPlugins(metadatas); - var pythonPlugins = PythonPlugins(metadatas, settings); + + var pythonEnv = new PythonEnvironment(metadatas, settings); + var pythonV2Env = new PythonV2Environment(metadatas, settings); + var tsEnv = new TypeScriptEnvironment(metadatas, settings); + var jsEnv = new JavaScriptEnvironment(metadatas, settings); + var tsV2Env = new TypeScriptV2Environment(metadatas, settings); + var jsV2Env = new JavaScriptV2Environment(metadatas, settings); + var pythonPlugins = pythonEnv.Setup(); + var pythonV2Plugins = pythonV2Env.Setup(); + var tsPlugins = tsEnv.Setup(); + var jsPlugins = jsEnv.Setup(); + var tsV2Plugins = tsV2Env.Setup(); + var jsV2Plugins = jsV2Env.Setup(); + var executablePlugins = ExecutablePlugins(metadatas); - var plugins = dotnetPlugins.Concat(pythonPlugins).Concat(executablePlugins).ToList(); + var executableV2Plugins = ExecutableV2Plugins(metadatas); + + var plugins = dotnetPlugins + .Concat(pythonPlugins) + .Concat(pythonV2Plugins) + .Concat(tsPlugins) + .Concat(jsPlugins) + .Concat(tsV2Plugins) + .Concat(jsV2Plugins) + .Concat(executablePlugins) + .Concat(executableV2Plugins) + .ToList(); return plugins; } @@ -83,7 +105,7 @@ namespace Flow.Launcher.Core.Plugin return; } - plugins.Add(new PluginPair {Plugin = plugin, Metadata = metadata}); + plugins.Add(new PluginPair { Plugin = plugin, Metadata = metadata }); }); metadata.InitTime += milliseconds; } @@ -98,111 +120,34 @@ 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 PythonPlugins(List source, PluginsSettings settings) - { - if (!source.Any(o => o.Language.ToUpper() == AllowedLanguage.Python)) - return new List(); - - if (!string.IsNullOrEmpty(settings.PythonDirectory) && FilesFolders.LocationExists(settings.PythonDirectory)) - return SetPythonPathForPluginPairs(source, Path.Combine(settings.PythonDirectory, PythonExecutable)); - - var pythonPath = string.Empty; - - if (MessageBox.Show("Flow detected you have installed Python plugins, which " + - "will need Python to run. Would you like to download Python? " + - Environment.NewLine + Environment.NewLine + - "Click no if it's already installed, " + - "and you will be prompted to select the folder that contains the Python executable", - string.Empty, MessageBoxButtons.YesNo) == DialogResult.No - && string.IsNullOrEmpty(settings.PythonDirectory)) - { - var dlg = new FolderBrowserDialog - { - SelectedPath = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) - }; - - var result = dlg.ShowDialog(); - if (result == DialogResult.OK) - { - string pythonDirectory = dlg.SelectedPath; - if (!string.IsNullOrEmpty(pythonDirectory)) - { - pythonPath = Path.Combine(pythonDirectory, PythonExecutable); - if (File.Exists(pythonPath)) - { - settings.PythonDirectory = pythonDirectory; - Constant.PythonPath = pythonPath; - } - else - { - MessageBox.Show("Can't find python in given directory"); - } - } - } - } - else - { - var installedPythonDirectory = Path.Combine(DataLocation.DataDirectory(), "PythonEmbeddable"); - - // Python 3.8.9 is used for Windows 7 compatibility - DroplexPackage.Drop(App.python_3_8_9_embeddable, installedPythonDirectory).Wait(); - - pythonPath = Path.Combine(installedPythonDirectory, PythonExecutable); - if (FilesFolders.FileExists(pythonPath)) - { - settings.PythonDirectory = installedPythonDirectory; - Constant.PythonPath = pythonPath; - } - else - { - Log.Error("PluginsLoader", - $"Failed to set Python path after Droplex install, {pythonPath} does not exist", - "PythonPlugins"); - } - } - - if (string.IsNullOrEmpty(settings.PythonDirectory) || string.IsNullOrEmpty(pythonPath)) - { - MessageBox.Show( - "Unable to set Python executable path, please try from Flow's settings (scroll down to the bottom)."); - Log.Error("PluginsLoader", - $"Not able to successfully set Python path, the PythonDirectory variable is still an empty string.", - "PythonPlugins"); - - return new List(); - } - - return SetPythonPathForPluginPairs(source, pythonPath); - } - - private static IEnumerable SetPythonPathForPluginPairs(List source, string pythonPath) - => source - .Where(o => o.Language.ToUpper() == AllowedLanguage.Python) - .Select(metadata => new PluginPair - { - Plugin = new PythonPlugin(pythonPath), - Metadata = metadata - }) - .ToList(); - - public static IEnumerable ExecutablePlugins(IEnumerable source) + public static IEnumerable ExecutablePlugins(IEnumerable source) { return source - .Where(o => o.Language.ToUpper() == AllowedLanguage.Executable) + .Where(o => o.Language.Equals(AllowedLanguage.Executable, StringComparison.OrdinalIgnoreCase)) .Select(metadata => new PluginPair { Plugin = new ExecutablePlugin(metadata.ExecuteFilePath), Metadata = metadata }); } + + public static IEnumerable ExecutableV2Plugins(IEnumerable source) + { + return source + .Where(o => o.Language.Equals(AllowedLanguage.ExecutableV2, StringComparison.OrdinalIgnoreCase)) + .Select(metadata => new PluginPair + { + Plugin = new ExecutablePluginV2(metadata.ExecuteFilePath), Metadata = metadata + }); + } } } diff --git a/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs b/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs new file mode 100644 index 000000000..bae263157 --- /dev/null +++ b/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs @@ -0,0 +1,91 @@ +#nullable enable + +using System; +using System.Collections.Generic; +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; + +namespace Flow.Launcher.Core.Plugin +{ + internal abstract class ProcessStreamPluginV2 : JsonRPCPluginV2 + { + private static JobObject _jobObject = new JobObject(); + + static ProcessStreamPluginV2() + { + _jobObject.SetLimits(new JobObjectLimits() + { + Flags = JobObjectLimitFlags.KillOnJobClose | JobObjectLimitFlags.DieOnUnhandledException | + JobObjectLimitFlags.SilentBreakawayOk + }); + + _jobObject.AssignProcess(Process.GetCurrentProcess()); + } + + protected sealed override IDuplexPipe ClientPipe { get; set; } = null!; + + protected abstract ProcessStartInfo StartInfo { get; set; } + + protected Process ClientProcess { get; set; } = null!; + + public override async Task InitAsync(PluginInitContext context) + { + StartInfo.EnvironmentVariables["FLOW_VERSION"] = Constant.Version; + StartInfo.EnvironmentVariables["FLOW_PROGRAM_DIRECTORY"] = Constant.ProgramDirectory; + StartInfo.EnvironmentVariables["FLOW_APPLICATION_DIRECTORY"] = Constant.ApplicationDirectory; + + StartInfo.RedirectStandardError = true; + StartInfo.RedirectStandardInput = true; + StartInfo.RedirectStandardOutput = true; + StartInfo.CreateNoWindow = true; + StartInfo.UseShellExecute = false; + StartInfo.WorkingDirectory = context.CurrentPluginMetadata.PluginDirectory; + + var process = Process.Start(StartInfo); + ArgumentNullException.ThrowIfNull(process); + ClientProcess = process; + _jobObject.AssignProcess(ClientProcess); + + SetupPipe(ClientProcess); + + ErrorStream = ClientProcess.StandardError; + + await base.InitAsync(context); + } + + private void SetupPipe(Process process) + { + var (reader, writer) = (PipeReader.Create(process.StandardOutput.BaseStream), + PipeWriter.Create(process.StandardInput.BaseStream)); + ClientPipe = new DuplexPipe(reader, writer); + } + + + public override async Task ReloadDataAsync() + { + var oldProcess = ClientProcess; + ClientProcess = Process.Start(StartInfo); + ArgumentNullException.ThrowIfNull(ClientProcess); + SetupPipe(ClientProcess); + await base.ReloadDataAsync(); + oldProcess.Kill(true); + await oldProcess.WaitForExitAsync(); + oldProcess.Dispose(); + } + + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + ClientProcess.Kill(true); + await ClientProcess.WaitForExitAsync(); + ClientProcess.Dispose(); + } + } +} diff --git a/Flow.Launcher.Core/Plugin/PythonPlugin.cs b/Flow.Launcher.Core/Plugin/PythonPlugin.cs index 8f7e5760a..e40b0330e 100644 --- a/Flow.Launcher.Core/Plugin/PythonPlugin.cs +++ b/Flow.Launcher.Core/Plugin/PythonPlugin.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Diagnostics; using System.IO; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Flow.Launcher.Infrastructure; @@ -11,7 +12,6 @@ namespace Flow.Launcher.Core.Plugin internal class PythonPlugin : JsonRPCPlugin { private readonly ProcessStartInfo _startInfo; - public override string SupportedLanguage { get; set; } = AllowedLanguage.Python; public PythonPlugin(string filename) { @@ -24,37 +24,79 @@ namespace Flow.Launcher.Core.Plugin RedirectStandardError = true, }; - // temp fix for issue #667 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) { - _startInfo.ArgumentList[2] = request.ToString(); + _startInfo.ArgumentList[2] = JsonSerializer.Serialize(request, RequestSerializeOption); return ExecuteAsync(_startInfo, token); } protected override string Request(JsonRPCRequestModel rpcRequest, CancellationToken token = default) { - _startInfo.ArgumentList[2] = rpcRequest.ToString(); - _startInfo.WorkingDirectory = context.CurrentPluginMetadata.PluginDirectory; + // since this is not static, request strings will build up in ArgumentList if index is not specified + _startInfo.ArgumentList[2] = JsonSerializer.Serialize(rpcRequest, RequestSerializeOption); + _startInfo.WorkingDirectory = Context.CurrentPluginMetadata.PluginDirectory; // 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 new file mode 100644 index 000000000..8a9e1ff44 --- /dev/null +++ b/Flow.Launcher.Core/Plugin/PythonPluginV2.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Pipelines; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; +using Flow.Launcher.Core.Plugin.JsonRPCV2Models; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Plugin; +using Microsoft.VisualStudio.Threading; +using Nerdbank.Streams; +using StreamJsonRpc; + +namespace Flow.Launcher.Core.Plugin +{ + internal sealed class PythonPluginV2 : ProcessStreamPluginV2 + { + protected override ProcessStartInfo StartInfo { get; set; } + + public PythonPluginV2(string filename) + { + StartInfo = new ProcessStartInfo { FileName = filename, }; + + var path = Path.Combine(Constant.ProgramDirectory, JsonRpc); + StartInfo.EnvironmentVariables["PYTHONPATH"] = path; + StartInfo.EnvironmentVariables["PYTHONDONTWRITEBYTECODE"] = "1"; + } + + public override async Task InitAsync(PluginInitContext context) + { + // 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); + } + + protected override MessageHandlerType MessageHandler { get; } = MessageHandlerType.NewLineDelimited; + } +} diff --git a/Flow.Launcher.Core/Plugin/QueryBuilder.cs b/Flow.Launcher.Core/Plugin/QueryBuilder.cs index a819f94b7..3dc7877ac 100644 --- a/Flow.Launcher.Core/Plugin/QueryBuilder.cs +++ b/Flow.Launcher.Core/Plugin/QueryBuilder.cs @@ -1,6 +1,5 @@ -using System; +using System; using System.Collections.Generic; -using System.Linq; using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.Plugin @@ -34,9 +33,13 @@ namespace Flow.Launcher.Core.Plugin searchTerms = terms; } - var query = new Query(rawQuery, search,terms, searchTerms, actionKeyword); - - return query; + return new Query () + { + Search = search, + RawQuery = rawQuery, + SearchTerms = searchTerms, + ActionKeyword = actionKeyword + }; } } } \ No newline at end of file diff --git a/Flow.Launcher.Core/Resource/AvailableLanguages.cs b/Flow.Launcher.Core/Resource/AvailableLanguages.cs index f541d3f35..ecaecf646 100644 --- a/Flow.Launcher.Core/Resource/AvailableLanguages.cs +++ b/Flow.Launcher.Core/Resource/AvailableLanguages.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace Flow.Launcher.Core.Resource { @@ -23,8 +23,12 @@ namespace Flow.Launcher.Core.Resource public static Language Spanish_LatinAmerica = new Language("es-419", "Spanish (Latin America)"); public static Language Italian = new Language("it", "Italiano"); public static Language Norwegian_Bokmal = new Language("nb-NO", "Norsk Bokmål"); - public static Language Slovak = new Language("sk", "Slovenský"); + public static Language Slovak = new Language("sk", "Slovenčina"); public static Language Turkish = new Language("tr", "Türkçe"); + 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() { @@ -50,9 +54,46 @@ namespace Flow.Launcher.Core.Resource Italian, Norwegian_Bokmal, Slovak, - Turkish + Turkish, + Czech, + Arabic, + 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 4568e92f3..e2a66656a 100644 --- a/Flow.Launcher.Core/Resource/Internationalization.cs +++ b/Flow.Launcher.Core/Resource/Internationalization.cs @@ -11,38 +11,65 @@ 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 const string Folder = "Languages"; + private const string DefaultLanguageCode = "en"; private const string DefaultFile = "en.xaml"; private const string Extension = ".xaml"; + private readonly Settings _settings; private readonly List _languageDirectories = new List(); private readonly List _oldResources = new List(); + private readonly string SystemLanguageCode; - public Internationalization() + public Internationalization(Settings settings) { - AddPluginLanguageDirectories(); - LoadDefaultLanguage(); - // we don't want to load /Languages/en.xaml twice - // so add flowlauncher language directory after load plugin language files + _settings = settings; AddFlowLauncherLanguageDirectory(); + SystemLanguageCode = GetSystemLanguageCodeAtStartup(); } - private void AddFlowLauncherLanguageDirectory() { var directory = Path.Combine(Constant.ProgramDirectory, Folder); _languageDirectories.Add(directory); } - - private void AddPluginLanguageDirectories() + private static string GetSystemLanguageCodeAtStartup() { - foreach (var plugin in PluginManager.GetPluginsForInterface()) + 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; + } + + internal void AddPluginLanguageDirectories(IEnumerable plugins) + { + foreach (var plugin in plugins) { var location = Assembly.GetAssembly(plugin.Plugin.GetType()).Location; var dir = Path.GetDirectoryName(location); @@ -56,10 +83,15 @@ namespace Flow.Launcher.Core.Resource Log.Error($"|Internationalization.AddPluginLanguageDirectories|Can't find plugin path <{location}> for <{plugin.Metadata.Name}>"); } } + + LoadDefaultLanguage(); } private void LoadDefaultLanguage() { + // Removes language files loaded before any plugins were loaded. + // Prevents the language Flow started in from overwriting English if the user switches back to English + RemoveOldLanguageFiles(); LoadLanguage(AvailableLanguages.English); _oldResources.Clear(); } @@ -67,8 +99,18 @@ namespace Flow.Launcher.Core.Resource 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); + ChangeLanguage(language, isSystem); } private Language GetLanguageByLanguageCode(string languageCode) @@ -86,19 +128,22 @@ namespace Flow.Launcher.Core.Resource } } - public void ChangeLanguage(Language language) + private void ChangeLanguage(Language language, bool isSystem) { language = language.NonNull(); - RemoveOldLanguageFiles(); if (language != AvailableLanguages.English) { LoadLanguage(language); } - Settings.Language = language.LanguageCode; - CultureInfo.CurrentCulture = new CultureInfo(language.LanguageCode); + // 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 = isSystem ? Constant.SystemLanguageCode : language.LanguageCode; _ = Task.Run(() => { UpdatePluginMetadataTranslations(); @@ -109,7 +154,7 @@ namespace Flow.Launcher.Core.Resource { var languageToSet = GetLanguageByLanguageCode(languageCodeToSet); - if (Settings.ShouldUsePinyin) + if (_settings.ShouldUsePinyin) return false; if (languageToSet != AvailableLanguages.Chinese && languageToSet != AvailableLanguages.Chinese_TW) @@ -119,7 +164,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; @@ -136,11 +181,14 @@ namespace Flow.Launcher.Core.Resource private void LoadLanguage(Language language) { + var flowEnglishFile = Path.Combine(Constant.ProgramDirectory, Folder, DefaultFile); var dicts = Application.Current.Resources.MergedDictionaries; var filename = $"{language.LanguageCode}{Extension}"; var files = _languageDirectories .Select(d => LanguageFile(d, filename)) - .Where(f => !string.IsNullOrEmpty(f)) + // Exclude Flow's English language file since it's built into the binary, and there's no need to load + // it again from the file system. + .Where(f => !string.IsNullOrEmpty(f) && f != flowEnglishFile) .ToArray(); if (files.Length > 0) @@ -159,7 +207,9 @@ 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) diff --git a/Flow.Launcher.Core/Resource/InternationalizationManager.cs b/Flow.Launcher.Core/Resource/InternationalizationManager.cs index 3d87626e6..5d718466c 100644 --- a/Flow.Launcher.Core/Resource/InternationalizationManager.cs +++ b/Flow.Launcher.Core/Resource/InternationalizationManager.cs @@ -1,26 +1,12 @@ -namespace Flow.Launcher.Core.Resource +using System; +using CommunityToolkit.Mvvm.DependencyInjection; + +namespace Flow.Launcher.Core.Resource { + [Obsolete("InternationalizationManager.Instance is obsolete. Use Ioc.Default.GetRequiredService() instead.")] 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; - } - } + => Ioc.Default.GetRequiredService(); } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Core/Resource/LocalizationConverter.cs b/Flow.Launcher.Core/Resource/LocalizationConverter.cs index 1d835a831..81600e023 100644 --- a/Flow.Launcher.Core/Resource/LocalizationConverter.cs +++ b/Flow.Launcher.Core/Resource/LocalizationConverter.cs @@ -4,7 +4,7 @@ using System.Globalization; using System.Reflection; using System.Windows.Data; -namespace Flow.Launcher.Core +namespace Flow.Launcher.Core.Resource { public class LocalizationConverter : IValueConverter { diff --git a/Flow.Launcher.Core/Resource/LocalizedDescriptionAttribute.cs b/Flow.Launcher.Core/Resource/LocalizedDescriptionAttribute.cs index af8b23136..52a232334 100644 --- a/Flow.Launcher.Core/Resource/LocalizedDescriptionAttribute.cs +++ b/Flow.Launcher.Core/Resource/LocalizedDescriptionAttribute.cs @@ -1,7 +1,6 @@ using System.ComponentModel; -using Flow.Launcher.Core.Resource; -namespace Flow.Launcher.Core +namespace Flow.Launcher.Core.Resource { public class LocalizedDescriptionAttribute : DescriptionAttribute { diff --git a/Flow.Launcher.Core/Resource/Theme.cs b/Flow.Launcher.Core/Resource/Theme.cs index 872c4543e..cda125a39 100644 --- a/Flow.Launcher.Core/Resource/Theme.cs +++ b/Flow.Launcher.Core/Resource/Theme.cs @@ -2,41 +2,52 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Runtime.InteropServices; +using System.Xml; using System.Windows; using System.Windows.Controls; -using System.Windows.Interop; using System.Windows.Markup; using System.Windows.Media; using System.Windows.Media.Effects; +using System.Windows.Shell; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.Resource { public class Theme { + 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); + public string CurrentTheme => _settings.Theme; + public bool BlurEnabled { get; set; } private double mainWindowWidth; - public Theme() + public Theme(IPublicAPI publicAPI, Settings settings) { + _api = publicAPI; + _settings = settings; + _themeDirectories.Add(DirectoryPath); _themeDirectories.Add(UserDirectoryPath); - MakesureThemeDirectoriesExist(); + MakeSureThemeDirectoriesExist(); var dicts = Application.Current.Resources.MergedDictionaries; _oldResource = dicts.First(d => @@ -55,20 +66,17 @@ namespace Flow.Launcher.Core.Resource _oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath); } - private void MakesureThemeDirectoriesExist() + private void MakeSureThemeDirectoriesExist() { - foreach (string dir in _themeDirectories) + foreach (var dir in _themeDirectories.Where(dir => !Directory.Exists(dir))) { - if (!Directory.Exists(dir)) + try { - try - { - Directory.CreateDirectory(dir); - } - catch (Exception e) - { - Log.Exception($"|Theme.MakesureThemeDirectoriesExist|Exception when create directory <{dir}>", e); - } + Directory.CreateDirectory(dir); + } + catch (Exception e) + { + Log.Exception($"|Theme.MakesureThemeDirectoriesExist|Exception when create directory <{dir}>", e); } } } @@ -83,11 +91,12 @@ namespace Flow.Launcher.Core.Resource if (string.IsNullOrEmpty(path)) throw new DirectoryNotFoundException("Theme path can't be found <{path}>"); - Settings.Theme = theme; - // reload all resources even if the theme itself hasn't changed in order to pickup changes // to things like fonts - UpdateResourceDictionary(GetResourceDictionary()); + 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) @@ -95,19 +104,19 @@ namespace Flow.Launcher.Core.Resource _oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath); } - BlurEnabled = IsBlurTheme(); + BlurEnabled = Win32Helper.IsBlurTheme(); - if (Settings.UseDropShadowEffect && !BlurEnabled) + if (_settings.UseDropShadowEffect && !BlurEnabled) AddDropShadowEffectToCurrentTheme(); - SetBlurForWindow(); + Win32Helper.SetBlurForWindow(Application.Current.MainWindow, BlurEnabled); } 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)); + _api.ShowMsgBox(string.Format(InternationalizationManager.Instance.GetTranslation("theme_load_failure_path_not_exists"), theme)); ChangeTheme(defaultTheme); } return false; @@ -117,7 +126,7 @@ namespace Flow.Launcher.Core.Resource 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)); + _api.ShowMsgBox(string.Format(InternationalizationManager.Instance.GetTranslation("theme_load_failure_parse_error"), theme)); ChangeTheme(defaultTheme); } return false; @@ -134,9 +143,9 @@ namespace Flow.Launcher.Core.Resource _oldResource = dictionaryToUpdate; } - private ResourceDictionary CurrentThemeResourceDictionary() + private ResourceDictionary GetThemeResourceDictionary(string theme) { - var uri = GetThemePath(Settings.Theme); + var uri = GetThemePath(theme); var dict = new ResourceDictionary { Source = new Uri(uri, UriKind.Absolute) @@ -145,17 +154,19 @@ namespace Flow.Launcher.Core.Resource return dict; } - public ResourceDictionary GetResourceDictionary() + private ResourceDictionary CurrentThemeResourceDictionary() => GetThemeResourceDictionary(_settings.Theme); + + public ResourceDictionary GetResourceDictionary(string theme) { - var dict = CurrentThemeResourceDictionary(); - + 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)); @@ -176,41 +187,96 @@ namespace Flow.Launcher.Core.Resource } if (dict["ItemTitleStyle"] is Style resultItemStyle && - dict["ItemSubTitleStyle"] is Style resultSubItemStyle && - dict["ItemSubTitleSelectedStyle"] is Style resultSubItemSelectedStyle && dict["ItemTitleSelectedStyle"] is Style resultItemSelectedStyle && 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( - new[] { resultItemStyle, resultSubItemStyle, resultItemSelectedStyle, resultSubItemSelectedStyle, resultHotkeyItemStyle, resultHotkeyItemSelectedStyle }, o + new[] { resultItemStyle, resultItemSelectedStyle, resultHotkeyItemStyle, resultHotkeyItemSelectedStyle }, o => Array.ForEach(setters, p => o.Setters.Add(p))); } + + if ( + 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[] setters = { fontFamily, fontStyle, fontWeight, fontStretch }; + Array.ForEach( + 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; + var width = _settings.WindowSize; windowStyle.Setters.Add(new Setter(Window.WidthProperty, width)); mainWindowWidth = (double)width; return dict; } - public List LoadAvailableThemes() + private ResourceDictionary GetCurrentResourceDictionary( ) { - List themes = new List(); + return GetResourceDictionary(_settings.Theme); + } + + public List LoadAvailableThemes() + { + List themes = new List(); foreach (var themeDirectory in _themeDirectories) { - themes.AddRange( - Directory.GetFiles(themeDirectory) - .Where(filePath => filePath.EndsWith(Extension) && !filePath.EndsWith("Base.xaml")) - .ToList()); + var filePaths = Directory + .GetFiles(themeDirectory) + .Where(filePath => filePath.EndsWith(Extension) && !filePath.EndsWith("Base.xaml")) + .Select(GetThemeDataFromPath); + themes.AddRange(filePaths); } - return themes.OrderBy(o => o).ToList(); + + return themes.OrderBy(o => o.Name).ToList(); + } + + private ThemeData GetThemeDataFromPath(string path) + { + using var reader = XmlReader.Create(path); + reader.Read(); + + var extensionlessName = Path.GetFileNameWithoutExtension(path); + + if (reader.NodeType is not XmlNodeType.Comment) + return new ThemeData(extensionlessName, extensionlessName); + + var commentLines = reader.Value.Trim().Split('\n').Select(v => v.Trim()); + + var name = extensionlessName; + bool? isDark = null; + bool? hasBlur = null; + foreach (var line in commentLines) + { + if (line.StartsWith(ThemeMetadataNamePrefix, StringComparison.OrdinalIgnoreCase)) + { + name = line.Remove(0, ThemeMetadataNamePrefix.Length).Trim(); + } + else if (line.StartsWith(ThemeMetadataIsDarkPrefix, StringComparison.OrdinalIgnoreCase)) + { + isDark = bool.Parse(line.Remove(0, ThemeMetadataIsDarkPrefix.Length).Trim()); + } + else if (line.StartsWith(ThemeMetadataHasBlurPrefix, StringComparison.OrdinalIgnoreCase)) + { + hasBlur = bool.Parse(line.Remove(0, ThemeMetadataHasBlurPrefix.Length).Trim()); + } + } + + return new ThemeData(extensionlessName, name, isDark, hasBlur); } private string GetThemePath(string themeName) @@ -229,7 +295,7 @@ namespace Flow.Launcher.Core.Resource public void AddDropShadowEffectToCurrentTheme() { - var dict = GetResourceDictionary(); + var dict = GetCurrentResourceDictionary(); var windowBorderStyle = dict["WindowBorderStyle"] as Style; @@ -248,12 +314,15 @@ namespace Flow.Launcher.Core.Resource var marginSetter = windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == Border.MarginProperty) as Setter; if (marginSetter == null) { + var margin = new Thickness(ShadowExtraMargin, 12, ShadowExtraMargin, ShadowExtraMargin); marginSetter = new Setter() { Property = Border.MarginProperty, - Value = new Thickness(ShadowExtraMargin, 12, ShadowExtraMargin, ShadowExtraMargin), + Value = margin, }; windowBorderStyle.Setters.Add(marginSetter); + + SetResizeBoarderThickness(margin); } else { @@ -264,6 +333,8 @@ namespace Flow.Launcher.Core.Resource baseMargin.Right + ShadowExtraMargin, baseMargin.Bottom + ShadowExtraMargin); marginSetter.Value = newMargin; + + SetResizeBoarderThickness(newMargin); } windowBorderStyle.Setters.Add(effectSetter); @@ -273,7 +344,7 @@ namespace Flow.Launcher.Core.Resource public void RemoveDropShadowEffectFromCurrentTheme() { - var dict = CurrentThemeResourceDictionary(); + 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; @@ -294,99 +365,36 @@ namespace Flow.Launcher.Core.Resource marginSetter.Value = newMargin; } + SetResizeBoarderThickness(null); + UpdateResourceDictionary(dict); } - #region Blur Handling - /* - Found on https://github.com/riverar/sample-win10-aeroglass - */ - private enum AccentState + // 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 static void SetResizeBoarderThickness(Thickness? effectMargin) { - 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 - { - public AccentState AccentState; - public int AccentFlags; - public int GradientColor; - public int AnimationId; - } - - [StructLayout(LayoutKind.Sequential)] - private struct WindowCompositionAttributeData - { - public WindowCompositionAttribute Attribute; - public IntPtr Data; - public int SizeOfData; - } - - private enum WindowCompositionAttribute - { - WCA_ACCENT_POLICY = 19 - } - [DllImport("user32.dll")] - private static extern int SetWindowCompositionAttribute(IntPtr hwnd, ref WindowCompositionAttributeData data); - - /// - /// Sets the blur for a window via SetWindowCompositionAttribute - /// - public void SetBlurForWindow() - { - if (BlurEnabled) + var window = Application.Current.MainWindow; + if (WindowChrome.GetWindowChrome(window) is WindowChrome windowChrome) { - SetWindowAccent(Application.Current.MainWindow, AccentState.ACCENT_ENABLE_BLURBEHIND); - } - else - { - SetWindowAccent(Application.Current.MainWindow, AccentState.ACCENT_DISABLED); + Thickness thickness; + if (effectMargin == null) + { + thickness = SystemParameters.WindowResizeBorderThickness; + } + else + { + thickness = 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); + } + + windowChrome.ResizeBorderThickness = thickness; } } - private bool IsBlurTheme() - { - if (Environment.OSVersion.Version >= new Version(6, 2)) - { - var resource = Application.Current.TryFindResource("ThemeBlurEnabled"); - - if (resource is bool) - return (bool)resource; - - return false; - } - - return false; - } - - 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 index 71f9acaa5..3cbe8319a 100644 --- a/Flow.Launcher.Core/Resource/ThemeManager.cs +++ b/Flow.Launcher.Core/Resource/ThemeManager.cs @@ -1,26 +1,12 @@ -namespace Flow.Launcher.Core.Resource +using System; +using CommunityToolkit.Mvvm.DependencyInjection; + +namespace Flow.Launcher.Core.Resource { + [Obsolete("ThemeManager.Instance is obsolete. Use Ioc.Default.GetRequiredService() instead.")] 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; - } - } + => Ioc.Default.GetRequiredService(); } } diff --git a/Flow.Launcher/Converters/TranslationConverter.cs b/Flow.Launcher.Core/Resource/TranslationConverter.cs similarity index 81% rename from Flow.Launcher/Converters/TranslationConverter.cs rename to Flow.Launcher.Core/Resource/TranslationConverter.cs index e1e8a58e3..ebab99e5b 100644 --- a/Flow.Launcher/Converters/TranslationConverter.cs +++ b/Flow.Launcher.Core/Resource/TranslationConverter.cs @@ -1,11 +1,10 @@ using System; using System.Globalization; using System.Windows.Data; -using Flow.Launcher.Core.Resource; -namespace Flow.Launcher.Converters +namespace Flow.Launcher.Core.Resource { - public class TranlationConverter : IValueConverter + public class TranslationConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { diff --git a/Flow.Launcher.Core/Updater.cs b/Flow.Launcher.Core/Updater.cs index bad0344eb..9a77ece32 100644 --- a/Flow.Launcher.Core/Updater.cs +++ b/Flow.Launcher.Core/Updater.cs @@ -22,23 +22,26 @@ namespace Flow.Launcher.Core { public class Updater { - public string GitHubRepository { get; } + public string GitHubRepository { get; init; } - public Updater(string gitHubRepository) + 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(); + 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); @@ -53,13 +56,13 @@ namespace Flow.Launcher.Core 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); @@ -68,9 +71,9 @@ 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"), + 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)); } @@ -79,21 +82,25 @@ namespace Flow.Launcher.Core await updateManager.CreateUninstallerRegistryEntry().ConfigureAwait(false); } - var newVersionTips = NewVersinoTips(newReleaseVersion.ToString()); + var newVersionTips = NewVersionTips(newReleaseVersion.ToString()); Log.Info($"|Updater.UpdateApp|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) when (e is HttpRequestException or WebException or SocketException || e.InnerException is TimeoutException) + catch (Exception e) { - 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)) + Log.Exception($"|Updater.UpdateApp|Check your connection and proxy settings to github-cloud.s3.amazonaws.com.", e); + else + Log.Exception($"|Updater.UpdateApp|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 { @@ -114,7 +121,7 @@ namespace Flow.Launcher.Core public string HtmlUrl { get; [UsedImplicitly] set; } } - /// https://github.com/Squirrel/Squirrel.Windows/blob/master/src/Squirrel/UpdateManager.Factory.cs + // https://github.com/Squirrel/Squirrel.Windows/blob/master/src/Squirrel/UpdateManager.Factory.cs private async Task GitHubUpdateManagerAsync(string repository) { var uri = new Uri(repository); @@ -137,13 +144,12 @@ namespace Flow.Launcher.Core return manager; } - public string NewVersinoTips(string version) + public string NewVersionTips(string version) { - var translater = InternationalizationManager.Instance; - var tips = string.Format(translater.GetTranslation("newVersionTips"), version); + var translator = InternationalizationManager.Instance; + var tips = string.Format(translator.GetTranslation("newVersionTips"), version); return tips; } - } } diff --git a/Flow.Launcher.CrashReporter/Flow.Launcher.CrashReporter.csproj b/Flow.Launcher.CrashReporter/Flow.Launcher.CrashReporter.csproj deleted file mode 100644 index 1e0a3fe52..000000000 --- a/Flow.Launcher.CrashReporter/Flow.Launcher.CrashReporter.csproj +++ /dev/null @@ -1,115 +0,0 @@ - - - - - Debug - AnyCPU - {2FEB2298-7653-4009-B1EA-FFFB1A768BCC} - Library - Properties - Flow.Launcher.CrashReporter - Flow.Launcher.CrashReporter - v4.5.2 - 512 - ..\ - - - - true - full - false - ..\Output\Debug\ - DEBUG;TRACE - prompt - 4 - false - - - pdbonly - true - ..\Output\Release\ - TRACE - prompt - 4 - false - - - - ..\packages\Exceptionless.1.5.2121\lib\net45\Exceptionless.dll - True - - - ..\packages\Exceptionless.1.5.2121\lib\net45\Exceptionless.Models.dll - True - - - ..\packages\JetBrains.Annotations.10.1.4\lib\net20\JetBrains.Annotations.dll - True - - - - - - - - - - - - - - - Properties\SolutionAssemblyInfo.cs - - - - - ReportWindow.xaml - - - - - Designer - MSBuild:Compile - - - - - {B749F0DB-8E75-47DB-9E5E-265D16D0C0D2} - Flow.Launcher.Core - - - {4fd29318-a8ab-4d8f-aa47-60bc241b8da3} - Flow.Launcher.Infrastructure - - - - - PreserveNewest - - - PreserveNewest - - - - - PreserveNewest - - - - - PreserveNewest - - - - - - - - \ No newline at end of file diff --git a/Flow.Launcher.CrashReporter/Images/app_error.png b/Flow.Launcher.CrashReporter/Images/app_error.png deleted file mode 100644 index 70599f504..000000000 Binary files a/Flow.Launcher.CrashReporter/Images/app_error.png and /dev/null differ diff --git a/Flow.Launcher.CrashReporter/Images/crash_go.png b/Flow.Launcher.CrashReporter/Images/crash_go.png deleted file mode 100644 index 2dd2c45f2..000000000 Binary files a/Flow.Launcher.CrashReporter/Images/crash_go.png and /dev/null differ diff --git a/Flow.Launcher.CrashReporter/Images/crash_stop.png b/Flow.Launcher.CrashReporter/Images/crash_stop.png deleted file mode 100644 index 022fbc197..000000000 Binary files a/Flow.Launcher.CrashReporter/Images/crash_stop.png and /dev/null differ diff --git a/Flow.Launcher.CrashReporter/Images/crash_warning.png b/Flow.Launcher.CrashReporter/Images/crash_warning.png deleted file mode 100644 index 8d29625ee..000000000 Binary files a/Flow.Launcher.CrashReporter/Images/crash_warning.png and /dev/null differ diff --git a/Flow.Launcher.CrashReporter/Properties/AssemblyInfo.cs b/Flow.Launcher.CrashReporter/Properties/AssemblyInfo.cs deleted file mode 100644 index acf2adca1..000000000 --- a/Flow.Launcher.CrashReporter/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,5 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("Flow.Launcher.CrashReporter")] -[assembly: Guid("0ea3743c-2c0d-4b13-b9ce-e5e1f85aea23")] \ No newline at end of file diff --git a/Flow.Launcher.CrashReporter/packages.config b/Flow.Launcher.CrashReporter/packages.config deleted file mode 100644 index ddad0d3b3..000000000 --- a/Flow.Launcher.CrashReporter/packages.config +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Flow.Launcher.Infrastructure/Constant.cs b/Flow.Launcher.Infrastructure/Constant.cs index 5ace46376..c86ed4324 100644 --- a/Flow.Launcher.Infrastructure/Constant.cs +++ b/Flow.Launcher.Infrastructure/Constant.cs @@ -7,6 +7,7 @@ namespace Flow.Launcher.Infrastructure public static class Constant { public const string FlowLauncher = "Flow.Launcher"; + public const string FlowLauncherFullName = "Flow Launcher"; public const string Plugins = "Plugins"; public const string PluginMetadataFileName = "plugin.json"; @@ -19,7 +20,7 @@ namespace Flow.Launcher.Infrastructure public static readonly string RootDirectory = Directory.GetParent(ApplicationDirectory).ToString(); public static readonly string PreinstalledDirectory = Path.Combine(ProgramDirectory, Plugins); - public const string Issue = "https://github.com/Flow-Launcher/Flow.Launcher/issues/new"; + public const string IssuesUrl = "https://github.com/Flow-Launcher/Flow.Launcher/issues"; public static readonly string Version = FileVersionInfo.GetVersionInfo(Assembly.Location.NonNull()).ProductVersion; public static readonly string Dev = "Dev"; public const string Documentation = "https://flowlauncher.com/docs/#/usage-tips"; @@ -29,8 +30,11 @@ namespace Flow.Launcher.Infrastructure public static readonly string DefaultIcon = Path.Combine(ImagesDirectory, "app.png"); 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 string PythonPath; + public static string NodePath; public static readonly string QueryTextBoxIconImagePath = $"{ProgramDirectory}\\Images\\mainsearch.svg"; @@ -45,7 +49,10 @@ namespace Flow.Launcher.Infrastructure public const string Logs = "Logs"; 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/Exception/ExceptionFormatter.cs b/Flow.Launcher.Infrastructure/Exception/ExceptionFormatter.cs index 40ac6b121..025109e58 100644 --- a/Flow.Launcher.Infrastructure/Exception/ExceptionFormatter.cs +++ b/Flow.Launcher.Infrastructure/Exception/ExceptionFormatter.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; -using System.Xml; using Microsoft.Win32; namespace Flow.Launcher.Infrastructure.Exception @@ -63,10 +62,11 @@ namespace Flow.Launcher.Infrastructure.Exception sb.AppendLine($"* Command Line: {Environment.CommandLine}"); sb.AppendLine($"* Timestamp: {DateTime.Now.ToString(CultureInfo.InvariantCulture)}"); sb.AppendLine($"* Flow Launcher version: {Constant.Version}"); - sb.AppendLine($"* OS Version: {Environment.OSVersion.VersionString}"); + sb.AppendLine($"* OS Version: {GetWindowsFullVersionFromRegistry()}"); sb.AppendLine($"* IntPtr Length: {IntPtr.Size}"); sb.AppendLine($"* x64: {Environment.Is64BitOperatingSystem}"); sb.AppendLine($"* Python Path: {Constant.PythonPath}"); + sb.AppendLine($"* Node Path: {Constant.NodePath}"); sb.AppendLine($"* CLR Version: {Environment.Version}"); sb.AppendLine($"* Installed .NET Framework: "); foreach (var result in GetFrameworkVersionFromRegistry()) @@ -172,5 +172,35 @@ namespace Flow.Launcher.Infrastructure.Exception } } + + public static string GetWindowsFullVersionFromRegistry() + { + try + { + var buildRevision = GetWindowsRevisionFromRegistry(); + var currentBuild = Environment.OSVersion.Version.Build; + return currentBuild.ToString() + "." + buildRevision; + } + catch + { + return Environment.OSVersion.VersionString; + } + } + + public static string GetWindowsRevisionFromRegistry() + { + try + { + using (RegistryKey registryKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion\")) + { + var buildRevision = registryKey.GetValue("UBR").ToString(); + return buildRevision; + } + } + catch + { + return "0"; + } + } } } diff --git a/Flow.Launcher.Infrastructure/FileExplorerHelper.cs b/Flow.Launcher.Infrastructure/FileExplorerHelper.cs new file mode 100644 index 000000000..b97c096c3 --- /dev/null +++ b/Flow.Launcher.Infrastructure/FileExplorerHelper.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Windows.Win32; + +namespace Flow.Launcher.Infrastructure +{ + public static class FileExplorerHelper + { + /// + /// Gets the path of the file explorer that is currently in the foreground + /// + public static string GetActiveExplorerPath() + { + var explorerWindow = GetActiveExplorer(); + string locationUrl = explorerWindow?.LocationURL; + 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; + } + + /// + /// Gets the file explorer that is currently in the foreground + /// + private static dynamic GetActiveExplorer() + { + Type type = Type.GetTypeFromProgID("Shell.Application"); + if (type == null) return null; + dynamic shell = Activator.CreateInstance(type); + if (shell == null) + { + return null; + } + + var explorerWindows = new List(); + var openWindows = shell.Windows(); + for (int i = 0; i < openWindows.Count; i++) + { + var window = openWindows.Item(i); + if (window == null) continue; + + // find the desired window and make sure that it is indeed a file explorer + // we don't want the Internet Explorer or the classic control panel + // ToLower() is needed, because Windows can report the path as "C:\\Windows\\Explorer.EXE" + if (Path.GetFileName((string)window.FullName)?.ToLower() == "explorer.exe") + { + explorerWindows.Add(window); + } + } + + if (explorerWindows.Count == 0) return null; + + var zOrders = GetZOrder(explorerWindows); + + return explorerWindows.Zip(zOrders).MinBy(x => x.Second).First; + } + + 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. + /// + private static IEnumerable GetZOrder(List hWnds) + { + var z = new int[hWnds.Count]; + for (var i = 0; i < hWnds.Count; i++) z[i] = -1; + + var index = 0; + var numRemaining = hWnds.Count; + PInvoke.EnumWindows((wnd, _) => + { + var searchIndex = hWnds.FindIndex(x => new IntPtr(x.HWND) == wnd); + if (searchIndex != -1) + { + z[searchIndex] = index; + numRemaining--; + if (numRemaining == 0) return false; + } + index++; + return true; + }, IntPtr.Zero); + + return z; + } + } +} diff --git a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj index 5a9122e01..b91da7114 100644 --- a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj +++ b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj @@ -1,7 +1,7 @@ - + - net6.0-windows + net7.0-windows {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3} Library true @@ -35,6 +35,10 @@ false + + + + @@ -49,16 +53,21 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - - - + diff --git a/Flow.Launcher.Infrastructure/Helper.cs b/Flow.Launcher.Infrastructure/Helper.cs index faa4c93b5..864d796c7 100644 --- a/Flow.Launcher.Infrastructure/Helper.cs +++ b/Flow.Launcher.Infrastructure/Helper.cs @@ -1,6 +1,7 @@ -using System; +#nullable enable + +using System; using System.IO; -using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; @@ -16,7 +17,7 @@ namespace Flow.Launcher.Infrastructure /// /// http://www.yinwang.org/blog-cn/2015/11/21/programming-philosophy /// - public static T NonNull(this T obj) + public static T NonNull(this T? obj) { if (obj == null) { diff --git a/Flow.Launcher.Infrastructure/Hotkey/GlobalHotkey.cs b/Flow.Launcher.Infrastructure/Hotkey/GlobalHotkey.cs index a09185696..b2a140755 100644 --- a/Flow.Launcher.Infrastructure/Hotkey/GlobalHotkey.cs +++ b/Flow.Launcher.Infrastructure/Hotkey/GlobalHotkey.cs @@ -1,7 +1,11 @@ -using System; -using System.Runtime.CompilerServices; +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 { @@ -11,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; @@ -57,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() @@ -91,4 +96,4 @@ namespace Flow.Launcher.Infrastructure.Hotkey Dispose(); } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Infrastructure/Hotkey/HotkeyModel.cs b/Flow.Launcher.Infrastructure/Hotkey/HotkeyModel.cs index 5bd97714c..25bc75a56 100644 --- a/Flow.Launcher.Infrastructure/Hotkey/HotkeyModel.cs +++ b/Flow.Launcher.Infrastructure/Hotkey/HotkeyModel.cs @@ -1,23 +1,23 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Windows.Input; namespace Flow.Launcher.Infrastructure.Hotkey { - public class HotkeyModel + public record struct HotkeyModel { public bool Alt { get; set; } public bool Shift { get; set; } public bool Win { get; set; } public bool Ctrl { get; set; } - public Key CharKey { get; set; } + public Key CharKey { get; set; } = Key.None; - Dictionary specialSymbolDictionary = new Dictionary + private static readonly Dictionary specialSymbolDictionary = new Dictionary { - {Key.Space, "Space"}, - {Key.Oem3, "~"} + { Key.Space, "Space" }, { Key.Oem3, "~" } }; public ModifierKeys ModifierKeys @@ -27,20 +27,24 @@ namespace Flow.Launcher.Infrastructure.Hotkey ModifierKeys modifierKeys = ModifierKeys.None; if (Alt) { - modifierKeys = ModifierKeys.Alt; + modifierKeys |= ModifierKeys.Alt; } + if (Shift) { - modifierKeys = modifierKeys | ModifierKeys.Shift; + modifierKeys |= ModifierKeys.Shift; } + if (Win) { - modifierKeys = modifierKeys | ModifierKeys.Windows; + modifierKeys |= ModifierKeys.Windows; } + if (Ctrl) { - modifierKeys = modifierKeys | ModifierKeys.Control; + modifierKeys |= ModifierKeys.Control; } + return modifierKeys; } } @@ -65,31 +69,37 @@ namespace Flow.Launcher.Infrastructure.Hotkey { return; } + List keys = hotkeyString.Replace(" ", "").Split('+').ToList(); if (keys.Contains("Alt")) { Alt = true; keys.Remove("Alt"); } + if (keys.Contains("Shift")) { Shift = true; keys.Remove("Shift"); } + if (keys.Contains("Win")) { Win = true; keys.Remove("Win"); } + if (keys.Contains("Ctrl")) { Ctrl = true; keys.Remove("Ctrl"); } - if (keys.Count > 0) + + if (keys.Count == 1) { string charKey = keys[0]; - KeyValuePair? specialSymbolPair = specialSymbolDictionary.FirstOrDefault(pair => pair.Value == charKey); + KeyValuePair? specialSymbolPair = + specialSymbolDictionary.FirstOrDefault(pair => pair.Value == charKey); if (specialSymbolPair.Value.Value != null) { CharKey = specialSymbolPair.Value.Key; @@ -98,11 +108,10 @@ namespace Flow.Launcher.Infrastructure.Hotkey { try { - CharKey = (Key) Enum.Parse(typeof (Key), charKey); + CharKey = (Key)Enum.Parse(typeof(Key), charKey); } catch (ArgumentException) { - } } } @@ -110,36 +119,112 @@ namespace Flow.Launcher.Infrastructure.Hotkey public override string ToString() { - string text = string.Empty; - if (Ctrl) + return string.Join(" + ", EnumerateDisplayKeys()); + } + + public IEnumerable EnumerateDisplayKeys() + { + if (Ctrl && CharKey is not (Key.LeftCtrl or Key.RightCtrl)) { - text += "Ctrl + "; + yield return "Ctrl"; } - if (Alt) + + if (Alt && CharKey is not (Key.LeftAlt or Key.RightAlt)) { - text += "Alt + "; + yield return "Alt"; } - if (Shift) + + if (Shift && CharKey is not (Key.LeftShift or Key.RightShift)) { - text += "Shift + "; + yield return "Shift"; } - if (Win) + + if (Win && CharKey is not (Key.LWin or Key.RWin)) { - text += "Win + "; + yield return "Win"; } if (CharKey != Key.None) { - text += specialSymbolDictionary.ContainsKey(CharKey) - ? specialSymbolDictionary[CharKey] + yield return specialSymbolDictionary.TryGetValue(CharKey, out var value) + ? value : CharKey.ToString(); } - else if (!string.IsNullOrEmpty(text)) - { - text = text.Remove(text.Length - 3); - } + } - return text; + /// + /// Validate hotkey + /// + /// Try to validate hotkey as a KeyGesture. + /// + public bool Validate(bool validateKeyGestrue = false) + { + switch (CharKey) + { + case Key.LeftAlt: + case Key.RightAlt: + case Key.LeftCtrl: + case Key.RightCtrl: + case Key.LeftShift: + case Key.RightShift: + case Key.LWin: + case Key.RWin: + case Key.None: + return false; + default: + if (validateKeyGestrue) + { + try + { + KeyGesture keyGesture = new KeyGesture(CharKey, ModifierKeys); + } + catch (System.Exception e) when + (e is NotSupportedException || e is InvalidEnumArgumentException) + { + return false; + } + } + + if (ModifierKeys == ModifierKeys.None) + { + return !IsPrintableCharacter(CharKey); + } + else + { + return true; + } + } + } + + private static bool IsPrintableCharacter(Key key) + { + // https://stackoverflow.com/questions/11881199/identify-if-a-event-key-is-text-not-only-alphanumeric + return (key >= Key.A && key <= Key.Z) || + (key >= Key.D0 && key <= Key.D9) || + (key >= Key.NumPad0 && key <= Key.NumPad9) || + key == Key.OemQuestion || + key == Key.OemQuotes || + key == Key.OemPlus || + key == Key.OemOpenBrackets || + key == Key.OemCloseBrackets || + key == Key.OemMinus || + key == Key.DeadCharProcessed || + key == Key.Oem1 || + key == Key.Oem7 || + key == Key.OemPeriod || + key == Key.OemComma || + key == Key.OemMinus || + key == Key.Add || + key == Key.Divide || + key == Key.Multiply || + key == Key.Subtract || + key == Key.Oem102 || + key == Key.Decimal; + } + + public override int GetHashCode() + { + return HashCode.Combine(ModifierKeys, CharKey); } } } diff --git a/Flow.Launcher.Infrastructure/Hotkey/IHotkeySettings.cs b/Flow.Launcher.Infrastructure/Hotkey/IHotkeySettings.cs new file mode 100644 index 000000000..448a70d19 --- /dev/null +++ b/Flow.Launcher.Infrastructure/Hotkey/IHotkeySettings.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace Flow.Launcher.Infrastructure.Hotkey; + +/// +/// Interface that you should implement in your settings class to be able to pass it to +/// Flow.Launcher.HotkeyControlDialog. It allows the dialog to display the hotkeys that have already been +/// registered, and optionally provide a way to unregister them. +/// +public interface IHotkeySettings +{ + /// + /// A list of hotkeys that have already been registered. The dialog will display these hotkeys and provide a way to + /// unregister them. + /// + public List RegisteredHotkeys { get; } +} 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 index 15e306883..95bb25837 100644 --- a/Flow.Launcher.Infrastructure/Hotkey/KeyEvent.cs +++ b/Flow.Launcher.Infrastructure/Hotkey/KeyEvent.cs @@ -1,3 +1,5 @@ +using Windows.Win32; + namespace Flow.Launcher.Infrastructure.Hotkey { public enum KeyEvent @@ -5,21 +7,21 @@ namespace Flow.Launcher.Infrastructure.Hotkey /// /// Key down /// - WM_KEYDOWN = 256, + WM_KEYDOWN = (int)PInvoke.WM_KEYDOWN, /// /// Key up /// - WM_KEYUP = 257, + WM_KEYUP = (int)PInvoke.WM_KEYUP, /// /// System key up /// - WM_SYSKEYUP = 261, + WM_SYSKEYUP = (int)PInvoke.WM_SYSKEYUP, /// /// System key down /// - WM_SYSKEYDOWN = 260 + WM_SYSKEYDOWN = (int)PInvoke.WM_SYSKEYDOWN } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Infrastructure/Hotkey/RegisteredHotkeyData.cs b/Flow.Launcher.Infrastructure/Hotkey/RegisteredHotkeyData.cs new file mode 100644 index 000000000..b6a10fc30 --- /dev/null +++ b/Flow.Launcher.Infrastructure/Hotkey/RegisteredHotkeyData.cs @@ -0,0 +1,119 @@ +using System; + +namespace Flow.Launcher.Infrastructure.Hotkey; + +#nullable enable + +/// +/// Represents a hotkey that has been registered. Used in Flow.Launcher.HotkeyControlDialog via +/// and to display errors if user tries to register a hotkey +/// that has already been registered, and optionally provides a way to unregister the hotkey. +/// +public record RegisteredHotkeyData +{ + /// + /// representation of this hotkey. + /// + public HotkeyModel Hotkey { get; } + + /// + /// String key in the localization dictionary that represents this hotkey. For example, ReloadPluginHotkey, + /// which represents the string "Reload Plugins Data" in en.xaml + /// + public string DescriptionResourceKey { get; } + + /// + /// Array of values that will replace {0}, {1}, {2}, etc. in the localized string found via + /// . + /// + public object?[] DescriptionFormatVariables { get; } = Array.Empty(); + + /// + /// An action that, when called, will unregister this hotkey. If it's null, it's assumed that + /// this hotkey can't be unregistered, and the "Overwrite" option will not appear in the hotkey dialog. + /// + public Action? RemoveHotkey { get; } + + /// + /// Creates an instance of RegisteredHotkeyData. Assumes that the key specified in + /// descriptionResourceKey doesn't need any arguments for string.Format. If it does, + /// use one of the other constructors. + /// + /// + /// The hotkey this class will represent. + /// Example values: F1, Ctrl+Shift+Enter + /// + /// + /// The key in the localization dictionary that represents this hotkey. For example, ReloadPluginHotkey, + /// which represents the string "Reload Plugins Data" in en.xaml + /// + /// + /// An action that, when called, will unregister this hotkey. If it's null, it's assumed that this hotkey + /// can't be unregistered, and the "Overwrite" option will not appear in the hotkey dialog. + /// + public RegisteredHotkeyData(string hotkey, string descriptionResourceKey, Action? removeHotkey = null) + { + Hotkey = new HotkeyModel(hotkey); + DescriptionResourceKey = descriptionResourceKey; + RemoveHotkey = removeHotkey; + } + + /// + /// Creates an instance of RegisteredHotkeyData. Assumes that the key specified in + /// descriptionResourceKey needs exactly one argument for string.Format. + /// + /// + /// The hotkey this class will represent. + /// Example values: F1, Ctrl+Shift+Enter + /// + /// + /// The key in the localization dictionary that represents this hotkey. For example, ReloadPluginHotkey, + /// which represents the string "Reload Plugins Data" in en.xaml + /// + /// + /// The value that will replace {0} in the localized string found via description. + /// + /// + /// An action that, when called, will unregister this hotkey. If it's null, it's assumed that this hotkey + /// can't be unregistered, and the "Overwrite" option will not appear in the hotkey dialog. + /// + public RegisteredHotkeyData( + string hotkey, string descriptionResourceKey, object? descriptionFormatVariable, Action? removeHotkey = null + ) + { + Hotkey = new HotkeyModel(hotkey); + DescriptionResourceKey = descriptionResourceKey; + DescriptionFormatVariables = new[] { descriptionFormatVariable }; + RemoveHotkey = removeHotkey; + } + + /// + /// Creates an instance of RegisteredHotkeyData. Assumes that the key specified in + /// needs multiple arguments for string.Format. + /// + /// + /// The hotkey this class will represent. + /// Example values: F1, Ctrl+Shift+Enter + /// + /// + /// The key in the localization dictionary that represents this hotkey. For example, ReloadPluginHotkey, + /// which represents the string "Reload Plugins Data" in en.xaml + /// + /// + /// Array of values that will replace {0}, {1}, {2}, etc. + /// in the localized string found via description. + /// + /// + /// An action that, when called, will unregister this hotkey. If it's null, it's assumed that this hotkey + /// can't be unregistered, and the "Overwrite" option will not appear in the hotkey dialog. + /// + public RegisteredHotkeyData( + string hotkey, string descriptionResourceKey, object?[] descriptionFormatVariables, Action? removeHotkey = null + ) + { + Hotkey = new HotkeyModel(hotkey); + DescriptionResourceKey = descriptionResourceKey; + DescriptionFormatVariables = descriptionFormatVariables; + RemoveHotkey = removeHotkey; + } +} diff --git a/Flow.Launcher.Infrastructure/Http/Http.cs b/Flow.Launcher.Infrastructure/Http/Http.cs index 9f4146b7b..030aff7cf 100644 --- a/Flow.Launcher.Infrastructure/Http/Http.cs +++ b/Flow.Launcher.Infrastructure/Http/Http.cs @@ -1,16 +1,14 @@ using System.IO; using System.Net; using System.Net.Http; -using System.Text; using System.Threading.Tasks; using JetBrains.Annotations; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; using System; -using System.ComponentModel; using System.Threading; -using System.Windows.Interop; using Flow.Launcher.Plugin; +using CommunityToolkit.Mvvm.DependencyInjection; namespace Flow.Launcher.Infrastructure.Http { @@ -20,8 +18,6 @@ namespace Flow.Launcher.Infrastructure.Http private static HttpClient client = new HttpClient(); - public static IPublicAPI API { get; set; } - static Http() { // need to be added so it would work on a win10 machine @@ -68,7 +64,7 @@ namespace Flow.Launcher.Infrastructure.Http var userName when string.IsNullOrEmpty(userName) => (new Uri($"http://{Proxy.Server}:{Proxy.Port}"), null), _ => (new Uri($"http://{Proxy.Server}:{Proxy.Port}"), - new NetworkCredential(Proxy.UserName, Proxy.Password)) + new NetworkCredential(Proxy.UserName, Proxy.Password)) }, _ => (null, null) }, @@ -79,22 +75,57 @@ namespace Flow.Launcher.Infrastructure.Http _ => throw new ArgumentOutOfRangeException() }; } - catch(UriFormatException e) + catch (UriFormatException e) { - API.ShowMsg("Please try again", "Unable to parse Http Proxy"); + Ioc.Default.GetRequiredService().ShowMsg("Please try again", "Unable to parse Http Proxy"); Log.Exception("Flow.Launcher.Infrastructure.Http", "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); + 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 { @@ -117,7 +148,7 @@ namespace Flow.Launcher.Infrastructure.Http public static Task GetAsync([NotNull] string url, CancellationToken token = default) { Log.Debug($"|Http.Get|Url <{url}>"); - return GetAsync(new Uri(url.Replace("#", "%23")), token); + return GetAsync(new Uri(url), token); } /// @@ -130,36 +161,57 @@ namespace Flow.Launcher.Infrastructure.Http { Log.Debug($"|Http.Get|Url <{url}>"); using var response = await client.GetAsync(url, token); - var content = await response.Content.ReadAsStringAsync(); - if (response.StatusCode == HttpStatusCode.OK) - { - return content; - } - else + var content = await response.Content.ReadAsStringAsync(token); + if (response.StatusCode != HttpStatusCode.OK) { throw new HttpRequestException( $"Error code <{response.StatusCode}> with content <{content}> returned from <{url}>"); } + + return content; } /// - /// Asynchrously get the result as stream from url. + /// Send a GET request to the specified Uri with an HTTP completion option and a cancellation token as an asynchronous operation. + /// + /// The Uri the request is sent to. + /// An HTTP completion option value that indicates when the operation should be considered completed. + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation + /// + 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. /// /// + /// /// - public static async Task GetStreamAsync([NotNull] string url, CancellationToken token = default) + public static async Task GetStreamAsync([NotNull] Uri url, + CancellationToken token = default) { Log.Debug($"|Http.Get|Url <{url}>"); - var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token); - return await response.Content.ReadAsStreamAsync(); + return await client.GetStreamAsync(url, token); + } + + public static async Task GetResponseAsync(string url, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken token = default) + => await GetResponseAsync(new Uri(url), completionOption, token); + + public static async Task GetResponseAsync([NotNull] Uri url, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken token = default) + { + Log.Debug($"|Http.Get|Url <{url}>"); + return await client.GetAsync(url, completionOption, token); } /// /// Asynchrously send an HTTP request. /// - public static async Task SendAsync(HttpRequestMessage request, CancellationToken token = default) + public static async Task SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken token = default) { - return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); + return await client.SendAsync(request, completionOption, token); } } } diff --git a/Flow.Launcher.Infrastructure/Image/ImageCache.cs b/Flow.Launcher.Infrastructure/Image/ImageCache.cs index 04e11bf1a..ddbab4ef0 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageCache.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageCache.cs @@ -1,95 +1,68 @@ -using System; +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; namespace Flow.Launcher.Infrastructure.Image { - [Serializable] - public class ImageUsage - { - - public int usage; - public ImageSource imageSource; - - public ImageUsage(int usage, ImageSource image) - { - this.usage = usage; - imageSource = image; - } - } - public class ImageCache { - private const int MaxCached = 50; - public ConcurrentDictionary Data { get; private set; } = new ConcurrentDictionary(); - private const int permissibleFactor = 2; - private SemaphoreSlim semaphore = new(1, 1); + private const int MaxCached = 150; - public void Initialization(Dictionary usage) + private ConcurrentLfu<(string, bool), ImageSource> CacheManager { get; set; } = new(MaxCached); + + public void Initialize(IEnumerable<(string, bool)> usage) { - foreach (var key in usage.Keys) + foreach (var key in usage) { - Data[key] = new ImageUsage(usage[key], null); + CacheManager.AddOrUpdate(key, null); } } - public ImageSource this[string path] + public ImageSource this[string path, bool isFullImage = false] { get { - if (Data.TryGetValue(path, out var value)) - { - value.usage++; - return value.imageSource; - } - - return null; + return CacheManager.TryGet((path, isFullImage), out var value) ? value : null; } set { - Data.AddOrUpdate( - path, - new ImageUsage(0, value), - (k, v) => - { - v.imageSource = value; - v.usage++; - return v; - } - ); - - SliceExtra(); - - async void SliceExtra() - { - // To prevent the dictionary from drastically increasing in size by caching images, the dictionary size is not allowed to grow more than the permissibleFactor * maxCached size - // This is done so that we don't constantly perform this resizing operation and also maintain the image cache size at the same time - if (Data.Count > permissibleFactor * MaxCached) - { - await semaphore.WaitAsync().ConfigureAwait(false); - // To delete the images from the data dictionary based on the resizing of the Usage Dictionary - // Double Check to avoid concurrent remove - if (Data.Count > permissibleFactor * MaxCached) - foreach (var key in Data.OrderBy(x => x.Value.usage).Take(Data.Count - MaxCached).Select(x => x.Key)) - Data.TryRemove(key, out _); - semaphore.Release(); - } - } + CacheManager.AddOrUpdate((path, isFullImage), value); } } - public bool ContainsKey(string key) + public async ValueTask GetOrAddAsync(string key, + Func<(string, bool), Task> valueFactory, + bool isFullImage = false) { - return key is not null && Data.ContainsKey(key) && Data[key].imageSource != null; + return await CacheManager.GetOrAddAsync((key, isFullImage), valueFactory); + } + + public bool ContainsKey(string key, bool isFullImage) + { + return CacheManager.TryGet((key, isFullImage), out _); + } + + public bool TryGetValue(string key, bool isFullImage, out ImageSource image) + { + if (CacheManager.TryGet((key, isFullImage), out var value)) + { + image = value; + return image != null; + } + + + image = null; + return false; } public int CacheSize() { - return Data.Count; + return CacheManager.Count; } /// @@ -97,7 +70,14 @@ namespace Flow.Launcher.Infrastructure.Image /// public int UniqueImagesInCache() { - return Data.Values.Select(x => x.imageSource).Distinct().Count(); + return CacheManager.Select(x => x.Value) + .Distinct() + .Count(); + } + + public IEnumerable> EnumerateEntries() + { + return CacheManager; } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 11f66c8af..6f7b1cd90 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -3,77 +3,90 @@ using System.Collections.Concurrent; using System.Collections.Generic; 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; namespace Flow.Launcher.Infrastructure.Image { public static class ImageLoader { private static readonly ImageCache ImageCache = new(); - private static BinaryStorage> _storage; + 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 DefaultImage { get; } = new BitmapImage(new Uri(Constant.MissingImgIcon)); + 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; - private static readonly string[] ImageExtensions = + private static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".ico" }; + + public static async Task InitializeAsync() { - ".png", - ".jpg", - ".jpeg", - ".gif", - ".bmp", - ".tiff", - ".ico" - }; - - public static void Initialize() - { - _storage = new BinaryStorage>("Image"); + _storage = new BinaryStorage>("Image"); _hashGenerator = new ImageHashGenerator(); - var usage = LoadStorageToConcurrentDictionary(); + var usage = await LoadStorageToConcurrentDictionaryAsync(); + + ImageCache.Initialize(usage); foreach (var icon in new[] { Constant.DefaultIcon, Constant.MissingImgIcon }) { ImageSource img = new BitmapImage(new Uri(icon)); img.Freeze(); - ImageCache[icon] = img; + ImageCache[icon, false] = img; } - _ = Task.Run(() => + _ = Task.Run(async () => { - Stopwatch.Normal("|ImageLoader.Initialize|Preload images cost", () => + await Stopwatch.NormalAsync("|ImageLoader.Initialize|Preload images cost", async () => { - ImageCache.Data.AsParallel().ForAll(x => + foreach (var (path, isFullImage) in usage) { - Load(x.Key); - }); + 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( + $"|ImageLoader.Initialize|Number of preload images is <{ImageCache.CacheSize()}>, Images Number: {ImageCache.CacheSize()}, Unique Items {ImageCache.UniqueImagesInCache()}"); }); } - public static void Save() + public static async Task Save() { - lock (_storage) + await storageLock.WaitAsync(); + + try { - _storage.Save(ImageCache.Data.Select(x => (x.Key, x.Value.usage)).ToDictionary(x => x.Key, x => x.usage)); + await _storage.SaveAsync(ImageCache.EnumerateEntries() + .Select(x => x.Key) + .ToList()); + } + finally + { + storageLock.Release(); } } - private static ConcurrentDictionary LoadStorageToConcurrentDictionary() + private static async Task> LoadStorageToConcurrentDictionaryAsync() { - lock (_storage) + await storageLock.WaitAsync(); + try { - var loaded = _storage.TryLoad(new Dictionary()); - - return new ConcurrentDictionary(loaded); + return await _storage.TryLoadAsync(new List<(string, bool)>()); + } + finally + { + storageLock.Release(); } } @@ -95,11 +108,12 @@ namespace Flow.Launcher.Infrastructure.Image Folder, Data, ImageFile, + FullImageFile, Error, Cache } - private static ImageResult LoadInternal(string path, bool loadFullImage = false) + private static async ValueTask LoadInternalAsync(string path, bool loadFullImage = false) { ImageResult imageResult; @@ -107,26 +121,33 @@ namespace Flow.Launcher.Infrastructure.Image { if (string.IsNullOrEmpty(path)) { - return new ImageResult(ImageCache[Constant.MissingImgIcon], ImageType.Error); - } - if (ImageCache.ContainsKey(path)) - { - return new ImageResult(ImageCache[path], ImageType.Cache); + return new ImageResult(MissingImage, ImageType.Error); } - if (path.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + // extra scope for use of same variable name + { + if (ImageCache.TryGetValue(path, loadFullImage, out var imageSource)) + { + return new ImageResult(imageSource, ImageType.Cache); + } + } + + if (Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out var uriResult) + && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps)) + { + var image = await LoadRemoteImageAsync(loadFullImage, uriResult); + ImageCache[path, loadFullImage] = image; + return new ImageResult(image, ImageType.ImageFile); + } + + if (path.StartsWith("data:image", StringComparison.OrdinalIgnoreCase)) { var imageSource = new BitmapImage(new Uri(path)); imageSource.Freeze(); return new ImageResult(imageSource, ImageType.Data); } - if (!Path.IsPathRooted(path)) - { - path = Path.Combine(Constant.ProgramDirectory, "Images", Path.GetFileName(path)); - } - - imageResult = GetThumbnailResult(ref path, loadFullImage); + imageResult = await Task.Run(() => GetThumbnailResult(ref path, loadFullImage)); } catch (System.Exception e) { @@ -140,8 +161,8 @@ namespace Flow.Launcher.Infrastructure.Image 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); - ImageSource image = ImageCache[Constant.MissingImgIcon]; - ImageCache[path] = image; + ImageSource image = ImageCache[Constant.MissingImgIcon, false]; + ImageCache[path, false] = image; imageResult = new ImageResult(image, ImageType.Error); } } @@ -149,6 +170,29 @@ namespace Flow.Launcher.Infrastructure.Image return imageResult; } + private static async Task LoadRemoteImageAsync(bool loadFullImage, Uri uriResult) + { + // Download image from url + await using var resp = await GetStreamAsync(uriResult); + await using var buffer = new MemoryStream(); + await resp.CopyToAsync(buffer); + buffer.Seek(0, SeekOrigin.Begin); + var image = new BitmapImage(); + image.BeginInit(); + image.CacheOption = BitmapCacheOption.OnLoad; + if (!loadFullImage) + { + image.DecodePixelHeight = SmallIconSize; + image.DecodePixelWidth = SmallIconSize; + } + + image.StreamSource = buffer; + image.EndInit(); + image.StreamSource = null; + image.Freeze(); + return image; + } + private static ImageResult GetThumbnailResult(ref string path, bool loadFullImage = false) { ImageSource image; @@ -157,8 +201,8 @@ namespace Flow.Launcher.Infrastructure.Image if (Directory.Exists(path)) { /* Directories can also have thumbnails instead of shell icons. - * Generating thumbnails for a bunch of folder results while scrolling - * could have a big impact on performance and Flow.Launcher responsibility. + * Generating thumbnails for a bunch of folder results while scrolling + * could have a big impact on performance and Flow.Launcher responsibility. * - Solution: just load the icon */ type = ImageType.Folder; @@ -172,13 +216,22 @@ namespace Flow.Launcher.Infrastructure.Image type = ImageType.ImageFile; if (loadFullImage) { - image = LoadFullImage(path); + try + { + image = LoadFullImage(path); + type = ImageType.FullImageFile; + } + catch (NotSupportedException) + { + image = Image; + type = ImageType.Error; + } } else { - /* Although the documentation for GetImage on MSDN indicates that + /* Although the documentation for GetImage on MSDN indicates that * if a thumbnail is available it will return one, this has proved to not - * be the case in many situations while testing. + * be the case in many situations while testing. * - Solution: explicitly pass the ThumbnailOnly flag */ image = GetThumbnail(path, ThumbnailOptions.ThumbnailOnly); @@ -187,12 +240,12 @@ namespace Flow.Launcher.Infrastructure.Image else { type = ImageType.File; - image = GetThumbnail(path, ThumbnailOptions.None); + image = GetThumbnail(path, ThumbnailOptions.None, loadFullImage ? FullIconSize : SmallIconSize); } } else { - image = ImageCache[Constant.MissingImgIcon]; + image = ImageCache[Constant.MissingImgIcon, false]; path = Constant.MissingImgIcon; } @@ -204,43 +257,52 @@ namespace Flow.Launcher.Infrastructure.Image return new ImageResult(image, type); } - private static BitmapSource GetThumbnail(string path, ThumbnailOptions option = ThumbnailOptions.ThumbnailOnly) + private static BitmapSource GetThumbnail(string path, ThumbnailOptions option = ThumbnailOptions.ThumbnailOnly, + int size = SmallIconSize) { return WindowsThumbnailProvider.GetThumbnail( path, - Constant.ThumbnailSize, - Constant.ThumbnailSize, + size, + size, option); } - public static bool CacheContainImage(string path) + public static bool CacheContainImage(string path, bool loadFullImage = false) { - return ImageCache.ContainsKey(path) && ImageCache[path] != null; + return ImageCache.ContainsKey(path, loadFullImage); } - public static ImageSource Load(string path, bool loadFullImage = false) + public static bool TryGetValue(string path, bool loadFullImage, out ImageSource image) { - var imageResult = LoadInternal(path, loadFullImage); + return ImageCache.TryGetValue(path, loadFullImage, out image); + } + + public static async ValueTask LoadAsync(string path, bool loadFullImage = false) + { + var imageResult = await LoadInternalAsync(path, loadFullImage); var img = imageResult.ImageSource; if (imageResult.ImageType != ImageType.Error && imageResult.ImageType != ImageType.Cache) - { // we need to get image hash + { + // we need to get image hash string hash = EnableImageHash ? _hashGenerator.GetHashFromImage(img) : null; if (hash != null) { - if (GuidToKey.TryGetValue(hash, out string key)) - { // image already exists - img = ImageCache[key] ?? img; + { + // image already exists + img = ImageCache[key, loadFullImage] ?? img; } else - { // new guid + { + // new guid + GuidToKey[hash] = path; } } // update cache - ImageCache[path] = img; + ImageCache[path, loadFullImage] = img; } return img; @@ -254,6 +316,32 @@ namespace Flow.Launcher.Infrastructure.Image image.UriSource = new Uri(path); image.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; image.EndInit(); + + if (image.PixelWidth > 320) + { + BitmapImage resizedWidth = new BitmapImage(); + resizedWidth.BeginInit(); + resizedWidth.CacheOption = BitmapCacheOption.OnLoad; + resizedWidth.UriSource = new Uri(path); + resizedWidth.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; + resizedWidth.DecodePixelWidth = 320; + resizedWidth.EndInit(); + + if (resizedWidth.PixelHeight > 320) + { + BitmapImage resizedHeight = new BitmapImage(); + resizedHeight.BeginInit(); + resizedHeight.CacheOption = BitmapCacheOption.OnLoad; + resizedHeight.UriSource = new Uri(path); + resizedHeight.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; + resizedHeight.DecodePixelHeight = 320; + resizedHeight.EndInit(); + return resizedHeight; + } + + return resizedWidth; + } + return image; } } diff --git a/Flow.Launcher.Infrastructure/Image/ThumbnailReader.cs b/Flow.Launcher.Infrastructure/Image/ThumbnailReader.cs index 247238bb6..b98ea50fe 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,13 @@ 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"; - - [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 Guid GUID_IShellItem = typeof(IShellItem).GUID; + private static readonly HRESULT S_ExtractionFailed = (HRESULT)0x8004B200; 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 +44,61 @@ 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 (ex.HResult == S_ExtractionFailed && options == ThumbnailOptions.ThumbnailOnly) + { + // Fallback to IconOnly if ThumbnailOnly fails + 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); + } + } + 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 b8f1408e7..7f847e287 100644 --- a/Flow.Launcher.Infrastructure/Logger/Log.cs +++ b/Flow.Launcher.Infrastructure/Logger/Log.cs @@ -5,11 +5,8 @@ using NLog; using NLog.Config; using NLog.Targets; using Flow.Launcher.Infrastructure.UserSettings; -using JetBrains.Annotations; -using NLog.Fluent; using NLog.Targets.Wrappers; using System.Runtime.ExceptionServices; -using System.Text; namespace Flow.Launcher.Infrastructure.Logger { @@ -51,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"); @@ -76,7 +101,6 @@ namespace Flow.Launcher.Infrastructure.Logger return valid; } - public static void Exception(string className, string message, System.Exception exception, [CallerMemberName] string methodName = "") { exception = exception.Demystify(); @@ -115,8 +139,6 @@ namespace Flow.Launcher.Infrastructure.Logger { var logger = LogManager.GetLogger(classAndMethod); - var messageBuilder = new StringBuilder(); - logger.Error(e, message); } @@ -136,7 +158,8 @@ namespace Flow.Launcher.Infrastructure.Logger } } - /// example: "|prefix|unprefixed" + /// Example: "|ClassName.MethodName|Message" + /// Example: "|ClassName.MethodName|Message" /// Exception public static void Exception(string message, System.Exception e) { @@ -158,7 +181,7 @@ namespace Flow.Launcher.Infrastructure.Logger #endif } - /// example: "|prefix|unprefixed" + /// Example: "|ClassName.MethodName|Message" public static void Error(string message) { LogInternal(message, LogLevel.Error); @@ -183,7 +206,7 @@ namespace Flow.Launcher.Infrastructure.Logger LogInternal(LogLevel.Debug, className, message, methodName); } - /// example: "|prefix|unprefixed" + /// Example: "|ClassName.MethodName|Message"" public static void Debug(string message) { LogInternal(message, LogLevel.Debug); @@ -194,7 +217,7 @@ namespace Flow.Launcher.Infrastructure.Logger LogInternal(LogLevel.Info, className, message, methodName); } - /// example: "|prefix|unprefixed" + /// Example: "|ClassName.MethodName|Message" public static void Info(string message) { LogInternal(message, LogLevel.Info); @@ -205,10 +228,16 @@ namespace Flow.Launcher.Infrastructure.Logger LogInternal(LogLevel.Warn, className, message, methodName); } - /// example: "|prefix|unprefixed" + /// 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/NativeMethods.txt b/Flow.Launcher.Infrastructure/NativeMethods.txt new file mode 100644 index 000000000..f117534a1 --- /dev/null +++ b/Flow.Launcher.Infrastructure/NativeMethods.txt @@ -0,0 +1,19 @@ +SHCreateItemFromParsingName +DeleteObject +IShellItem +IShellItemImageFactory +S_OK + +SetWindowsHookEx +UnhookWindowsHookEx +CallNextHookEx +GetModuleHandle +GetKeyState +VIRTUAL_KEY + +WM_KEYDOWN +WM_KEYUP +WM_SYSKEYDOWN +WM_SYSKEYUP + +EnumWindows \ No newline at end of file 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/Storage/BinaryStorage.cs b/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs index ea2d42773..2a439b8cc 100644 --- a/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs @@ -4,115 +4,78 @@ 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 MemoryPack; namespace Flow.Launcher.Infrastructure.Storage { -#pragma warning disable SYSLIB0011 // BinaryFormatter is obsolete. /// /// Stroage object using binary data /// Normally, it has better performance, but not readable /// + /// + /// It utilize MemoryPack, which means the object must be MemoryPackSerializable + /// https://github.com/Cysharp/MemoryPack + /// public class BinaryStorage { + const string DirectoryName = "Cache"; + + const string FileSuffix = ".cache"; + public BinaryStorage(string filename) { - const string directoryName = "Cache"; - var directoryPath = Path.Combine(DataLocation.DataDirectory(), directoryName); + var directoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName); Helper.ValidateDirectory(directoryPath); - const string fileSuffix = ".cache"; - FilePath = Path.Combine(directoryPath, $"{filename}{fileSuffix}"); + FilePath = Path.Combine(directoryPath, $"{filename}{FileSuffix}"); } public string FilePath { get; } - public T TryLoad(T defaultData) + public async ValueTask TryLoadAsync(T defaultData) { if (File.Exists(FilePath)) { if (new FileInfo(FilePath).Length == 0) { Log.Error($"|BinaryStorage.TryLoad|Zero length cache file <{FilePath}>"); - Save(defaultData); + await SaveAsync(defaultData); return defaultData; } - using (var stream = new FileStream(FilePath, FileMode.Open)) - { - var d = Deserialize(stream, defaultData); - return d; - } + await using var stream = new FileStream(FilePath, FileMode.Open); + var d = await DeserializeAsync(stream, defaultData); + return d; } else { Log.Info("|BinaryStorage.TryLoad|Cache file not exist, load default data"); - Save(defaultData); + await SaveAsync(defaultData); return defaultData; } } - private T Deserialize(FileStream stream, T defaultData) + private async ValueTask DeserializeAsync(Stream stream, T defaultData) { - //http://stackoverflow.com/questions/2120055/binaryformatter-deserialize-gives-serializationexception - AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve; - BinaryFormatter binaryFormatter = new BinaryFormatter - { - AssemblyFormat = FormatterAssemblyStyle.Simple - }; - try { - var t = ((T)binaryFormatter.Deserialize(stream)).NonNull(); + var t = await MemoryPackSerializer.DeserializeAsync(stream); return t; } catch (System.Exception e) { - Log.Exception($"|BinaryStorage.Deserialize|Deserialize error for file <{FilePath}>", e); + // Log.Exception($"|BinaryStorage.Deserialize|Deserialize error for file <{FilePath}>", e); return defaultData; } - finally - { - AppDomain.CurrentDomain.AssemblyResolve -= CurrentDomain_AssemblyResolve; - } } - private Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args) + public async ValueTask SaveAsync(T data) { - Assembly ayResult = null; - string sShortAssemblyName = args.Name.Split(',')[0]; - Assembly[] ayAssemblies = AppDomain.CurrentDomain.GetAssemblies(); - foreach (Assembly ayAssembly in ayAssemblies) - { - if (sShortAssemblyName == ayAssembly.FullName.Split(',')[0]) - { - ayResult = ayAssembly; - break; - } - } - return ayResult; - } - - public void Save(T data) - { - using (var stream = new FileStream(FilePath, FileMode.Create)) - { - BinaryFormatter binaryFormatter = new BinaryFormatter - { - AssemblyFormat = FormatterAssemblyStyle.Simple - }; - - try - { - binaryFormatter.Serialize(stream, data); - } - catch (SerializationException e) - { - Log.Exception($"|BinaryStorage.Save|serialize error for file <{FilePath}>", e); - } - } + await using var stream = new FileStream(FilePath, FileMode.Create); + await MemoryPackSerializer.SerializeAsync(stream, data); } } -#pragma warning restore SYSLIB0011 } diff --git a/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs b/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs index ec7a38804..865041fb3 100644 --- a/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.IO; using Flow.Launcher.Infrastructure.UserSettings; namespace Flow.Launcher.Infrastructure.Storage diff --git a/Flow.Launcher.Infrastructure/Storage/ISavable.cs b/Flow.Launcher.Infrastructure/Storage/ISavable.cs deleted file mode 100644 index ba2b58c6a..000000000 --- a/Flow.Launcher.Infrastructure/Storage/ISavable.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System; - -namespace Flow.Launcher.Infrastructure.Storage -{ - [Obsolete("Deprecated as of Flow Launcher v1.8.0, on 2021.06.21. " + - "This is used only for Everything plugin v1.4.9 or below backwards compatibility")] - public interface ISavable : Plugin.ISavable { } -} \ No newline at end of file diff --git a/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs b/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs index 0083ccb87..507838d94 100644 --- a/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs @@ -1,7 +1,9 @@ -using System; +#nullable enable +using System; using System.Globalization; using System.IO; using System.Text.Json; +using System.Threading.Tasks; using Flow.Launcher.Infrastructure.Logger; namespace Flow.Launcher.Infrastructure.Storage @@ -11,62 +13,158 @@ namespace Flow.Launcher.Infrastructure.Storage /// public class JsonStorage where T : new() { - protected T _data; + protected T? Data; + // need a new directory name public const string DirectoryName = "Settings"; public const string FileSuffix = ".json"; - public string FilePath { get; set; } - public string DirectoryPath { get; set; } + protected string FilePath { get; init; } = null!; - public T Load() + private string TempFilePath => $"{FilePath}.tmp"; + + private string BackupFilePath => $"{FilePath}.bak"; + + protected string DirectoryPath { get; init; } = null!; + + // Let the derived class to set the file path + protected JsonStorage() { + } + + public JsonStorage(string filePath) + { + FilePath = filePath; + DirectoryPath = Path.GetDirectoryName(filePath) ?? throw new ArgumentException("Invalid file path"); + + Helper.ValidateDirectory(DirectoryPath); + } + + public async Task LoadAsync() + { + if (Data != null) + return Data; + + string? serialized = null; + if (File.Exists(FilePath)) { - var serialized = File.ReadAllText(FilePath); - if (!string.IsNullOrWhiteSpace(serialized)) + serialized = await File.ReadAllTextAsync(FilePath); + } + + if (!string.IsNullOrEmpty(serialized)) + { + try { - Deserialize(serialized); + Data = JsonSerializer.Deserialize(serialized) ?? await LoadBackupOrDefaultAsync(); } - else + catch (JsonException) { - LoadDefault(); + Data = await LoadBackupOrDefaultAsync(); } } else { - LoadDefault(); + Data = await LoadBackupOrDefaultAsync(); } - return _data.NonNull(); + + return Data.NonNull(); } - private void Deserialize(string serialized) + private async ValueTask LoadBackupOrDefaultAsync() { + var backup = await TryLoadBackupAsync(); + + return backup ?? LoadDefault(); + } + + private async ValueTask TryLoadBackupAsync() + { + if (!File.Exists(BackupFilePath)) + return default; + try { - _data = JsonSerializer.Deserialize(serialized); - } - catch (JsonException e) - { - LoadDefault(); - Log.Exception($"|JsonStorage.Deserialize|Deserialize error for json <{FilePath}>", e); - } + await using var source = File.OpenRead(BackupFilePath); + var data = await JsonSerializer.DeserializeAsync(source) ?? default; - if (_data == null) + if (data != null) + RestoreBackup(); + + return data; + } + catch (JsonException) { - LoadDefault(); + return default; } } - private void LoadDefault() + private void RestoreBackup() + { + Log.Info($"|JsonStorage.Load|Failed to load settings.json, {BackupFilePath} restored successfully"); + + if (File.Exists(FilePath)) + File.Replace(BackupFilePath, FilePath, null); + else + File.Move(BackupFilePath, FilePath); + } + + public T Load() + { + string? serialized = null; + + if (File.Exists(FilePath)) + { + serialized = File.ReadAllText(FilePath); + } + + if (!string.IsNullOrEmpty(serialized)) + { + try + { + Data = JsonSerializer.Deserialize(serialized) ?? TryLoadBackup() ?? LoadDefault(); + } + catch (JsonException) + { + Data = TryLoadBackup() ?? LoadDefault(); + } + } + else + { + Data = TryLoadBackup() ?? LoadDefault(); + } + + return Data.NonNull(); + } + + private T LoadDefault() { if (File.Exists(FilePath)) { BackupOriginFile(); } - _data = new T(); - Save(); + return new T(); + } + + private T? TryLoadBackup() + { + if (!File.Exists(BackupFilePath)) + return default; + + try + { + var data = JsonSerializer.Deserialize(File.ReadAllText(BackupFilePath)); + + if (data != null) + RestoreBackup(); + + return data; + } + catch (JsonException) + { + return default; + } } private void BackupOriginFile() @@ -82,13 +180,33 @@ namespace Flow.Launcher.Infrastructure.Storage public void Save() { - string serialized = JsonSerializer.Serialize(_data, new JsonSerializerOptions() { WriteIndented = true }); + string serialized = JsonSerializer.Serialize(Data, + new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(FilePath, serialized); + File.WriteAllText(TempFilePath, serialized); + + AtomicWriteSetting(); + } + + public async Task SaveAsync() + { + await using var tempOutput = File.OpenWrite(TempFilePath); + await JsonSerializer.SerializeAsync(tempOutput, Data, + new JsonSerializerOptions { WriteIndented = true }); + AtomicWriteSetting(); + } + + private void AtomicWriteSetting() + { + if (!File.Exists(FilePath)) + { + File.Move(TempFilePath, FilePath); + } + else + { + var finalFilePath = new FileInfo(FilePath).LinkTarget ?? FilePath; + File.Replace(TempFilePath, finalFilePath, BackupFilePath); + } } } - - [Obsolete("Deprecated as of Flow Launcher v1.8.0, on 2021.06.21. " + - "This is used only for Everything plugin v1.4.9 or below backwards compatibility")] - public class JsonStrorage : JsonStorage where T : new() { } } diff --git a/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs b/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs index 923a1a6b5..bc3900da8 100644 --- a/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs @@ -3,14 +3,17 @@ using Flow.Launcher.Infrastructure.UserSettings; 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; + 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); + AssemblyName = dataType.Assembly.GetName().Name; + DirectoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName, Constant.Plugins, AssemblyName); Helper.ValidateDirectory(DirectoryPath); FilePath = Path.Combine(DirectoryPath, $"{dataType.Name}{FileSuffix}"); @@ -18,8 +21,15 @@ namespace Flow.Launcher.Infrastructure.Storage public PluginJsonStorage(T data) : this() { - _data = data; + Data = data; + } + + public void DeleteDirectory() + { + if (Directory.Exists(DirectoryPath)) + { + Directory.Delete(DirectoryPath, true); + } } } } - 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/UserSettings/CustomExplorerViewModel.cs b/Flow.Launcher.Infrastructure/UserSettings/CustomExplorerViewModel.cs index 7806debe1..c54c30478 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/CustomExplorerViewModel.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/CustomExplorerViewModel.cs @@ -1,9 +1,4 @@ using Flow.Launcher.Plugin; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Flow.Launcher.ViewModel { diff --git a/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs b/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs index e93c341dc..e294f52b8 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs @@ -1,9 +1,5 @@ -using System; -using System.Collections.Generic; +using System; using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Flow.Launcher.Infrastructure.UserSettings { @@ -31,5 +27,10 @@ namespace Flow.Launcher.Infrastructure.UserSettings public static readonly string PluginsDirectory = Path.Combine(DataDirectory(), Constant.Plugins); public static readonly string PluginSettingsDirectory = Path.Combine(DataDirectory(), "Settings", Constant.Plugins); + + public const string PythonEnvironmentName = "Python"; + public const string NodeEnvironmentName = "Node.js"; + public const string PluginEnvironments = "Environments"; + public static readonly string PluginEnvironmentsPath = Path.Combine(DataDirectory(), PluginEnvironments); } } diff --git a/Flow.Launcher.Infrastructure/UserSettings/HttpProxy.cs b/Flow.Launcher.Infrastructure/UserSettings/HttpProxy.cs index 213193526..c8d76d2ba 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/HttpProxy.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/HttpProxy.cs @@ -1,6 +1,4 @@ -using System.ComponentModel; - -namespace Flow.Launcher.Infrastructure.UserSettings +namespace Flow.Launcher.Infrastructure.UserSettings { public enum ProxyProperty { diff --git a/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs b/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs index c06e1587d..98f4dccda 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs @@ -1,11 +1,31 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Flow.Launcher.Plugin; namespace Flow.Launcher.Infrastructure.UserSettings { public class PluginsSettings : BaseModel { - public string PythonDirectory { get; set; } + private string pythonExecutablePath = string.Empty; + public string PythonExecutablePath { + get { return pythonExecutablePath; } + set + { + pythonExecutablePath = value; + Constant.PythonPath = value; + } + } + + private string nodeExecutablePath = string.Empty; + public string NodeExecutablePath + { + get { return nodeExecutablePath; } + set + { + nodeExecutablePath = value; + Constant.NodePath = value; + } + } + public Dictionary Plugins { get; set; } = new Dictionary(); public void UpdatePluginSettings(List metadatas) @@ -15,25 +35,6 @@ namespace Flow.Launcher.Infrastructure.UserSettings if (Plugins.ContainsKey(metadata.ID)) { var settings = Plugins[metadata.ID]; - - if (metadata.ID == "572be03c74c642baae319fc283e561a8" && metadata.ActionKeywords.Count > settings.ActionKeywords.Count) - { - // TODO: Remove. This is backwards compatibility for Explorer 1.8.0 release. - // Introduced two new action keywords in Explorer, so need to update plugin setting in the UserData folder. - if (settings.Version.CompareTo("1.8.0") < 0) - { - settings.ActionKeywords.Add(Query.GlobalPluginWildcardSign); // for index search - settings.ActionKeywords.Add(Query.GlobalPluginWildcardSign); // for path search - settings.ActionKeywords.Add(Query.GlobalPluginWildcardSign); // for quick access action keyword - } - - // TODO: Remove. This is backwards compatibility for Explorer 1.9.0 release. - // Introduced a new action keywords in Explorer since 1.8.0, so need to update plugin setting in the UserData folder. - if (settings.Version.CompareTo("1.8.0") > 0) - { - settings.ActionKeywords.Add(Query.GlobalPluginWildcardSign); // for quick access action keyword - } - } if (string.IsNullOrEmpty(settings.Version)) settings.Version = metadata.Version; diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs index 33072b53d..93f6db111 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -1,24 +1,58 @@ -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 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; using Flow.Launcher.ViewModel; namespace Flow.Launcher.Infrastructure.UserSettings { - public class Settings : BaseModel + 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 language = Constant.SystemLanguageCode; + private string _theme = Constant.DefaultTheme; public string Hotkey { get; set; } = $"{KeyConstant.Alt} + {KeyConstant.Space}"; public string OpenResultModifiers { get; set; } = KeyConstant.Alt; public string ColorScheme { get; set; } = "System"; public bool ShowOpenResultHotkey { get; set; } = true; public double WindowSize { get; set; } = 580; + public string PreviewHotkey { get; set; } = $"F1"; + public string AutoCompleteHotkey { get; set; } = $"{KeyConstant.Ctrl} + Tab"; + public string AutoCompleteHotkey2 { get; set; } = $""; + public string SelectNextItemHotkey { get; set; } = $"Tab"; + public string SelectNextItemHotkey2 { get; set; } = $""; + public string SelectPrevItemHotkey { get; set; } = $"Shift + Tab"; + public string SelectPrevItemHotkey2 { get; set; } = $""; + public string SelectNextPageHotkey { get; set; } = $"PageUp"; + public string SelectPrevPageHotkey { get; set; } = $"PageDown"; + public string OpenContextMenuHotkey { get; set; } = $"Ctrl+O"; + public string SettingWindowHotkey { get; set; } = $"Ctrl+I"; + public string CycleHistoryUpHotkey { get; set; } = $"{KeyConstant.Alt} + Up"; + public string CycleHistoryDownHotkey { get; set; } = $"{KeyConstant.Alt} + Down"; public string Language { @@ -29,8 +63,26 @@ namespace Flow.Launcher.Infrastructure.UserSettings OnPropertyChanged(); } } - public string Theme { get; set; } = Constant.DefaultTheme; - public bool UseDropShadowEffect { get; set; } = false; + public string Theme + { + get => _theme; + set + { + if (value == _theme) + return; + _theme = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(MaxResultsToShow)); + } + } + public bool UseDropShadowEffect { get; set; } = true; + + /* 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 ResultItemFontSize { get; set; } = 16; + public double ResultSubItemFontSize { get; set; } = 13; public string QueryBoxFont { get; set; } = FontFamily.GenericSansSerif.Name; public string QueryBoxFontStyle { get; set; } public string QueryBoxFontWeight { get; set; } @@ -39,9 +91,15 @@ namespace Flow.Launcher.Infrastructure.UserSettings 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 ResultSubFontStyle { get; set; } + public string ResultSubFontWeight { get; set; } + public string ResultSubFontStretch { get; set; } public bool UseGlyphIcons { get; set; } = true; public bool UseAnimation { get; set; } = true; public bool UseSound { get; set; } = true; + public double SoundVolume { get; set; } = 50; + public bool UseClock { get; set; } = true; public bool UseDate { get; set; } = false; public string TimeFormat { get; set; } = "hh:mm tt"; @@ -50,8 +108,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings public double SettingWindowWidth { get; set; } = 1000; public double SettingWindowHeight { get; set; } = 700; - public double SettingWindowTop { get; set; } - public double SettingWindowLeft { get; set; } + public double? SettingWindowTop { get; set; } = null; + public double? SettingWindowLeft { get; set; } = null; public System.Windows.WindowState SettingWindowState { get; set; } = WindowState.Normal; public int CustomExplorerIndex { get; set; } = 0; @@ -142,38 +200,28 @@ 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 /// public bool ShouldUsePinyin { get; set; } = false; - [JsonInclude, JsonConverter(typeof(JsonStringEnumConverter))] - public SearchPrecisionScore QuerySearchPrecision { get; private set; } = SearchPrecisionScore.Regular; + public bool AlwaysPreview { get; set; } = false; - [JsonIgnore] - public string QuerySearchPrecisionString + public bool AlwaysStartEn { get; set; } = false; + + private SearchPrecisionScore _querySearchPrecision = SearchPrecisionScore.Regular; + [JsonInclude, JsonConverter(typeof(JsonStringEnumConverter))] + public SearchPrecisionScore QuerySearchPrecision { - get { return QuerySearchPrecision.ToString(); } + get => _querySearchPrecision; set { - try - { - var precisionScore = (SearchPrecisionScore)Enum - .Parse(typeof(SearchPrecisionScore), value); - - QuerySearchPrecision = precisionScore; - StringMatcher.Instance.UserSettingSearchPrecision = precisionScore; - } - catch (ArgumentException e) - { - Logger.Log.Exception(nameof(Settings), "Failed to load QuerySearchPrecisionString value from Settings file", e); - - QuerySearchPrecision = SearchPrecisionScore.Regular; - StringMatcher.Instance.UserSettingSearchPrecision = SearchPrecisionScore.Regular; - - throw; - } + _querySearchPrecision = value; + if (_stringMatcher != null) + _stringMatcher.UserSettingSearchPrecision = value; } } @@ -181,6 +229,18 @@ namespace Flow.Launcher.Infrastructure.UserSettings public double WindowLeft { get; set; } public double WindowTop { get; set; } + + /// + /// Custom left position on selected monitor + /// + public double CustomWindowLeft { get; set; } = 0; + + /// + /// Custom top position on selected monitor + /// + public double CustomWindowTop { get; set; } = 0; + + public bool KeepMaxResults { get; set; } = false; public int MaxResultsToShow { get; set; } = 5; public int ActivateTimes { get; set; } @@ -190,14 +250,17 @@ namespace Flow.Launcher.Infrastructure.UserSettings public ObservableCollection CustomShortcuts { get; set; } = new ObservableCollection(); [JsonIgnore] - public ObservableCollection BuiltinShortcuts { get; set; } = new ObservableCollection() { - new BuiltinShortcutModel("{clipboard}", "shortcut_clipboard_description", Clipboard.GetText) + public ObservableCollection BuiltinShortcuts { get; set; } = new() + { + new BuiltinShortcutModel("{clipboard}", "shortcut_clipboard_description", Clipboard.GetText), + new BuiltinShortcutModel("{active_explorer_path}", "shortcut_active_explorer_path", FileExplorerHelper.GetActiveExplorerPath) }; public bool DontPromptUpdateMsg { get; set; } 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; } public bool HideNotifyIcon @@ -210,8 +273,16 @@ namespace Flow.Launcher.Infrastructure.UserSettings } } public bool LeaveCmdOpen { get; set; } - public bool HideWhenDeactive { get; set; } = true; - public SearchWindowPositions SearchWindowPosition { get; set; } = SearchWindowPositions.MouseScreenCenter; + public bool HideWhenDeactivated { get; set; } = true; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public SearchWindowScreens SearchWindowScreen { get; set; } = SearchWindowScreens.Cursor; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public SearchWindowAligns SearchWindowAlign { get; set; } = SearchWindowAligns.Center; + + public int CustomScreenNumber { get; set; } = 1; + public bool IgnoreHotkeysOnFullscreen { get; set; } public HttpProxy Proxy { get; set; } = new HttpProxy(); @@ -219,16 +290,112 @@ namespace Flow.Launcher.Infrastructure.UserSettings [JsonConverter(typeof(JsonStringEnumConverter))] public LastQueryMode LastQueryMode { get; set; } = LastQueryMode.Selected; + [JsonConverter(typeof(JsonStringEnumConverter))] + public AnimationSpeeds AnimationSpeed { get; set; } = AnimationSpeeds.Medium; + public int CustomAnimationLength { get; set; } = 360; + + [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(); + + [JsonIgnore] + public List RegisteredHotkeys + { + get + { + var list = FixedHotkeys(); + + // Customizeable hotkeys + if(!string.IsNullOrEmpty(Hotkey)) + list.Add(new(Hotkey, "flowlauncherHotkey", () => Hotkey = "")); + if(!string.IsNullOrEmpty(PreviewHotkey)) + list.Add(new(PreviewHotkey, "previewHotkey", () => PreviewHotkey = "")); + if(!string.IsNullOrEmpty(AutoCompleteHotkey)) + list.Add(new(AutoCompleteHotkey, "autoCompleteHotkey", () => AutoCompleteHotkey = "")); + if(!string.IsNullOrEmpty(AutoCompleteHotkey2)) + list.Add(new(AutoCompleteHotkey2, "autoCompleteHotkey", () => AutoCompleteHotkey2 = "")); + if(!string.IsNullOrEmpty(SelectNextItemHotkey)) + list.Add(new(SelectNextItemHotkey, "SelectNextItemHotkey", () => SelectNextItemHotkey = "")); + if(!string.IsNullOrEmpty(SelectNextItemHotkey2)) + list.Add(new(SelectNextItemHotkey2, "SelectNextItemHotkey", () => SelectNextItemHotkey2 = "")); + if(!string.IsNullOrEmpty(SelectPrevItemHotkey)) + list.Add(new(SelectPrevItemHotkey, "SelectPrevItemHotkey", () => SelectPrevItemHotkey = "")); + if(!string.IsNullOrEmpty(SelectPrevItemHotkey2)) + list.Add(new(SelectPrevItemHotkey2, "SelectPrevItemHotkey", () => SelectPrevItemHotkey2 = "")); + if(!string.IsNullOrEmpty(SettingWindowHotkey)) + list.Add(new(SettingWindowHotkey, "SettingWindowHotkey", () => SettingWindowHotkey = "")); + if(!string.IsNullOrEmpty(OpenContextMenuHotkey)) + list.Add(new(OpenContextMenuHotkey, "OpenContextMenuHotkey", () => OpenContextMenuHotkey = "")); + if(!string.IsNullOrEmpty(SelectNextPageHotkey)) + list.Add(new(SelectNextPageHotkey, "SelectNextPageHotkey", () => SelectNextPageHotkey = "")); + if(!string.IsNullOrEmpty(SelectPrevPageHotkey)) + list.Add(new(SelectPrevPageHotkey, "SelectPrevPageHotkey", () => SelectPrevPageHotkey = "")); + if (!string.IsNullOrEmpty(CycleHistoryUpHotkey)) + list.Add(new(CycleHistoryUpHotkey, "CycleHistoryUpHotkey", () => CycleHistoryUpHotkey = "")); + if (!string.IsNullOrEmpty(CycleHistoryDownHotkey)) + list.Add(new(CycleHistoryDownHotkey, "CycleHistoryDownHotkey", () => CycleHistoryDownHotkey = "")); + + // Custom Query Hotkeys + foreach (var customPluginHotkey in CustomPluginHotkeys) + { + if (!string.IsNullOrEmpty(customPluginHotkey.Hotkey)) + list.Add(new(customPluginHotkey.Hotkey, "customQueryHotkey", () => customPluginHotkey.Hotkey = "")); + } + + return list; + } + } + + private List FixedHotkeys() + { + return new List + { + new("Up", "HotkeyLeftRightDesc"), + new("Down", "HotkeyLeftRightDesc"), + new("Left", "HotkeyUpDownDesc"), + new("Right", "HotkeyUpDownDesc"), + new("Escape", "HotkeyESCDesc"), + new("F5", "ReloadPluginHotkey"), + 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"), + new("Ctrl+OemMinus", "QuickHeightHotkey"), + new("Ctrl+Shift+Enter", "HotkeyCtrlShiftEnterDesc"), + new("Shift+Enter", "OpenContextMenuHotkey"), + new("Enter", "HotkeyRunDesc"), + new("Ctrl+Enter", "OpenContainFolderHotkey"), + new("Alt+Enter", "HotkeyOpenResult"), + new("Ctrl+F12", "ToggleGameModeHotkey"), + new("Ctrl+Shift+C", "CopyFilePathHotkey"), + + new($"{OpenResultModifiers}+D1", "HotkeyOpenResultN", 1), + new($"{OpenResultModifiers}+D2", "HotkeyOpenResultN", 2), + new($"{OpenResultModifiers}+D3", "HotkeyOpenResultN", 3), + new($"{OpenResultModifiers}+D4", "HotkeyOpenResultN", 4), + new($"{OpenResultModifiers}+D5", "HotkeyOpenResultN", 5), + new($"{OpenResultModifiers}+D6", "HotkeyOpenResultN", 6), + new($"{OpenResultModifiers}+D7", "HotkeyOpenResultN", 7), + new($"{OpenResultModifiers}+D8", "HotkeyOpenResultN", 8), + new($"{OpenResultModifiers}+D9", "HotkeyOpenResultN", 9), + new($"{OpenResultModifiers}+D0", "HotkeyOpenResultN", 10) + }; + } } public enum LastQueryMode { Selected, Empty, - Preserved + Preserved, + ActionKeywordPreserved, + ActionKeywordSelected } public enum ColorSchemes @@ -237,12 +404,30 @@ namespace Flow.Launcher.Infrastructure.UserSettings Light, Dark } - public enum SearchWindowPositions + + public enum SearchWindowScreens { RememberLastLaunchLocation, - MouseScreenCenter, - MouseScreenCenterTop, - MouseScreenLeftTop, - MouseScreenRightTop + Cursor, + Focus, + Primary, + Custom + } + + public enum SearchWindowAligns + { + Center, + CenterTop, + LeftTop, + RightTop, + Custom + } + + public enum AnimationSpeeds + { + Slow, + Medium, + Fast, + Custom } } diff --git a/Flow.Launcher.Infrastructure/Win32Helper.cs b/Flow.Launcher.Infrastructure/Win32Helper.cs new file mode 100644 index 000000000..867fef4f5 --- /dev/null +++ b/Flow.Launcher.Infrastructure/Win32Helper.cs @@ -0,0 +1,101 @@ +using System; +using System.Runtime.InteropServices; +using System.Windows.Interop; +using System.Windows; + +namespace Flow.Launcher.Infrastructure +{ + public static class Win32Helper + { + #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 + { + public AccentState AccentState; + public int AccentFlags; + public int GradientColor; + public int AnimationId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WindowCompositionAttributeData + { + public WindowCompositionAttribute Attribute; + public IntPtr Data; + public int SizeOfData; + } + + private enum WindowCompositionAttribute + { + WCA_ACCENT_POLICY = 19 + } + + [DllImport("user32.dll")] + private static extern int SetWindowCompositionAttribute(IntPtr hwnd, ref WindowCompositionAttributeData data); + + /// + /// Checks if the blur theme is enabled + /// + public static bool IsBlurTheme() + { + if (Environment.OSVersion.Version >= new Version(6, 2)) + { + var resource = Application.Current.TryFindResource("ThemeBlurEnabled"); + + if (resource is bool b) + return b; + + return false; + } + + return false; + } + + /// + /// Sets the blur for a window via SetWindowCompositionAttribute + /// + public static void SetBlurForWindow(Window w, bool blur) + { + SetWindowAccent(w, blur ? AccentState.ACCENT_ENABLE_BLURBEHIND : AccentState.ACCENT_DISABLED); + } + + private static 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 + } +} diff --git a/Flow.Launcher.Plugin/ActionContext.cs b/Flow.Launcher.Plugin/ActionContext.cs index b9e499d2b..e31c8e31d 100644 --- a/Flow.Launcher.Plugin/ActionContext.cs +++ b/Flow.Launcher.Plugin/ActionContext.cs @@ -1,15 +1,61 @@ -namespace Flow.Launcher.Plugin +using System.Windows.Input; + +namespace Flow.Launcher.Plugin { + /// + /// Context provided as a parameter when invoking a + /// or + /// public class ActionContext { + /// + /// Contains the press state of certain special keys. + /// public SpecialKeyState SpecialKeyState { get; set; } } + /// + /// Contains the press state of certain special keys. + /// public class SpecialKeyState { + /// + /// True if the Ctrl key is pressed. + /// public bool CtrlPressed { get; set; } + + /// + /// True if the Shift key is pressed. + /// public bool ShiftPressed { get; set; } + + /// + /// True if the Alt key is pressed. + /// public bool AltPressed { get; set; } + + /// + /// True if the Windows key is pressed. + /// public bool WinPressed { get; set; } + + /// + /// Get this object represented as a flag combination. + /// + /// + public ModifierKeys ToModifierKeys() + { + return (CtrlPressed ? ModifierKeys.Control : ModifierKeys.None) | + (ShiftPressed ? ModifierKeys.Shift : ModifierKeys.None) | + (AltPressed ? ModifierKeys.Alt : ModifierKeys.None) | + (WinPressed ? ModifierKeys.Windows : ModifierKeys.None); + } + + public static readonly SpecialKeyState Default = new () { + CtrlPressed = false, + ShiftPressed = false, + AltPressed = false, + WinPressed = false + }; } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin/AllowedLanguage.cs b/Flow.Launcher.Plugin/AllowedLanguage.cs index 94c645d27..619a94deb 100644 --- a/Flow.Launcher.Plugin/AllowedLanguage.cs +++ b/Flow.Launcher.Plugin/AllowedLanguage.cs @@ -1,4 +1,6 @@ -namespace Flow.Launcher.Plugin +using System; + +namespace Flow.Launcher.Plugin { /// /// Allowed plugin languages @@ -8,34 +10,52 @@ /// /// Python /// - public static string Python - { - get { return "PYTHON"; } - } + public const string Python = "Python"; + + /// + /// Python V2 + /// + public const string PythonV2 = "Python_v2"; /// /// C# /// - public static string CSharp - { - get { return "CSHARP"; } - } + public const string CSharp = "CSharp"; /// /// F# /// - public static string FSharp - { - get { return "FSHARP"; } - } + public const string FSharp = "FSharp"; /// /// Standard .exe /// - public static string Executable - { - get { return "EXECUTABLE"; } - } + public const string Executable = "Executable"; + + /// + /// Standard .exe + /// + public const string ExecutableV2 = "Executable_V2"; + + /// + /// TypeScript + /// + public const string TypeScript = "TypeScript"; + + /// + /// TypeScript + /// + public const string TypeScriptV2 = "TypeScript_V2"; + + /// + /// JavaScript + /// + public const string JavaScript = "JavaScript"; + + /// + /// JavaScript + /// + public const string JavaScriptV2 = "JavaScript_V2"; /// /// Determines if this language is a .NET language @@ -44,8 +64,8 @@ /// public static bool IsDotNet(string language) { - return language.ToUpper() == CSharp - || language.ToUpper() == FSharp; + return language.Equals(CSharp, StringComparison.OrdinalIgnoreCase) + || language.Equals(FSharp, StringComparison.OrdinalIgnoreCase); } /// @@ -56,8 +76,15 @@ public static bool IsAllowed(string language) { return IsDotNet(language) - || language.ToUpper() == Python.ToUpper() - || language.ToUpper() == Executable.ToUpper(); + || 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); + ; } } } diff --git a/Flow.Launcher.Plugin/EventHandler.cs b/Flow.Launcher.Plugin/EventHandler.cs index 009e1721c..893b0ba80 100644 --- a/Flow.Launcher.Plugin/EventHandler.cs +++ b/Flow.Launcher.Plugin/EventHandler.cs @@ -1,4 +1,5 @@ -using System.Windows; +using System; +using System.Windows; using System.Windows.Input; namespace Flow.Launcher.Plugin @@ -32,6 +33,24 @@ namespace Flow.Launcher.Plugin /// return true to continue handling, return false to intercept system handling public delegate bool FlowLauncherGlobalKeyboardEventHandler(int keyevent, int vkcode, SpecialKeyState state); + /// + /// A delegate for when the visibility is changed + /// + /// + /// + public delegate void VisibilityChangedEventHandler(object sender, VisibilityChangedEventArgs args); + + /// + /// The event args for + /// + public class VisibilityChangedEventArgs : EventArgs + { + /// + /// if the main window has become visible + /// + public bool IsVisible { get; init; } + } + /// /// Arguments container for the Key Down event /// diff --git a/Flow.Launcher.Plugin/Features.cs b/Flow.Launcher.Plugin/Features.cs index 5b9c6a7b9..b4a1eccc1 100644 --- a/Flow.Launcher.Plugin/Features.cs +++ b/Flow.Launcher.Plugin/Features.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Threading; - -namespace Flow.Launcher.Plugin +namespace Flow.Launcher.Plugin { /// /// Base Interface for Flow's special plugin feature interface diff --git a/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj b/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj index 41072993c..2feb21b12 100644 --- a/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj +++ b/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj @@ -1,7 +1,7 @@ - net6.0-windows + net7.0-windows {8451ECDD-2EA4-4966-BB0A-7BBC40138E80} true Library @@ -14,10 +14,10 @@ - 3.0.0 - 3.0.0 - 3.0.0 - 3.0.0 + 4.4.0 + 4.4.0 + 4.4.0 + 4.4.0 Flow.Launcher.Plugin Flow-Launcher MIT @@ -26,6 +26,7 @@ flowlauncher true true + Readme.md @@ -56,7 +57,11 @@ - + + + + + @@ -65,8 +70,12 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Flow.Launcher.Plugin/GlyphInfo.cs b/Flow.Launcher.Plugin/GlyphInfo.cs index 730046e1d..45b90b09e 100644 --- a/Flow.Launcher.Plugin/GlyphInfo.cs +++ b/Flow.Launcher.Plugin/GlyphInfo.cs @@ -1,11 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Media; - -namespace Flow.Launcher.Plugin +namespace Flow.Launcher.Plugin { /// /// Text with FontFamily specified diff --git a/Flow.Launcher.Plugin/Interfaces/IAsyncExternalPreview.cs b/Flow.Launcher.Plugin/Interfaces/IAsyncExternalPreview.cs new file mode 100644 index 000000000..cc4f94c56 --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IAsyncExternalPreview.cs @@ -0,0 +1,40 @@ +using System.Threading.Tasks; + +namespace Flow.Launcher.Plugin +{ + /// + /// This interface is for plugins that wish to provide file preview (external preview) + /// via a third party app instead of the default preview. + /// + public interface IAsyncExternalPreview : IFeatures + { + /// + /// Method for opening/showing the preview. + /// + /// The file path to open the preview for + /// Whether to send a toast message notification on failure for the user + public Task OpenPreviewAsync(string path, bool sendFailToast = true); + + /// + /// Method for closing/hiding the preview. + /// + public Task ClosePreviewAsync(); + + /// + /// Method for switching the preview to the next file result. + /// This requires the external preview be already open/showing + /// + /// The file path to switch the preview for + /// Whether to send a toast message notification on failure for the user + public Task SwitchPreviewAsync(string path, bool sendFailToast = true); + + /// + /// Allows the preview plugin to override the AlwaysPreview setting. Typically useful if plugin's preview does not + /// fully work well with being shown together when the query window appears with results. + /// When AlwaysPreview setting is on and this is set to false, the preview will not be shown when query + /// window appears with results, instead the internal preview will be shown. + /// + /// + public bool AllowAlwaysPreview(); + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IContextMenu.cs b/Flow.Launcher.Plugin/Interfaces/IContextMenu.cs index 5befbf5c9..06398fb1b 100644 --- a/Flow.Launcher.Plugin/Interfaces/IContextMenu.cs +++ b/Flow.Launcher.Plugin/Interfaces/IContextMenu.cs @@ -1,9 +1,18 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace Flow.Launcher.Plugin { + /// + /// Adds support for presenting additional options for a given from a context menu. + /// public interface IContextMenu : IFeatures { + /// + /// Load context menu items for the given result. + /// + /// + /// The for which the user has activated the context menu. + /// List LoadContextMenus(Result selectedResult); } } \ No newline at end of file diff --git a/Flow.Launcher.Plugin/Interfaces/IPluginI18n.cs b/Flow.Launcher.Plugin/Interfaces/IPluginI18n.cs index 61662b671..bbc998fcb 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPluginI18n.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPluginI18n.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; namespace Flow.Launcher.Plugin { @@ -7,8 +7,14 @@ namespace Flow.Launcher.Plugin /// public interface IPluginI18n : IFeatures { + /// + /// Get a localised version of the plugin's title + /// string GetTranslatedPluginTitle(); + /// + /// Get a localised version of the plugin's description + /// string GetTranslatedPluginDescription(); /// diff --git a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs index 41d062570..4c7af4cd4 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -7,6 +7,7 @@ using System.IO; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using System.Windows; namespace Flow.Launcher.Plugin { @@ -16,12 +17,13 @@ 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 /// - /// force requery By default, Flow Launcher will not fire query if your query is same with existing one. - /// Set this to true to force Flow Launcher requerying + /// Force requery. By default, Flow Launcher will not fire query if your query is same with existing one. + /// Set this to to force Flow Launcher requerying /// void ChangeQuery(string query, bool requery = false); @@ -38,12 +40,18 @@ namespace Flow.Launcher.Plugin /// Thrown when unable to find the file specified in the command /// Thrown when error occurs during the execution of the command void ShellRun(string cmd, string filename = "cmd.exe"); - + /// - /// Copy Text to clipboard + /// Copies the passed in text and shows a message indicating whether the operation was completed successfully. + /// When directCopy is set to true and passed in text is the path to a file or directory, + /// the actual file/directory will be copied to clipboard. Otherwise the text itself will still be copied to clipboard. /// /// Text to save on clipboard - public void CopyToClipboard(string text); + /// When true it will directly copy the file/folder from the path specified in text + /// Whether to show the default notification from this method after copy is done. + /// It will show file/folder/text is copied successfully. + /// Turn this off to show your own notification after copy is done.> + public void CopyToClipboard(string text, bool directCopy = false, bool showDefaultNotification = true); /// /// Save everything, all of Flow Launcher and plugins' data and settings @@ -80,6 +88,22 @@ namespace Flow.Launcher.Plugin /// void ShowMainWindow(); + /// + /// Hide MainWindow + /// + void HideMainWindow(); + + /// + /// Representing whether the main window is visible + /// + /// + bool IsMainWindowVisible(); + + /// + /// Invoked when the visibility of the main window has changed. Currently, the plugin will continue to be subscribed even if it is turned off. + /// + event VisibilityChangedEventHandler VisibilityChanged; + /// /// Show message box /// @@ -116,13 +140,6 @@ namespace Flow.Launcher.Plugin /// List GetAllPlugins(); - /// - /// Fired after global keyboard events - /// if you want to hook something like Ctrl+R, you should use this event - /// - [Obsolete("Unable to Retrieve correct return value")] - event FlowLauncherGlobalKeyboardEventHandler GlobalKeyboardEvent; - /// /// Register a callback for Global Keyboard Event /// @@ -165,9 +182,13 @@ 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 @@ -183,6 +204,13 @@ namespace Flow.Launcher.Plugin /// The actionkeyword that is supposed to be removed void RemoveActionKeyword(string pluginId, string oldActionKeyword); + /// + /// Check whether specific ActionKeyword is assigned to any of the plugin + /// + /// The actionkeyword for checking + /// True if the actionkeyword is already assigned, False otherwise + bool ActionKeywordAssigned(string actionKeyword); + /// /// Log debug message /// Message will only be logged in Debug mode @@ -226,8 +254,8 @@ namespace Flow.Launcher.Plugin /// Open directory in an explorer configured by user via Flow's Settings. The default is Windows Explorer /// /// Directory Path to open - /// Extra FileName Info - public void OpenDirectory(string DirectoryPath, string FileName = null); + /// Extra FileName Info + public void OpenDirectory(string DirectoryPath, string FileNameOrFilePath = null); /// /// Opens the URL with the given Uri object. @@ -252,5 +280,69 @@ namespace Flow.Launcher.Plugin /// Non-C# plugins should use this method /// public void OpenAppUri(string appUri); + + /// + /// Toggles Game Mode. off -> on and backwards + /// + public void ToggleGameMode(); + + /// + /// Switches Game Mode to given value + /// + /// New Game Mode status + public void SetGameMode(bool value); + + /// + /// Representing Game Mode status + /// + /// + public bool IsGameModeOn(); + + /// + /// Reloads the query. + /// 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(); } } diff --git a/Flow.Launcher.Plugin/Interfaces/ISavable.cs b/Flow.Launcher.Plugin/Interfaces/ISavable.cs index 2d13eaa6e..77bd304e4 100644 --- a/Flow.Launcher.Plugin/Interfaces/ISavable.cs +++ b/Flow.Launcher.Plugin/Interfaces/ISavable.cs @@ -1,12 +1,18 @@ -namespace Flow.Launcher.Plugin +namespace Flow.Launcher.Plugin { /// - /// Save addtional plugin data. Inherit this interface if additional data e.g. cache needs to be saved, - /// Otherwise if LoadSettingJsonStorage or SaveSettingJsonStorage has been callded, - /// plugin settings will be automatically saved (see Flow.Launcher/PublicAPIInstance.SavePluginSettings) by Flow + /// Inherit this interface if additional data e.g. cache needs to be saved. /// + /// + /// For storing plugin settings, prefer + /// or . + /// Once called, your settings will be automatically saved by Flow. + /// public interface ISavable : IFeatures { + /// + /// Save additional plugin data, such as cache. + /// void Save(); } } \ No newline at end of file diff --git a/Flow.Launcher.Plugin/NativeMethods.txt b/Flow.Launcher.Plugin/NativeMethods.txt new file mode 100644 index 000000000..e3e2b705e --- /dev/null +++ b/Flow.Launcher.Plugin/NativeMethods.txt @@ -0,0 +1,3 @@ +EnumThreadWindows +GetWindowText +GetWindowTextLength \ No newline at end of file diff --git a/Flow.Launcher.Plugin/PluginInitContext.cs b/Flow.Launcher.Plugin/PluginInitContext.cs index 04f20e984..f040752bd 100644 --- a/Flow.Launcher.Plugin/PluginInitContext.cs +++ b/Flow.Launcher.Plugin/PluginInitContext.cs @@ -1,7 +1,8 @@ -using System; - -namespace Flow.Launcher.Plugin +namespace Flow.Launcher.Plugin { + /// + /// Carries data passed to a plugin when it gets initialized. + /// public class PluginInitContext { public PluginInitContext() @@ -14,6 +15,9 @@ namespace Flow.Launcher.Plugin API = api; } + /// + /// The metadata of the plugin being initialized. + /// public PluginMetadata CurrentPluginMetadata { get; internal set; } /// diff --git a/Flow.Launcher.Plugin/PluginMetadata.cs b/Flow.Launcher.Plugin/PluginMetadata.cs index e8f5cf744..b4e06913e 100644 --- a/Flow.Launcher.Plugin/PluginMetadata.cs +++ b/Flow.Launcher.Plugin/PluginMetadata.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Text.Json.Serialization; diff --git a/Flow.Launcher.Plugin/Query.cs b/Flow.Launcher.Plugin/Query.cs index 95547d273..e182491c2 100644 --- a/Flow.Launcher.Plugin/Query.cs +++ b/Flow.Launcher.Plugin/Query.cs @@ -1,7 +1,4 @@ -using JetBrains.Annotations; -using System; -using System.Collections.Generic; -using System.Linq; +using System.Text.Json.Serialization; namespace Flow.Launcher.Plugin { @@ -9,88 +6,89 @@ namespace Flow.Launcher.Plugin { public Query() { } - /// - /// to allow unit tests for plug ins - /// - public Query(string rawQuery, string search, string[] terms, string[] searchTerms, string actionKeyword = "") - { - Search = search; - RawQuery = rawQuery; -#pragma warning disable CS0618 - Terms = terms; -#pragma warning restore CS0618 - SearchTerms = searchTerms; - ActionKeyword = actionKeyword; - } - /// /// Raw query, this includes action keyword if it has /// We didn't recommend use this property directly. You should always use Search property. /// public string RawQuery { get; internal init; } + /// + /// Determines whether the query was forced to execute again. + /// For example, the value will be true when the user presses Ctrl + R. + /// When this property is true, plugins handling this query should avoid serving cached results. + /// + public bool IsReQuery { get; internal set; } = false; + /// /// Search part of a query. /// This will not include action keyword if exclusive plugin gets it, otherwise it should be same as RawQuery. - /// Since we allow user to switch a exclusive plugin to generic plugin, + /// Since we allow user to switch a exclusive plugin to generic plugin, /// so this property will always give you the "real" query part of the query /// public string Search { get; internal init; } /// /// The search string split into a string array. + /// Does not include the . /// public string[] SearchTerms { get; init; } - /// - /// The raw query split into a string array - /// - [Obsolete("It may or may not include action keyword, which can be confusing. Use SearchTerms instead")] - public string[] Terms { get; init; } - /// /// Query can be splited into multiple terms by whitespace /// public const string TermSeparator = " "; - [Obsolete("Typo")] - public const string TermSeperater = TermSeparator; /// /// User can set multiple action keywords seperated by ';' /// public const string ActionKeywordSeparator = ";"; - [Obsolete("Typo")] - public const string ActionKeywordSeperater = ActionKeywordSeparator; - /// - /// '*' is used for System Plugin + /// Wildcard action keyword. Plugins using this value will be queried on every search. /// public const string GlobalPluginWildcardSign = "*"; + /// + /// The action keyword part of this query. + /// For global plugins this value will be empty. + /// public string ActionKeyword { get; init; } + [JsonIgnore] /// - /// Return first search split by space if it has + /// Splits by spaces and returns the first item. /// + /// + /// returns an empty string when does not have enough items. + /// public string FirstSearch => SplitSearch(0); - + + [JsonIgnore] private string _secondToEndSearch; - + /// /// strings from second search (including) to last search /// + [JsonIgnore] public string SecondToEndSearch => SearchTerms.Length > 1 ? (_secondToEndSearch ??= string.Join(' ', SearchTerms[1..])) : ""; /// - /// Return second search split by space if it has + /// Splits by spaces and returns the second item. /// + /// + /// returns an empty string when does not have enough items. + /// + [JsonIgnore] public string SecondSearch => SplitSearch(1); /// - /// Return third search split by space if it has + /// Splits by spaces and returns the third item. /// + /// + /// returns an empty string when does not have enough items. + /// + [JsonIgnore] public string ThirdSearch => SplitSearch(2); private string SplitSearch(int index) @@ -98,6 +96,7 @@ namespace Flow.Launcher.Plugin return index < SearchTerms.Length ? SearchTerms[index] : string.Empty; } + /// public override string ToString() => RawQuery; } } diff --git a/Flow.Launcher.Plugin/README.md b/Flow.Launcher.Plugin/README.md index 5c5b7c3ed..f3091a1fc 100644 --- a/Flow.Launcher.Plugin/README.md +++ b/Flow.Launcher.Plugin/README.md @@ -1,6 +1,7 @@ -What does Flow.Launcher.Plugin do? -==== +Reference this package to develop a plugin for [Flow Launcher](https://github.com/Flow-Launcher/Flow.Launcher). -* Defines base objects and interfaces for plugins -* Plugin authors making C# plugins should reference this DLL via nuget -* Contains commands and models that can be used by plugins +Useful links: + +* [General plugin development guide](https://www.flowlauncher.com/docs/#/plugin-dev) +* [.Net plugin development guide](https://www.flowlauncher.com/docs/#/develop-dotnet-plugins) +* [Package API Reference](https://www.flowlauncher.com/docs/#/API-Reference/Flow.Launcher.Plugin) diff --git a/Flow.Launcher.Plugin/Result.cs b/Flow.Launcher.Plugin/Result.cs index a1d3b83ab..9b16cc1cb 100644 --- a/Flow.Launcher.Plugin/Result.cs +++ b/Flow.Launcher.Plugin/Result.cs @@ -1,13 +1,15 @@ using System; +using System.Runtime; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using System.Windows.Controls; using System.Windows.Media; namespace Flow.Launcher.Plugin { /// - /// Describes the result of a plugin + /// Describes a result of a executed by a plugin /// public class Result { @@ -16,6 +18,8 @@ namespace Flow.Launcher.Plugin private string _icoPath; + private string _copyText = string.Empty; + /// /// The title of the result. This is always required. /// @@ -27,13 +31,13 @@ namespace Flow.Launcher.Plugin public string SubTitle { get; set; } = string.Empty; /// - /// This holds the action keyword that triggered the result. + /// This holds the action keyword that triggered the result. /// If result is triggered by global keyword: *, this should be empty. /// public string ActionKeywordAssigned { get; set; } /// - /// This holds the text which can be provided by plugin to be copied to the + /// This holds the text which can be provided by plugin to be copied to the /// user's clipboard when Ctrl + C is pressed on a result. If the text is a file/directory path /// flow will copy the actual file/folder instead of just the path text. /// @@ -45,24 +49,31 @@ namespace Flow.Launcher.Plugin /// /// This holds the text which can be provided by plugin to help Flow autocomplete text - /// for user on the plugin result. If autocomplete action for example is tab, pressing tab will have + /// for user on the plugin result. If autocomplete action for example is tab, pressing tab will have /// the default constructed autocomplete text (result's Title), or the text provided here if not empty. /// + /// When a value is not set, the will be used. public string AutoCompleteText { get; set; } /// - /// Image Displayed on the result - /// Relative Path to the Image File - /// GlyphInfo is prioritized if not null + /// The image to be displayed for the result. /// + /// Can be a local file path or a URL. + /// GlyphInfo is prioritized if not null public string IcoPath { get { return _icoPath; } set { - if (!string.IsNullOrEmpty(PluginDirectory) && !Path.IsPathRooted(value)) + // 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)) { - _icoPath = Path.Combine(value, IcoPath); + _icoPath = Path.Combine(PluginDirectory, value); } else { @@ -70,22 +81,22 @@ namespace Flow.Launcher.Plugin } } } + /// /// Determines if Icon has a border radius /// public bool RoundedIcon { get; set; } = false; /// - /// Delegate function, see + /// Delegate function that produces an /// /// public delegate ImageSource IconDelegate(); /// - /// Delegate to Get Image Source + /// Delegate to load an icon for this result. /// public IconDelegate Icon; - private string _copyText = string.Empty; /// /// Information for Glyph Icon (Prioritized than IcoPath/Icon if user enable Glyph Icons) @@ -94,25 +105,29 @@ namespace Flow.Launcher.Plugin /// - /// Delegate. An action to take in the form of a function call when the result has been selected - /// - /// true to hide flowlauncher after select result - /// + /// An action to take in the form of a function call when the result has been selected. /// + /// + /// The function is invoked with an as the only parameter. + /// Its result determines what happens to Flow Launcher's query form: + /// when true, the form will be hidden; when false, it will stay in focus. + /// public Func Action { get; set; } /// - /// Delegate. An Async action to take in the form of a function call when the result has been selected - /// - /// true to hide flowlauncher after select result - /// + /// An async action to take in the form of a function call when the result has been selected. /// + /// + /// The function is invoked with an as the only parameter and awaited. + /// Its result determines what happens to Flow Launcher's query form: + /// when true, the form will be hidden; when false, it will stay in focus. + /// public Func> AsyncAction { get; set; } /// /// Priority of the current result - /// default: 0 /// + /// default: 0 public int Score { get; set; } /// @@ -120,12 +135,6 @@ namespace Flow.Launcher.Plugin /// public IList TitleHighlightData { get; set; } - /// - /// Deprecated as of Flow Launcher v1.9.1. Subtitle highlighting is no longer offered - /// - [Obsolete("Deprecated as of Flow Launcher v1.9.1. Subtitle highlighting is no longer offered")] - public IList SubTitleHighlightData { get; set; } - /// /// Query information associated with the result /// @@ -140,46 +149,61 @@ namespace Flow.Launcher.Plugin set { _pluginDirectory = value; - if (!string.IsNullOrEmpty(IcoPath) && !Path.IsPathRooted(IcoPath)) - { - IcoPath = Path.Combine(value, IcoPath); - } + + // 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. + IcoPath = _icoPath; } } - /// - 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?.IcoPath, IcoPath) && - TitleHighlightData == r.TitleHighlightData; - - return equality; - } - - /// - public override int GetHashCode() - { - var hashcode = (Title?.GetHashCode() ?? 0) ^ - (SubTitle?.GetHashCode() ?? 0); - return hashcode; - } - /// public override string ToString() { - return Title + SubTitle; + 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, + ContextData = ContextData, + PluginID = PluginID, + TitleToolTip = TitleToolTip, + SubTitleToolTip = SubTitleToolTip, + PreviewPanel = PreviewPanel, + ProgressBar = ProgressBar, + ProgressBarColor = ProgressBarColor, + Preview = Preview, + AddSelectedCount = AddSelectedCount, + RecordKey = RecordKey + }; } /// /// Additional data associated with this result + /// /// /// As external information for ContextMenu /// - /// public object ContextData { get; set; } /// @@ -197,6 +221,11 @@ namespace Flow.Launcher.Plugin /// public string SubTitleToolTip { get; set; } + /// + /// Customized Preview Panel + /// + public Lazy PreviewPanel { get; set; } + /// /// Run this result, asynchronously /// @@ -217,5 +246,74 @@ namespace Flow.Launcher.Plugin /// /// #26a0da (blue) public string ProgressBarColor { get; set; } = "#26a0da"; + + /// + /// Contains data used to populate the preview section of this result. + /// + 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; + + /// + /// 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; + + /// + /// 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; + + /// + /// Info of the preview section of a + /// + public record PreviewInfo + { + /// + /// Full image used for preview panel + /// + public string PreviewImagePath { get; set; } = null; + + /// + /// Determines if the preview image should occupy the full width of the preview panel. + /// + public bool IsMedia { get; set; } = false; + + /// + /// Result description text that is shown at the bottom of the preview panel. + /// + /// + /// When a value is not set, the will be used. + /// + public string Description { get; set; } = null; + + /// + /// Delegate to get the preview panel's image + /// + public IconDelegate PreviewDelegate { get; set; } = null; + + /// + /// File path of the result. For third-party programs providing external preview. + /// + public string FilePath { get; set; } = null; + + /// + /// Default instance of + /// + public static PreviewInfo Default { get; } = new() + { + PreviewImagePath = null, + Description = null, + IsMedia = false, + PreviewDelegate = null, + FilePath = null, + }; + } } } diff --git a/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs b/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs index 5cb3a171a..5f003e351 100644 --- a/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs +++ b/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs @@ -1,7 +1,10 @@ using System; using System.Diagnostics; using System.IO; +using System.Linq; +#pragma warning disable IDE0005 using System.Windows; +#pragma warning restore IDE0005 namespace Flow.Launcher.Plugin.SharedCommands { @@ -12,15 +15,14 @@ namespace Flow.Launcher.Plugin.SharedCommands { private const string FileExplorerProgramName = "explorer"; - private const string FileExplorerProgramEXE = "explorer.exe"; - /// /// Copies the folder and all of its files and folders /// including subfolders to the target location /// /// /// - 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); @@ -53,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) @@ -61,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 } @@ -74,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 { @@ -95,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 } @@ -106,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 { @@ -118,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 } } @@ -147,9 +154,15 @@ 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 { FileName = FileExplorerProgramName, UseShellExecute = true, Arguments = '"' + fileOrFolderPath + '"' }; + var psi = new ProcessStartInfo + { + FileName = FileExplorerProgramName, + UseShellExecute = true, + Arguments = '"' + fileOrFolderPath + '"' + }; try { if (LocationExists(fileOrFolderPath) || FileExists(fileOrFolderPath)) @@ -160,18 +173,60 @@ 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 } } /// - /// Open the folder that contains + /// Open a file with associated application /// - /// - public static void OpenContainingFolder(string path) + /// File path + /// Working directory + /// Open as Administrator + /// + public static void OpenFile(string filePath, string workingDir = "", bool asAdmin = false, Func messageBoxExShow = null) { - Process.Start(FileExplorerProgramEXE, $" /select,\"{path}\""); + var psi = new ProcessStartInfo + { + FileName = filePath, + UseShellExecute = true, + WorkingDirectory = workingDir, + Verb = asAdmin ? "runas" : string.Empty + }; + try + { + if (FileExists(filePath)) + Process.Start(psi); + } + catch (Exception) + { +#if DEBUG + throw; +#else + messageBoxExShow ??= MessageBox.Show; + messageBoxExShow(string.Format("Unable to open the path {0}, please check if it exists", filePath)); +#endif + } + } + + /// + /// This checks whether a given string is a zip file path. + /// By default does not check if the zip file actually exist on disk, can do so by + /// setting checkFileExists = true. + /// + public static bool IsZipFilePath(string querySearchString, bool checkFileExists = false) + { + if (IsLocationPathString(querySearchString) && querySearchString.Split('.').Last() == "zip") + { + if (checkFileExists) + return FileExists(querySearchString); + + return true; + } + + return false; } /// @@ -206,22 +261,16 @@ namespace Flow.Launcher.Plugin.SharedCommands /// public static string GetPreviousExistingDirectory(Func locationExists, string path) { - var previousDirectoryPath = ""; var index = path.LastIndexOf('\\'); if (index > 0 && index < (path.Length - 1)) { - previousDirectoryPath = path.Substring(0, index + 1); - if (!locationExists(previousDirectoryPath)) - { - return ""; - } + string previousDirectoryPath = path.Substring(0, index + 1); + return locationExists(previousDirectoryPath) ? previousDirectoryPath : ""; } else { return ""; } - - return previousDirectoryPath; } /// @@ -241,5 +290,33 @@ namespace Flow.Launcher.Plugin.SharedCommands return path; } + + /// + /// Returns if contains . Equal paths are not considered to be contained by default. + /// From https://stackoverflow.com/a/66877016 + /// + /// Parent path + /// Sub path + /// If , when and are equal, returns + /// + public static bool PathContains(string parentPath, string subPath, bool allowEqual = false) + { + var rel = Path.GetRelativePath(parentPath.EnsureTrailingSlash(), subPath); + return (rel != "." || allowEqual) + && rel != ".." + && !rel.StartsWith("../") + && !rel.StartsWith(@"..\") + && !Path.IsPathRooted(rel); + } + + /// + /// Returns path ended with "\" + /// + /// + /// + public static string EnsureTrailingSlash(this string path) + { + return path.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + } } } diff --git a/Flow.Launcher.Plugin/SharedCommands/SearchWeb.cs b/Flow.Launcher.Plugin/SharedCommands/SearchWeb.cs index 6588132b9..a7744ffac 100644 --- a/Flow.Launcher.Plugin/SharedCommands/SearchWeb.cs +++ b/Flow.Launcher.Plugin/SharedCommands/SearchWeb.cs @@ -1,4 +1,4 @@ -using Microsoft.Win32; +using Microsoft.Win32; using System; using System.Diagnostics; using System.IO; @@ -71,12 +71,6 @@ namespace Flow.Launcher.Plugin.SharedCommands } } - [Obsolete("This is provided for backwards compatibility after 1.9.0 release, e.g. GitHub plugin. Use the new method instead")] - public static void NewBrowserWindow(this string url, string browserPath = "") - { - OpenInBrowserWindow(url, browserPath); - } - /// /// Opens search as a tab in the default browser chosen in Windows settings. /// @@ -111,11 +105,5 @@ namespace Flow.Launcher.Plugin.SharedCommands }); } } - - [Obsolete("This is provided for backwards compatibility after 1.9.0 release, e.g. GitHub plugin. Use the new method instead")] - public static void NewTabInBrowser(this string url, string browserPath = "") - { - OpenInBrowserTab(url, browserPath); - } } } \ No newline at end of file diff --git a/Flow.Launcher.Plugin/SharedCommands/ShellCommand.cs b/Flow.Launcher.Plugin/SharedCommands/ShellCommand.cs index c18f8b90c..a0440e30d 100644 --- a/Flow.Launcher.Plugin/SharedCommands/ShellCommand.cs +++ b/Flow.Launcher.Plugin/SharedCommands/ShellCommand.cs @@ -1,22 +1,16 @@ using System; -using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.IO; -using System.Linq; -using System.Runtime.InteropServices; -using System.Text; using System.Threading; -using System.Threading.Tasks; +using Windows.Win32; +using Windows.Win32.Foundation; namespace Flow.Launcher.Plugin.SharedCommands { public static class ShellCommand { 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; @@ -31,6 +25,7 @@ namespace Flow.Launcher.Plugin.SharedCommands CheckSecurityWindow(); Thread.Sleep(25); } + while (containsSecurityWindow) // while this process contains a "Windows Security" dialog, stay open { containsSecurityWindow = false; @@ -45,24 +40,33 @@ 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) + 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.Test/FilesFoldersTest.cs b/Flow.Launcher.Test/FilesFoldersTest.cs new file mode 100644 index 000000000..2621fc2da --- /dev/null +++ b/Flow.Launcher.Test/FilesFoldersTest.cs @@ -0,0 +1,54 @@ +using Flow.Launcher.Plugin.SharedCommands; +using NUnit.Framework; +using NUnit.Framework.Legacy; + +namespace Flow.Launcher.Test +{ + [TestFixture] + + public class FilesFoldersTest + { + // Testcases from https://stackoverflow.com/a/31941905/20703207 + // Disk + [TestCase(@"c:", @"c:\foo", true)] + [TestCase(@"c:\", @"c:\foo", true)] + // Slash + [TestCase(@"c:\foo\bar\", @"c:\foo\", false)] + [TestCase(@"c:\foo\bar", @"c:\foo\", false)] + [TestCase(@"c:\foo", @"c:\foo\bar", true)] + [TestCase(@"c:\foo\", @"c:\foo\bar", true)] + // File + [TestCase(@"c:\foo", @"c:\foo\a.txt", true)] + [TestCase(@"c:\foo", @"c:/foo/a.txt", true)] + [TestCase(@"c:\FOO\a.txt", @"c:\foo", false)] + [TestCase(@"c:\foo\a.txt", @"c:\foo\", false)] + [TestCase(@"c:\foobar\a.txt", @"c:\foo", false)] + [TestCase(@"c:\foobar\a.txt", @"c:\foo\", false)] + [TestCase(@"c:\foo\", @"c:\foo.txt", false)] + // Prefix + [TestCase(@"c:\foo", @"c:\foobar", false)] + [TestCase(@"C:\Program", @"C:\Program Files\", false)] + [TestCase(@"c:\foobar", @"c:\foo\a.txt", false)] + [TestCase(@"c:\foobar\", @"c:\foo\a.txt", false)] + // Edge case + [TestCase(@"c:\foo", @"c:\foo\..\bar\baz", false)] + [TestCase(@"c:\bar", @"c:\foo\..\bar\baz", true)] + [TestCase(@"c:\barr", @"c:\foo\..\bar\baz", false)] + public void GivenTwoPaths_WhenCheckPathContains_ThenShouldBeExpectedResult(string parentPath, string path, bool expectedResult) + { + ClassicAssert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path)); + } + + // Equality + [TestCase(@"c:\foo", @"c:\foo", false)] + [TestCase(@"c:\foo\", @"c:\foo", false)] + [TestCase(@"c:\foo", @"c:\foo\", false)] + [TestCase(@"c:\foo", @"c:\foo", true)] + [TestCase(@"c:\foo\", @"c:\foo", true)] + [TestCase(@"c:\foo", @"c:\foo\", true)] + public void GivenTwoPathsAreTheSame_WhenCheckPathContains_ThenShouldBeExpectedResult(string parentPath, string path, bool 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 c4341288f..0241a374e 100644 --- a/Flow.Launcher.Test/Flow.Launcher.Test.csproj +++ b/Flow.Launcher.Test/Flow.Launcher.Test.csproj @@ -1,7 +1,7 @@ - + - net6.0-windows10.0.19041.0 + net7.0-windows10.0.19041.0 {FF742965-9A80-41A5-B042-D6C7D3A21708} Library Properties @@ -48,13 +48,13 @@ - - - + + + 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 637747a07..4f135978a 100644 --- a/Flow.Launcher.Test/HttpTest.cs +++ b/Flow.Launcher.Test/HttpTest.cs @@ -1,7 +1,6 @@ -using NUnit.Framework; +using NUnit.Framework; +using NUnit.Framework.Legacy; using System; -using System.Collections.Generic; -using System.Text; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Infrastructure.Http; @@ -18,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 78be463e4..420da266d 100644 --- a/Flow.Launcher.Test/Plugins/ExplorerTest.cs +++ b/Flow.Launcher.Test/Plugins/ExplorerTest.cs @@ -5,10 +5,11 @@ 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.Threading; -using System.Threading.Tasks; +using System.IO; +using System.Runtime.Versioning; +using static Flow.Launcher.Plugin.Explorer.Search.SearchManager; namespace Flow.Launcher.Test.Plugins { @@ -19,49 +20,25 @@ 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; + [SupportedOSPlatform("windows7.0")] [TestCase("C:\\SomeFolder\\", "directory='file:C:\\SomeFolder\\'")] public void GivenWindowsIndexSearch_WhenProvidedFolderPath_ThenQueryWhereRestrictionsShouldUseDirectoryString(string path, string expectedString) { - // Given - var queryConstructor = new QueryConstructor(new Settings()); - // When var folderPath = path; - var result = queryConstructor.QueryWhereRestrictionsForTopLevelDirectorySearch(folderPath); + 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")] public void GivenWindowsIndexSearch_WhenSearchTypeIsTopLevelDirectorySearch_ThenQueryShouldUseExpectedString(string folderPath, string expectedString) @@ -70,148 +47,87 @@ namespace Flow.Launcher.Test.Plugins var queryConstructor = new QueryConstructor(new Settings()); //When - var queryString = queryConstructor.QueryForTopLevelDirectorySearch(folderPath); + var queryString = queryConstructor.Directory(folderPath); // Then - Assert.IsTrue(queryString == expectedString, + ClassicAssert.IsTrue(queryString.Replace(" ", " ") == expectedString.Replace(" ", " "), $"Expected string: {expectedString}{Environment.NewLine} " + $"Actual string was: {queryString}{Environment.NewLine}"); } - [TestCase("C:\\SomeFolder\\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 directory='file:C:\\SomeFolder' ORDER BY System.FileName")] + [SupportedOSPlatform("windows7.0")] + [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")] public void GivenWindowsIndexSearchTopLevelDirectory_WhenSearchingForSpecificItem_ThenQueryShouldUseExpectedString( - string userSearchString, string expectedString) + string folderPath, string userSearchString, string expectedString) { // Given var queryConstructor = new QueryConstructor(new Settings()); //When - var queryString = queryConstructor.QueryForTopLevelDirectorySearch(userSearchString); + var queryString = queryConstructor.Directory(folderPath, userSearchString); // Then - Assert.IsTrue(queryString == expectedString, - $"Expected string: {expectedString}{Environment.NewLine} " + - $"Actual string was: {queryString}{Environment.NewLine}"); - } - - [TestCase("C:\\SomeFolder\\SomeApp", "(System.FileName LIKE 'SomeApp%' " + - "OR CONTAINS(System.FileName,'\"SomeApp*\"',1033))" + - " AND directory='file:C:\\SomeFolder'")] - public void GivenWindowsIndexSearchTopLevelDirectory_WhenSearchingForSpecificItem_ThenQueryWhereRestrictionsShouldUseDirectoryString( - string userSearchString, string expectedString) - { - // Given - var queryConstructor = new QueryConstructor(new Settings()); - - //When - var queryString = queryConstructor.QueryWhereRestrictionsForTopLevelDirectorySearch(userSearchString); - - // Then - Assert.IsTrue(queryString == expectedString, - $"Expected string: {expectedString}{Environment.NewLine} " + - $"Actual string was: {queryString}{Environment.NewLine}"); + ClassicAssert.AreEqual(expectedString, queryString); } + [SupportedOSPlatform("windows7.0")] [TestCase("scope='file:'")] public void GivenWindowsIndexSearch_WhenSearchAllFoldersAndFiles_ThenQueryWhereRestrictionsShouldUseScopeString(string expectedString) { //When - var resultString = QueryConstructor.QueryWhereRestrictionsForAllFilesAndFoldersSearch; + const string resultString = QueryConstructor.RestrictionsForAllFilesAndFoldersSearch; // Then - Assert.IsTrue(resultString == expectedString, - $"Expected QueryWhereRestrictions string: {expectedString}{Environment.NewLine} " + - $"Actual string was: {resultString}{Environment.NewLine}"); + 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")] + "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")] public void GivenWindowsIndexSearch_WhenSearchAllFoldersAndFiles_ThenQueryShouldUseExpectedString( string userSearchString, string expectedString) { // Given var queryConstructor = new QueryConstructor(new Settings()); - var baseQuery = queryConstructor.CreateBaseQuery(); - + var baseQuery = queryConstructor.CreateBaseQuery(); + // system running this test could have different locale than the hard-coded 1033 LCID en-US. var queryKeywordLocale = baseQuery.QueryKeywordLocale; expectedString = expectedString.Replace("1033", queryKeywordLocale.ToString()); - - //When - var resultString = queryConstructor.QueryForAllFilesAndFolders(userSearchString); + var resultString = queryConstructor.FilesAndFolders(userSearchString); // Then - Assert.IsTrue(resultString == expectedString, - $"Expected query string: {expectedString}{Environment.NewLine} " + - $"Actual string was: {resultString}{Environment.NewLine}"); + ClassicAssert.AreEqual(expectedString, resultString); } - [TestCase] - public async Task GivenTopLevelDirectorySearch_WhenIndexSearchNotRequired_ThenSearchMethodShouldContinueDirectoryInfoClassSearchAsync() - { - // Given - var searchManager = new SearchManager(new Settings(), new PluginInitContext()); - - // When - var results = await searchManager.TopLevelDirectorySearchBehaviourAsync( - MethodWindowsIndexSearchReturnsZeroResultsAsync, - MethodDirectoryInfoClassSearchReturnsTwoResults, - false, - new Query(), - "string not used", - default); - - // Then - Assert.IsTrue(results.Count == 2, - $"Expected to have 2 results from DirectoryInfoClassSearch {Environment.NewLine} " + - $"Actual number of results is {results.Count} {Environment.NewLine}"); - } - - [TestCase] - public async Task GivenTopLevelDirectorySearch_WhenIndexSearchNotRequired_ThenSearchMethodShouldNotContinueDirectoryInfoClassSearchAsync() - { - // Given - var searchManager = new SearchManager(new Settings(), new PluginInitContext()); - - // When - var results = await searchManager.TopLevelDirectorySearchBehaviourAsync( - MethodWindowsIndexSearchReturnsZeroResultsAsync, - MethodDirectoryInfoClassSearchReturnsTwoResults, - true, - new Query(), - "string not used", - default); - - // Then - Assert.IsTrue(results.Count == 0, - $"Expected to have 0 results because location is indexed {Environment.NewLine} " + - $"Actual number of results is {results.Count} {Environment.NewLine}"); - } + [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.QueryWhereRestrictionsForFileContentSearch(querySearchString); + 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 System.FileName")] public void GivenWindowsIndexSearch_WhenSearchForFileContent_ThenQueryShouldUseExpectedString( string userSearchString, string expectedString) { @@ -219,18 +135,21 @@ namespace Flow.Launcher.Test.Plugins var queryConstructor = new QueryConstructor(new Settings()); //When - var resultString = queryConstructor.QueryForFileContentSearch(userSearchString); + 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 { ActionKeyword = "doc:", Search = "search term" }; + var query = new Query + { + ActionKeyword = "doc:", Search = "search term" + }; var searchManager = new SearchManager(new Settings(), new PluginInitContext()); @@ -238,7 +157,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}"); } @@ -248,17 +167,22 @@ namespace Flow.Launcher.Test.Plugins [TestCase(@"\c:\", false)] [TestCase(@"cc:\", false)] [TestCase(@"\\\SomeNetworkLocation\", false)] + [TestCase(@"\\SomeNetworkLocation\", true)] [TestCase("RandomFile", false)] [TestCase(@"c:\>*", true)] [TestCase(@"c:\>", true)] [TestCase(@"c:\SomeLocation\SomeOtherLocation\>", true)] + [TestCase(@"c:\SomeLocation\SomeOtherLocation", true)] + [TestCase(@"c:\SomeLocation\SomeOtherLocation\SomeFile.exe", true)] + [TestCase(@"\\SomeNetworkLocation\SomeFile.exe", true)] + public void WhenGivenQuerySearchString_ThenShouldIndicateIfIsLocationPathString(string querySearchString, bool expectedResult) { // When, Given 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}"); @@ -285,7 +209,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}"); } @@ -298,29 +222,24 @@ 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}"); } - [TestCase("c:\\SomeFolder\\>", "scope='file:c:\\SomeFolder'")] - [TestCase("c:\\SomeFolder\\>SomeName", "(System.FileName LIKE 'SomeName%' " - + "OR CONTAINS(System.FileName,'\"SomeName*\"',1033)) AND " - + "scope='file:c:\\SomeFolder'")] - public void GivenWindowsIndexSearch_WhenSearchPatternHotKeyIsSearchAll_ThenQueryWhereRestrictionsShouldUseScopeString(string path, string expectedString) + [SupportedOSPlatform("windows7.0")] + [TestCase("c:\\SomeFolder", "scope='file:c:\\SomeFolder'")] + [TestCase("c:\\OtherFolder", "scope='file:c:\\OtherFolder'")] + public void GivenFilePath_WhenSearchPatternHotKeyIsSearchAll_ThenQueryWhereRestrictionsShouldUseScopeString(string path, string expectedString) { - // Given - var queryConstructor = new QueryConstructor(new Settings()); - //When - var resultString = queryConstructor.QueryWhereRestrictionsForTopLevelDirectoryAllFilesAndFoldersSearch(path); + var resultString = QueryConstructor.RecursiveDirectoryConstraint(path); // Then - Assert.IsTrue(resultString == expectedString, - $"Expected QueryWhereRestrictions string: {expectedString}{Environment.NewLine} " + - $"Actual string was: {resultString}{Environment.NewLine}"); + ClassicAssert.AreEqual(expectedString, resultString); } + [SupportedOSPlatform("windows7.0")] [TestCase("c:\\somefolder\\>somefile", "*somefile*")] [TestCase("c:\\somefolder\\somefile", "somefile*")] [TestCase("c:\\somefolder\\", "*")] @@ -331,9 +250,194 @@ namespace Flow.Launcher.Test.Plugins var resultString = DirectoryInfoSearch.ConstructSearchCriteria(path); // Then - Assert.IsTrue(resultString == expectedString, - $"Expected criteria string: {expectedString}{Environment.NewLine} " + - $"Actual criteria string was: {resultString}{Environment.NewLine}"); + ClassicAssert.AreEqual(expectedString, resultString); + } + + [TestCase("c:\\somefolder\\someotherfolder", ResultType.Folder, "irrelevant", false, true, "c:\\somefolder\\someotherfolder\\")] + [TestCase("c:\\somefolder\\someotherfolder\\", ResultType.Folder, "irrelevant", true, true, "c:\\somefolder\\someotherfolder\\")] + [TestCase("c:\\somefolder\\someotherfolder", ResultType.Folder, "irrelevant", true, false, "p c:\\somefolder\\someotherfolder\\")] + [TestCase("c:\\somefolder\\someotherfolder\\", ResultType.Folder, "irrelevant", false, false, "c:\\somefolder\\someotherfolder\\")] + [TestCase("c:\\somefolder\\someotherfolder", ResultType.Folder, "p", true, false, "p c:\\somefolder\\someotherfolder\\")] + [TestCase("c:\\somefolder\\someotherfolder", ResultType.Folder, "", true, true, "c:\\somefolder\\someotherfolder\\")] + public void GivenFolderResult_WhenGetPath_ThenPathShouldBeExpectedString( + string path, + ResultType type, + string actionKeyword, + bool pathSearchKeywordEnabled, + bool searchActionKeywordEnabled, + string expectedResult) + { + // Given + var settings = new Settings() + { + PathSearchKeywordEnabled = pathSearchKeywordEnabled, + PathSearchActionKeyword = "p", + SearchActionKeywordEnabled = searchActionKeywordEnabled, + SearchActionKeyword = Query.GlobalPluginWildcardSign + }; + ResultManager.Init(new PluginInitContext(), settings); + + // When + var result = ResultManager.GetPathWithActionKeyword(path, type, actionKeyword); + + // Then + ClassicAssert.AreEqual(result, expectedResult); + } + + [TestCase("c:\\somefolder\\somefile", ResultType.File, "irrelevant", false, true, "e c:\\somefolder\\somefile")] + [TestCase("c:\\somefolder\\somefile", ResultType.File, "p", true, false, "p c:\\somefolder\\somefile")] + [TestCase("c:\\somefolder\\somefile", ResultType.File, "e", true, true, "e c:\\somefolder\\somefile")] + [TestCase("c:\\somefolder\\somefile", ResultType.File, "irrelevant", false, false, "e c:\\somefolder\\somefile")] + public void GivenFileResult_WhenGetPath_ThenPathShouldBeExpectedString( + string path, + ResultType type, + string actionKeyword, + bool pathSearchKeywordEnabled, + bool searchActionKeywordEnabled, + string expectedResult) + { + // Given + var settings = new Settings() + { + PathSearchKeywordEnabled = pathSearchKeywordEnabled, + PathSearchActionKeyword = "p", + SearchActionKeywordEnabled = searchActionKeywordEnabled, + SearchActionKeyword = "e" + }; + ResultManager.Init(new PluginInitContext(), settings); + + // When + var result = ResultManager.GetPathWithActionKeyword(path, type, actionKeyword); + + // Then + ClassicAssert.AreEqual(result, expectedResult); + } + + [TestCase("somefolder", "c:\\somefolder\\", ResultType.Folder, "q", false, false, "q somefolder")] + [TestCase("somefolder", "c:\\somefolder\\", ResultType.Folder, "i", true, false, "p c:\\somefolder\\")] + [TestCase("somefolder", "c:\\somefolder\\", ResultType.Folder, "irrelevant", true, true, "c:\\somefolder\\")] + public void GivenQueryWithFolderTypeResult_WhenGetAutoComplete_ThenResultShouldBeExpectedString( + string title, + string path, + ResultType resultType, + string actionKeyword, + bool pathSearchKeywordEnabled, + bool searchActionKeywordEnabled, + string expectedResult) + { + // Given + var query = new Query() { ActionKeyword = actionKeyword }; + var settings = new Settings() + { + PathSearchKeywordEnabled = pathSearchKeywordEnabled, + PathSearchActionKeyword = "p", + SearchActionKeywordEnabled = searchActionKeywordEnabled, + SearchActionKeyword = Query.GlobalPluginWildcardSign, + QuickAccessActionKeyword = "q", + IndexSearchActionKeyword = "i" + }; + ResultManager.Init(new PluginInitContext(), settings); + + // When + var result = ResultManager.GetAutoCompleteText(title, query, path, resultType); + + // Then + ClassicAssert.AreEqual(result, expectedResult); + } + + [TestCase("somefile", "c:\\somefolder\\somefile", ResultType.File, "q", false, false, "q somefile")] + [TestCase("somefile", "c:\\somefolder\\somefile", ResultType.File, "i", true, false, "p c:\\somefolder\\somefile")] + [TestCase("somefile", "c:\\somefolder\\somefile", ResultType.File, "irrelevant", true, true, "c:\\somefolder\\somefile")] + public void GivenQueryWithFileTypeResult_WhenGetAutoComplete_ThenResultShouldBeExpectedString( + string title, + string path, + ResultType resultType, + string actionKeyword, + bool pathSearchKeywordEnabled, + bool searchActionKeywordEnabled, + string expectedResult) + { + // Given + var query = new Query() { ActionKeyword = actionKeyword }; + var settings = new Settings() + { + QuickAccessActionKeyword = "q", + IndexSearchActionKeyword = "i", + PathSearchActionKeyword = "p", + PathSearchKeywordEnabled = pathSearchKeywordEnabled, + SearchActionKeywordEnabled = searchActionKeywordEnabled, + SearchActionKeyword = Query.GlobalPluginWildcardSign + }; + ResultManager.Init(new PluginInitContext(), settings); + + // When + var result = ResultManager.GetAutoCompleteText(title, query, path, resultType); + + // Then + ClassicAssert.AreEqual(result, expectedResult); + } + + [TestCase(@"c:\foo", @"c:\foo", true)] + [TestCase(@"C:\Foo\", @"c:\foo\", true)] + [TestCase(@"c:\foo", @"c:\foo\", false)] + public void GivenTwoPaths_WhenCompared_ThenShouldBeExpectedSameOrDifferent(string path1, string path2, bool expectedResult) + { + // Given + var comparator = PathEqualityComparator.Instance; + var result1 = new Result + { + Title = Path.GetFileName(path1), + SubTitle = path1 + }; + var result2 = new Result + { + Title = Path.GetFileName(path2), + SubTitle = path2 + }; + + // When, Then + ClassicAssert.AreEqual(expectedResult, comparator.Equals(result1, result2)); + } + + [TestCase(@"c:\foo\", @"c:\foo\")] + [TestCase(@"C:\Foo\", @"c:\foo\")] + public void GivenTwoPaths_WhenComparedHasCode_ThenShouldBeSame(string path1, string path2) + { + // Given + var comparator = PathEqualityComparator.Instance; + var result1 = new Result + { + Title = Path.GetFileName(path1), + SubTitle = path1 + }; + var result2 = new Result + { + Title = Path.GetFileName(path2), + SubTitle = path2 + }; + + var hash1 = comparator.GetHashCode(result1); + var hash2 = comparator.GetHashCode(result2); + + // When, Then + ClassicAssert.IsTrue(hash1 == hash2); + } + + [TestCase(@"%appdata%", true)] + [TestCase(@"%appdata%\123", true)] + [TestCase(@"c:\foo %appdata%\", false)] + [TestCase(@"c:\users\%USERNAME%\downloads", true)] + [TestCase(@"c:\downloads", false)] + [TestCase(@"%", false)] + [TestCase(@"%%", false)] + [TestCase(@"%bla%blabla%", false)] + public void GivenPath_WhenHavingEnvironmentVariableOrNot_ThenShouldBeExpected(string path, bool expectedResult) + { + // When + var result = EnvironmentVariables.HasEnvironmentVar(path); + + // Then + ClassicAssert.AreEqual(result, expectedResult); } } } diff --git a/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs b/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs index fb91c6388..497f874e7 100644 --- a/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs @@ -1,13 +1,11 @@ -using NUnit; -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 @@ -16,8 +14,6 @@ namespace Flow.Launcher.Test.Plugins // ReSharper disable once InconsistentNaming internal class JsonRPCPluginTest : JsonRPCPlugin { - public override string SupportedLanguage { get; set; } = AllowedLanguage.Executable; - protected override string Request(JsonRPCRequestModel rpcRequest, CancellationToken token = default) { throw new System.NotImplementedException(); @@ -43,58 +39,27 @@ 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); } } public static List ResponseModelsSource = new() { - new() + new JsonRPCQueryResponseModel(0, new List()), + new JsonRPCQueryResponseModel(0, new List { - Result = new() - }, - new() - { - Result = new() + new() { - new JsonRPCResult - { - Title = "Test1", - SubTitle = "Test2" - } + 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); - } - } - } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Test/Plugins/ProgramTest.cs b/Flow.Launcher.Test/Plugins/ProgramTest.cs deleted file mode 100644 index e3a05f484..000000000 --- a/Flow.Launcher.Test/Plugins/ProgramTest.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Flow.Launcher.Plugin.Program.Programs; -using NUnit.Framework; -using System; -using Windows.ApplicationModel; - -namespace Flow.Launcher.Test.Plugins -{ - [TestFixture] - public class ProgramTest - { - [TestCase("Microsoft.WindowsCamera", "ms-resource:LensSDK/Resources/AppTitle", "ms-resource://Microsoft.WindowsCamera/LensSDK/Resources/AppTitle")] - [TestCase("microsoft.windowscommunicationsapps", "ms-resource://microsoft.windowscommunicationsapps/hxoutlookintl/AppManifest_MailDesktop_DisplayName", - "ms-resource://microsoft.windowscommunicationsapps/hxoutlookintl/AppManifest_MailDesktop_DisplayName")] - [TestCase("windows.immersivecontrolpanel", "ms-resource:DisplayName", "ms-resource://windows.immersivecontrolpanel/Resources/DisplayName")] - [TestCase("Microsoft.MSPaint", "ms-resource:AppName", "ms-resource://Microsoft.MSPaint/Resources/AppName")] - public void WhenGivenPriReferenceValueShouldReturnCorrectFormat(string packageName, string rawPriReferenceValue, string expectedFormat) - { - // Arrange - var app = new UWP.Application(); - - // Act - var result = UWP.Application.FormattedPriReferenceValue(packageName, rawPriReferenceValue); - - // Assert - Assert.IsTrue(result == expectedFormat, - $"Expected Pri reference format: {expectedFormat}{Environment.NewLine} " + - $"Actual: {result}{Environment.NewLine}"); - } - } -} 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 45ff8fc9e..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; @@ -15,10 +16,19 @@ namespace Flow.Launcher.Test {">", new PluginPair {Metadata = new PluginMetadata {ActionKeywords = new List {">"}}}} }; - Query q = QueryBuilder.Build("> file.txt file2 file3", nonGlobalPlugins); + Query q = QueryBuilder.Build("> ping google.com -n 20 -6", nonGlobalPlugins); - Assert.AreEqual("file.txt file2 file3", q.Search); - 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); + + ClassicAssert.AreEqual(5, q.SearchTerms.Length, "The length of SearchTerms should match."); + + ClassicAssert.AreEqual("ping", q.FirstSearch); + ClassicAssert.AreEqual("google.com", q.SecondSearch); + ClassicAssert.AreEqual("-n", q.ThirdSearch); + + ClassicAssert.AreEqual("google.com -n 20 -6", q.SecondToEndSearch, "SecondToEndSearch should be trimmed of multiple whitespace characters"); } [Test] @@ -29,9 +39,13 @@ namespace Flow.Launcher.Test {">", new PluginPair {Metadata = new PluginMetadata {ActionKeywords = new List {">"}, Disabled = true}}} }; - Query q = QueryBuilder.Build("> file.txt file2 file3", nonGlobalPlugins); + Query q = QueryBuilder.Build("> ping google.com -n 20 -6", nonGlobalPlugins); - Assert.AreEqual("> file.txt file2 file3", q.Search); + 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] @@ -39,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.sln b/Flow.Launcher.sln index f59d3d26f..e44b23232 100644 --- a/Flow.Launcher.sln +++ b/Flow.Launcher.sln @@ -2,15 +2,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32901.215 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Flow.Launcher.Test", "Flow.Launcher.Test\Flow.Launcher.Test.csproj", "{FF742965-9A80-41A5-B042-D6C7D3A21708}" - ProjectSection(ProjectDependencies) = postProject - {DB90F671-D861-46BB-93A3-F1304F5BA1C5} = {DB90F671-D861-46BB-93A3-F1304F5BA1C5} - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Flow.Launcher.Plugin", "Flow.Launcher.Plugin\Flow.Launcher.Plugin.csproj", "{8451ECDD-2EA4-4966-BB0A-7BBC40138E80}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", "{3A73F5A7-0335-40D8-BF7C-F20BE5D0BA87}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Flow.Launcher", "Flow.Launcher\Flow.Launcher.csproj", "{DB90F671-D861-46BB-93A3-F1304F5BA1C5}" ProjectSection(ProjectDependencies) = postProject {0B9DE348-9361-4940-ADB6-F5953BFFCCEC} = {0B9DE348-9361-4940-ADB6-F5953BFFCCEC} @@ -27,6 +18,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Flow.Launcher", "Flow.Launc {588088F4-3262-4F9F-9663-A05DE12534C3} = {588088F4-3262-4F9F-9663-A05DE12534C3} EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Flow.Launcher.Test", "Flow.Launcher.Test\Flow.Launcher.Test.csproj", "{FF742965-9A80-41A5-B042-D6C7D3A21708}" + ProjectSection(ProjectDependencies) = postProject + {DB90F671-D861-46BB-93A3-F1304F5BA1C5} = {DB90F671-D861-46BB-93A3-F1304F5BA1C5} + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Flow.Launcher.Plugin", "Flow.Launcher.Plugin\Flow.Launcher.Plugin.csproj", "{8451ECDD-2EA4-4966-BB0A-7BBC40138E80}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", "{3A73F5A7-0335-40D8-BF7C-F20BE5D0BA87}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Flow.Launcher.Infrastructure", "Flow.Launcher.Infrastructure\Flow.Launcher.Infrastructure.csproj", "{4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Flow.Launcher.Core", "Flow.Launcher.Core\Flow.Launcher.Core.csproj", "{B749F0DB-8E75-47DB-9E5E-265D16D0C0D2}" @@ -47,12 +47,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .gitattributes = .gitattributes .gitignore = .gitignore appveyor.yml = appveyor.yml + Directory.Build.props = Directory.Build.props Directory.Build.targets = Directory.Build.targets Scripts\flowlauncher.nuspec = Scripts\flowlauncher.nuspec LICENSE = LICENSE Scripts\post_build.ps1 = Scripts\post_build.ps1 README.md = README.md SolutionAssemblyInfo.cs = SolutionAssemblyInfo.cs + Settings.XamlStyler = Settings.XamlStyler EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Flow.Launcher.Plugin.Shell", "Plugins\Flow.Launcher.Plugin.Shell\Flow.Launcher.Plugin.Shell.csproj", "{C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0}" @@ -80,7 +82,7 @@ Global EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|Any CPU.Build.0 = Debug|Any CPU {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|x64.ActiveCfg = Debug|Any CPU {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|x64.Build.0 = Debug|Any CPU {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|x86.ActiveCfg = Debug|Any CPU diff --git a/Flow.Launcher/ActionKeywords.xaml.cs b/Flow.Launcher/ActionKeywords.xaml.cs index e116e6f4d..c3966e618 100644 --- a/Flow.Launcher/ActionKeywords.xaml.cs +++ b/Flow.Launcher/ActionKeywords.xaml.cs @@ -1,31 +1,22 @@ using System.Windows; -using Flow.Launcher.Core.Plugin; using Flow.Launcher.Core.Resource; -using Flow.Launcher.Infrastructure.Exception; -using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using Flow.Launcher.ViewModel; +using Flow.Launcher.Core; namespace Flow.Launcher { - public partial class ActionKeywords : Window + public partial class ActionKeywords { private readonly PluginPair plugin; - private Settings settings; private readonly Internationalization translater = InternationalizationManager.Instance; private readonly PluginViewModel pluginViewModel; - public ActionKeywords(string pluginId, Settings settings, PluginViewModel pluginViewModel) + public ActionKeywords(PluginViewModel pluginViewModel) { InitializeComponent(); - plugin = PluginManager.GetPluginForId(pluginId); - this.settings = settings; + plugin = pluginViewModel.PluginPair; this.pluginViewModel = pluginViewModel; - if (plugin == null) - { - MessageBox.Show(translater.GetTranslation("cannotFindSpecifiedPlugin")); - Close(); - } } private void ActionKeyword_OnLoaded(object sender, RoutedEventArgs e) @@ -44,6 +35,7 @@ namespace Flow.Launcher var oldActionKeyword = plugin.Metadata.ActionKeywords[0]; var newActionKeyword = tbAction.Text.Trim(); newActionKeyword = newActionKeyword.Length > 0 ? newActionKeyword : "*"; + if (!PluginViewModel.IsActionKeywordRegistered(newActionKeyword)) { pluginViewModel.ChangeActionKeyword(newActionKeyword, oldActionKeyword); @@ -52,7 +44,7 @@ namespace Flow.Launcher else { string msg = translater.GetTranslation("newActionKeywordsHasBeenAssigned"); - MessageBox.Show(msg); + App.API.ShowMsgBox(msg); } } } diff --git a/Flow.Launcher/App.xaml b/Flow.Launcher/App.xaml index b8e2a1cfe..17c0ae0d5 100644 --- a/Flow.Launcher/App.xaml +++ b/Flow.Launcher/App.xaml @@ -20,11 +20,17 @@ + + + + + - + + diff --git a/Flow.Launcher/App.xaml.cs b/Flow.Launcher/App.xaml.cs index cb8e79d63..550b1bdae 100644 --- a/Flow.Launcher/App.xaml.cs +++ b/Flow.Launcher/App.xaml.cs @@ -1,11 +1,13 @@ using System; using System.Diagnostics; using System.Text; +using System.Threading; using System.Threading.Tasks; -using System.Timers; using System.Windows; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Core; using Flow.Launcher.Core.Configuration; +using Flow.Launcher.Core.ExternalPlugins.Environments; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Core.Resource; using Flow.Launcher.Helper; @@ -13,24 +15,86 @@ using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Http; using Flow.Launcher.Infrastructure.Image; using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.Storage; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; using Flow.Launcher.ViewModel; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Stopwatch = Flow.Launcher.Infrastructure.Stopwatch; namespace Flow.Launcher { public partial class App : IDisposable, ISingleInstanceApp { - public static PublicAPIInstance API { get; private set; } + public static IPublicAPI API { get; private set; } private const string Unique = "Flow.Launcher_Unique_Application_Mutex"; private static bool _disposed; - private Settings _settings; - private MainViewModel _mainVM; - private SettingWindowViewModel _settingsVM; - private readonly Updater _updater = new Updater(Flow.Launcher.Properties.Settings.Default.GithubRepo); - private readonly Portable _portable = new Portable(); - private readonly PinyinAlphabet _alphabet = new PinyinAlphabet(); - private StringMatcher _stringMatcher; + private readonly Settings _settings; + + public App() + { + // Initialize settings + try + { + var storage = new FlowLauncherJsonStorage(); + _settings = storage.Load(); + _settings.SetStorage(storage); + _settings.WMPInstalled = WindowsMediaPlayerHelper.IsWindowsMediaPlayerInstalled(); + } + catch (Exception e) + { + ShowErrorMsgBoxAndFailFast("Cannot load setting storage, please check local data directory", e); + return; + } + + // Configure the dependency injection container + try + { + var host = Host.CreateDefaultBuilder() + .UseContentRoot(AppContext.BaseDirectory) + .ConfigureServices(services => services + .AddSingleton(_ => _settings) + .AddSingleton(sp => new Updater(sp.GetRequiredService(), Launcher.Properties.Settings.Default.GithubRepo)) + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + ).Build(); + Ioc.Default.ConfigureServices(host.Services); + } + catch (Exception e) + { + ShowErrorMsgBoxAndFailFast("Cannot configure dependency injection container, please open new issue in Flow.Launcher", e); + return; + } + + // Initialize the public API and Settings first + try + { + API = Ioc.Default.GetRequiredService(); + _settings.Initialize(); + } + catch (Exception e) + { + ShowErrorMsgBoxAndFailFast("Cannot initialize api and settings, please open new issue in Flow.Launcher", e); + return; + } + } + + private static void ShowErrorMsgBoxAndFailFast(string message, Exception e) + { + // Firstly show users the message + MessageBox.Show(e.ToString(), message, MessageBoxButton.OK, MessageBoxImage.Error); + + // Flow cannot construct its App instance, so ensure Flow crashes w/ the exception info. + Environment.FailFast(message, e); + } [STAThread] public static void Main() @@ -49,47 +113,42 @@ namespace Flow.Launcher { await Stopwatch.NormalAsync("|App.OnStartup|Startup cost", async () => { - _portable.PreStartCleanUpAfterPortabilityUpdate(); + Log.SetLogLevel(_settings.LogLevel); + + Ioc.Default.GetRequiredService().PreStartCleanUpAfterPortabilityUpdate(); Log.Info("|App.OnStartup|Begin Flow Launcher startup ----------------------------------------------------"); Log.Info($"|App.OnStartup|Runtime info:{ErrorReporting.RuntimeInfo()}"); + RegisterAppDomainExceptions(); RegisterDispatcherUnhandledException(); - ImageLoader.Initialize(); + var imageLoadertask = ImageLoader.InitializeAsync(); - _settingsVM = new SettingWindowViewModel(_updater, _portable); - _settings = _settingsVM.Settings; + AbstractPluginEnvironment.PreStartPluginExecutablePathUpdate(_settings); - _alphabet.Initialize(_settings); - _stringMatcher = new StringMatcher(_alphabet); - StringMatcher.Instance = _stringMatcher; - _stringMatcher.UserSettingSearchPrecision = _settings.QuerySearchPrecision; + // TODO: Clean InternationalizationManager.Instance and InternationalizationManager.Instance.GetTranslation in future + InternationalizationManager.Instance.ChangeLanguage(_settings.Language); PluginManager.LoadPlugins(_settings.PluginSettings); - _mainVM = new MainViewModel(_settings); - API = new PublicAPIInstance(_settingsVM, _mainVM, _alphabet); - - Http.API = API; Http.Proxy = _settings.Proxy; - await PluginManager.InitializePluginsAsync(API); - var window = new MainWindow(_settings, _mainVM); + await PluginManager.InitializePluginsAsync(); + await imageLoadertask; + + var mainVM = Ioc.Default.GetRequiredService(); + var window = new MainWindow(_settings, mainVM); Log.Info($"|App.OnStartup|Dependencies Info:{ErrorReporting.DependenciesInfo()}"); Current.MainWindow = window; Current.MainWindow.Title = Constant.FlowLauncher; - - HotKeyMapper.Initialize(_mainVM); - // happlebao todo temp fix for instance code logic - // load plugin before change language, because plugin language also needs be changed - InternationalizationManager.Instance.Settings = _settings; - InternationalizationManager.Instance.ChangeLanguage(_settings.Language); - // main windows needs initialized before theme change because of blur settigns - ThemeManager.Instance.Settings = _settings; + HotKeyMapper.Initialize(); + + // main windows needs initialized before theme change because of blur settings + // TODO: Clean ThemeManager.Instance in future ThemeManager.Instance.ChangeTheme(_settings.Theme); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); @@ -100,7 +159,8 @@ namespace Flow.Launcher AutoUpdates(); API.SaveAppAllSettings(); - Log.Info("|App.OnStartup|End Flow Launcher startup ---------------------------------------------------- "); + Log.Info( + "|App.OnStartup|End Flow Launcher startup ---------------------------------------------------- "); }); } @@ -112,14 +172,22 @@ namespace Flow.Launcher { try { - Helper.AutoStartup.Enable(); + if (_settings.UseLogonTaskForStartup) + { + Helper.AutoStartup.EnableViaLogonTask(); + } + else + { + Helper.AutoStartup.EnableViaRegistry(); + } } catch (Exception e) { // but if it fails (permissions, etc) then don't keep retrying // this also gives the user a visual indication in the Settings widget _settings.StartFlowLauncherOnSystemStartup = false; - Notification.Show(InternationalizationManager.Instance.GetTranslation("setAutoStartFailed"), e.Message); + Notification.Show(InternationalizationManager.Instance.GetTranslation("setAutoStartFailed"), + e.Message); } } } @@ -127,20 +195,17 @@ namespace Flow.Launcher //[Conditional("RELEASE")] private void AutoUpdates() { - Task.Run(async () => + _ = Task.Run(async () => { if (_settings.AutoUpdates) { - // check udpate every 5 hours - var timer = new Timer(1000 * 60 * 60 * 5); - timer.Elapsed += async (s, e) => - { - await _updater.UpdateAppAsync(API); - }; - timer.Start(); + // check update every 5 hours + var timer = new PeriodicTimer(TimeSpan.FromHours(5)); + await Ioc.Default.GetRequiredService().UpdateAppAsync(); - // check updates on startup - await _updater.UpdateAppAsync(API); + while (await timer.WaitForNextTickAsync()) + // check updates on startup + await Ioc.Default.GetRequiredService().UpdateAppAsync(); } }); } @@ -183,7 +248,7 @@ namespace Flow.Launcher public void OnSecondAppStarted() { - Current.MainWindow.Show(); + Ioc.Default.GetRequiredService().Show(); } } } diff --git a/Flow.Launcher/Converters/BoolToIMEConversionModeConverter.cs b/Flow.Launcher/Converters/BoolToIMEConversionModeConverter.cs new file mode 100644 index 000000000..41e879913 --- /dev/null +++ b/Flow.Launcher/Converters/BoolToIMEConversionModeConverter.cs @@ -0,0 +1,40 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Input; + +namespace Flow.Launcher.Converters; + +internal class BoolToIMEConversionModeConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value switch + { + true => ImeConversionModeValues.Alphanumeric, + _ => ImeConversionModeValues.DoNotCare + }; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +internal class BoolToIMEStateConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value switch + { + true => InputMethodState.Off, + _ => InputMethodState.DoNotCare + }; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Flow.Launcher/Converters/BoolToVisibilityConverter.cs b/Flow.Launcher/Converters/BoolToVisibilityConverter.cs index ad474d693..c0fe6ab55 100644 --- a/Flow.Launcher/Converters/BoolToVisibilityConverter.cs +++ b/Flow.Launcher/Converters/BoolToVisibilityConverter.cs @@ -1,43 +1,40 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Globalization; using System.Windows; -using System.Windows.Controls; using System.Windows.Data; -namespace Flow.Launcher.Converters +namespace Flow.Launcher.Converters; + +public class BoolToVisibilityConverter : IValueConverter { - public class BoolToVisibilityConverter : IValueConverter + public object Convert(object value, System.Type targetType, object parameter, CultureInfo culture) { - public object Convert(object value, System.Type targetType, object parameter, CultureInfo culture) + return (value, parameter) switch { - if (parameter != null) - { - if (value is true) - { - return Visibility.Collapsed; - } + (true, not null) => Visibility.Collapsed, + (_, not null) => Visibility.Visible, - else - { - return Visibility.Visible; - } - } - else { - if (value is true) - { - return Visibility.Visible; - } - - else { - return Visibility.Collapsed; - } - } - } - - public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) => throw new System.InvalidOperationException(); + (true, null) => Visibility.Visible, + (_, null) => Visibility.Collapsed + }; } + + public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) => throw new System.InvalidOperationException(); +} + +public class SplitterConverter : IValueConverter +/* Prevents the dragging part of the preview area from working when preview is turned off. */ +{ + public object Convert(object value, System.Type targetType, object parameter, CultureInfo culture) + { + return (value, parameter) switch + { + (true, not null) => 0, + (_, not null) => 5, + + (true, null) => 5, + (_, null) => 0 + }; + } + + public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) => throw new System.InvalidOperationException(); } diff --git a/Flow.Launcher/Converters/BorderClipConverter.cs b/Flow.Launcher/Converters/BorderClipConverter.cs index 83e83f1de..9b0579c33 100644 --- a/Flow.Launcher/Converters/BorderClipConverter.cs +++ b/Flow.Launcher/Converters/BorderClipConverter.cs @@ -1,57 +1,47 @@ using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using System.Windows; using System.Windows.Data; using System.Windows.Media; -using System.Windows.Documents; using System.Windows.Shapes; // For Clipping inside listbox item -namespace Flow.Launcher.Converters +namespace Flow.Launcher.Converters; + +public class BorderClipConverter : IMultiValueConverter { - public class BorderClipConverter : IMultiValueConverter + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) { - public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + if (values is not [double width, double height, CornerRadius radius]) { - if (values.Length == 3 && values[0] is double && values[1] is double && values[2] is CornerRadius) - { - var width = (double)values[0]; - var height = (double)values[1]; - Path myPath = new Path(); - if (width < Double.Epsilon || height < Double.Epsilon) - { - return Geometry.Empty; - } - var radius = (CornerRadius)values[2]; - var radiusHeight = radius.TopLeft; - - // Drawing Round box for bottom round, and rect for top area of listbox. - var corner = new RectangleGeometry(new Rect(0, 0, width, height), radius.TopLeft, radius.TopLeft); - var box = new RectangleGeometry(new Rect(0, 0, width, radiusHeight), 0, 0); - - GeometryGroup myGeometryGroup = new GeometryGroup(); - myGeometryGroup.Children.Add(corner); - myGeometryGroup.Children.Add(box); - - CombinedGeometry c1 = new CombinedGeometry(GeometryCombineMode.Union, corner, box); - myPath.Data = c1; - - myPath.Data.Freeze(); - return myPath.Data; - } - return DependencyProperty.UnsetValue; } - public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + Path myPath = new Path(); + if (width < Double.Epsilon || height < Double.Epsilon) { - throw new NotSupportedException(); + return Geometry.Empty; } + var radiusHeight = radius.TopLeft; + + // Drawing Round box for bottom round, and rect for top area of listbox. + var corner = new RectangleGeometry(new Rect(0, 0, width, height), radius.TopLeft, radius.TopLeft); + var box = new RectangleGeometry(new Rect(0, 0, width, radiusHeight), 0, 0); + + GeometryGroup myGeometryGroup = new GeometryGroup(); + myGeometryGroup.Children.Add(corner); + myGeometryGroup.Children.Add(box); + + CombinedGeometry c1 = new CombinedGeometry(GeometryCombineMode.Union, corner, box); + myPath.Data = c1; + + myPath.Data.Freeze(); + return myPath.Data; } + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } } diff --git a/Flow.Launcher/Converters/DateTimeFormatToNowConverter.cs b/Flow.Launcher/Converters/DateTimeFormatToNowConverter.cs index 3c46fd01a..7d75ffd5c 100644 --- a/Flow.Launcher/Converters/DateTimeFormatToNowConverter.cs +++ b/Flow.Launcher/Converters/DateTimeFormatToNowConverter.cs @@ -2,18 +2,17 @@ using System.Globalization; using System.Windows.Data; -namespace Flow.Launcher.Converters -{ - public class DateTimeFormatToNowConverter : IValueConverter - { +namespace Flow.Launcher.Converters; - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return value is not string format ? null : DateTime.Now.ToString(format); - } - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } +public class DateTimeFormatToNowConverter : IValueConverter +{ + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is not string format ? null : DateTime.Now.ToString(format); + } + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); } } diff --git a/Flow.Launcher/Converters/DiameterToCenterPointConverter.cs b/Flow.Launcher/Converters/DiameterToCenterPointConverter.cs new file mode 100644 index 000000000..24a316603 --- /dev/null +++ b/Flow.Launcher/Converters/DiameterToCenterPointConverter.cs @@ -0,0 +1,24 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace Flow.Launcher.Converters; + +public class DiameterToCenterPointConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double d) + { + return new Point(d / 2, d / 2); + } + + return new Point(0, 0); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/Flow.Launcher/Converters/HighlightTextConverter.cs b/Flow.Launcher/Converters/HighlightTextConverter.cs index 972dd1bc8..436f5fed5 100644 --- a/Flow.Launcher/Converters/HighlightTextConverter.cs +++ b/Flow.Launcher/Converters/HighlightTextConverter.cs @@ -1,56 +1,48 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using System.Windows; using System.Windows.Data; -using System.Windows.Media; using System.Windows.Documents; -namespace Flow.Launcher.Converters +namespace Flow.Launcher.Converters; + +public class HighlightTextConverter : IMultiValueConverter { - public class HighlightTextConverter : IMultiValueConverter + public object Convert(object[] value, Type targetType, object parameter, CultureInfo cultureInfo) { - public object Convert(object[] value, Type targetType, object parameter, CultureInfo cultureInfo) + if (value.Length < 2) + return new Run(string.Empty); + + if (value[0] is not string text) + return new Run(string.Empty); + + if (value[1] is not List { Count: > 0 } highlightData) + // No highlight data, just return the text + return new Run(text); + + var highlightStyle = (Style)Application.Current.FindResource("HighlightStyle"); + var textBlock = new Span(); + + for (var i = 0; i < text.Length; i++) { - var text = value[0] as string; - var highlightData = value[1] as List; - - var textBlock = new Span(); - - if (highlightData == null || !highlightData.Any()) + var currentCharacter = text.Substring(i, 1); + var run = new Run(currentCharacter) { - // No highlight data, just return the text - return new Run(text); - } - - for (var i = 0; i < text.Length; i++) - { - var currentCharacter = text.Substring(i, 1); - if (this.ShouldHighlight(highlightData, i)) - { - - textBlock.Inlines.Add(new Run(currentCharacter) { Style = (Style)Application.Current.FindResource("HighlightStyle") }); - - } - else - { - textBlock.Inlines.Add(new Run(currentCharacter)); - } - } - return textBlock; + Style = ShouldHighlight(highlightData, i) ? highlightStyle : null + }; + textBlock.Inlines.Add(run); } + return textBlock; + } - public object[] ConvertBack(object value, Type[] targetType, object parameter, CultureInfo culture) - { - return new[] { DependencyProperty.UnsetValue, DependencyProperty.UnsetValue }; - } + public object[] ConvertBack(object value, Type[] targetType, object parameter, CultureInfo culture) + { + return new[] { DependencyProperty.UnsetValue, DependencyProperty.UnsetValue }; + } - private bool ShouldHighlight(List highlightData, int index) - { - return highlightData.Contains(index); - } + private bool ShouldHighlight(List highlightData, int index) + { + return highlightData.Contains(index); } } diff --git a/Flow.Launcher/Converters/IconRadiusConverter.cs b/Flow.Launcher/Converters/IconRadiusConverter.cs new file mode 100644 index 000000000..d26b39d6b --- /dev/null +++ b/Flow.Launcher/Converters/IconRadiusConverter.cs @@ -0,0 +1,20 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace Flow.Launcher.Converters; + +public class IconRadiusConverter : IMultiValueConverter +{ + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values is not [double size, bool isIconCircular]) + throw new ArgumentException("IconRadiusConverter must have 2 parameters: [double, bool]"); + + return isIconCircular ? size / 2 : size; + } + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/Flow.Launcher/Converters/OpenResultHotkeyVisibilityConverter.cs b/Flow.Launcher/Converters/OpenResultHotkeyVisibilityConverter.cs index 7586d1fcf..7ab13190a 100644 --- a/Flow.Launcher/Converters/OpenResultHotkeyVisibilityConverter.cs +++ b/Flow.Launcher/Converters/OpenResultHotkeyVisibilityConverter.cs @@ -1,29 +1,26 @@ using System; -using System.Collections.Generic; using System.Globalization; -using System.Text; using System.Windows; using System.Windows.Controls; using System.Windows.Data; -namespace Flow.Launcher.Converters +namespace Flow.Launcher.Converters; + +[ValueConversion(typeof(bool), typeof(Visibility))] +public class OpenResultHotkeyVisibilityConverter : IValueConverter { - [ValueConversion(typeof(bool), typeof(Visibility))] - public class OpenResultHotkeyVisibilityConverter : IValueConverter + private const int MaxVisibleHotkeys = 10; + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - private const int MaxVisibleHotkeys = 10; + var number = int.MaxValue; - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - var number = int.MaxValue; + if (value is ListBoxItem listBoxItem + && ItemsControl.ItemsControlFromItemContainer(listBoxItem) is ListBox listBox) + number = listBox.ItemContainerGenerator.IndexFromContainer(listBoxItem) + 1; - if (value is ListBoxItem listBoxItem - && ItemsControl.ItemsControlFromItemContainer(listBoxItem) is ListBox listBox) - number = listBox.ItemContainerGenerator.IndexFromContainer(listBoxItem) + 1; - - return number <= MaxVisibleHotkeys ? Visibility.Visible : Visibility.Collapsed; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new System.InvalidOperationException(); + return number <= MaxVisibleHotkeys ? Visibility.Visible : Visibility.Collapsed; } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new InvalidOperationException(); } diff --git a/Flow.Launcher/Converters/OrdinalConverter.cs b/Flow.Launcher/Converters/OrdinalConverter.cs index 0c716ac7e..9aed2e07b 100644 --- a/Flow.Launcher/Converters/OrdinalConverter.cs +++ b/Flow.Launcher/Converters/OrdinalConverter.cs @@ -1,23 +1,24 @@ -using System.Globalization; +using System; +using System.Globalization; using System.Windows.Controls; using System.Windows.Data; -namespace Flow.Launcher.Converters -{ - public class OrdinalConverter : IValueConverter - { - public object Convert(object value, System.Type targetType, object parameter, CultureInfo culture) - { - if (value is ListBoxItem listBoxItem - && ItemsControl.ItemsControlFromItemContainer(listBoxItem) is ListBox listBox) - { - var res = listBox.ItemContainerGenerator.IndexFromContainer(listBoxItem) + 1; - return res == 10 ? 0 : res; // 10th item => HOTKEY+0 - } +namespace Flow.Launcher.Converters; +public class OrdinalConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not ListBoxItem listBoxItem + || ItemsControl.ItemsControlFromItemContainer(listBoxItem) is not ListBox listBox) + { return 0; } - public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) => throw new System.InvalidOperationException(); + var res = listBox.ItemContainerGenerator.IndexFromContainer(listBoxItem) + 1; + return res == 10 ? 0 : res; // 10th item => HOTKEY+0 + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new InvalidOperationException(); } diff --git a/Flow.Launcher/Converters/QuerySuggestionBoxConverter.cs b/Flow.Launcher/Converters/QuerySuggestionBoxConverter.cs index ecdfc5851..ed94771f0 100644 --- a/Flow.Launcher/Converters/QuerySuggestionBoxConverter.cs +++ b/Flow.Launcher/Converters/QuerySuggestionBoxConverter.cs @@ -6,73 +6,62 @@ using System.Windows.Media; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.ViewModel; -namespace Flow.Launcher.Converters +namespace Flow.Launcher.Converters; + +public class QuerySuggestionBoxConverter : IMultiValueConverter { - public class QuerySuggestionBoxConverter : IMultiValueConverter + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) { - public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + // values[0] is TextBox: The textbox displaying the autocomplete suggestion + // values[1] is ResultViewModel: Currently selected item in the list + // values[2] is string: Query text + if ( + values.Length != 3 || + values[0] is not TextBox queryTextBox || + values[1] is null || + values[2] is not string queryText || + string.IsNullOrEmpty(queryText) + ) + return string.Empty; + + if (values[1] is not ResultViewModel selectedItem) + return Binding.DoNothing; + + try { - if (values.Length != 3) - { - return string.Empty; - } - var QueryTextBox = values[0] as TextBox; + var selectedResult = selectedItem.Result; + var selectedResultActionKeyword = string.IsNullOrEmpty(selectedResult.ActionKeywordAssigned) ? "" : selectedResult.ActionKeywordAssigned + " "; + var selectedResultPossibleSuggestion = selectedResultActionKeyword + selectedResult.Title; - var queryText = (string)values[2]; - - if (string.IsNullOrEmpty(queryText)) + if (!selectedResultPossibleSuggestion.StartsWith(queryText, StringComparison.CurrentCultureIgnoreCase)) return string.Empty; - // second prop is the current selected item result - var val = values[1]; - if (val == null) - { + + // For AutocompleteQueryCommand. + // When user typed lower case and result title is uppercase, we still want to display suggestion + selectedItem.QuerySuggestionText = queryText + selectedResultPossibleSuggestion.Substring(queryText.Length); + + // Check if Text will be larger than our QueryTextBox + Typeface typeface = new Typeface(queryTextBox.FontFamily, queryTextBox.FontStyle, queryTextBox.FontWeight, queryTextBox.FontStretch); + // TODO: Obsolete warning? + var ft = new FormattedText(queryTextBox.Text, CultureInfo.CurrentCulture, System.Windows.FlowDirection.LeftToRight, typeface, queryTextBox.FontSize, Brushes.Black); + + var offset = queryTextBox.Padding.Right; + + if (ft.Width + offset > queryTextBox.ActualWidth || queryTextBox.HorizontalOffset != 0) return string.Empty; - } - if (!(val is ResultViewModel)) - { - return System.Windows.Data.Binding.DoNothing; - } - try - { - var selectedItem = (ResultViewModel)val; - - var selectedResult = selectedItem.Result; - var selectedResultActionKeyword = string.IsNullOrEmpty(selectedResult.ActionKeywordAssigned) ? "" : selectedResult.ActionKeywordAssigned + " "; - var selectedResultPossibleSuggestion = selectedResultActionKeyword + selectedResult.Title; - - if (!selectedResultPossibleSuggestion.StartsWith(queryText, StringComparison.CurrentCultureIgnoreCase)) - return string.Empty; - - - // For AutocompleteQueryCommand. - // When user typed lower case and result title is uppercase, we still want to display suggestion - selectedItem.QuerySuggestionText = queryText + selectedResultPossibleSuggestion.Substring(queryText.Length); - - // Check if Text will be larger then our QueryTextBox - System.Windows.Media.Typeface typeface = new Typeface(QueryTextBox.FontFamily, QueryTextBox.FontStyle, QueryTextBox.FontWeight, QueryTextBox.FontStretch); - System.Windows.Media.FormattedText ft = new FormattedText(QueryTextBox.Text, System.Globalization.CultureInfo.CurrentCulture, System.Windows.FlowDirection.LeftToRight, typeface, QueryTextBox.FontSize, Brushes.Black); - - var offset = QueryTextBox.Padding.Right; - - if ((ft.Width + offset) > QueryTextBox.ActualWidth || QueryTextBox.HorizontalOffset != 0) - { - return string.Empty; - }; - - return selectedItem.QuerySuggestionText; - } - catch (Exception e) - { - Log.Exception(nameof(QuerySuggestionBoxConverter), "fail to convert text for suggestion box", e); - return string.Empty; - } + return selectedItem.QuerySuggestionText; } - - public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + catch (Exception e) { - throw new NotImplementedException(); + Log.Exception(nameof(QuerySuggestionBoxConverter), "fail to convert text for suggestion box", e); + return string.Empty; } } -} \ No newline at end of file + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Flow.Launcher/Converters/StringToKeyBindingConverter.cs b/Flow.Launcher/Converters/StringToKeyBindingConverter.cs new file mode 100644 index 000000000..21bf584e7 --- /dev/null +++ b/Flow.Launcher/Converters/StringToKeyBindingConverter.cs @@ -0,0 +1,29 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Input; + +namespace Flow.Launcher.Converters; + +class StringToKeyBindingConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (parameter is not string mode || value is not string hotkeyStr) + return null; + + var converter = new KeyGestureConverter(); + var key = (KeyGesture)converter.ConvertFromString(hotkeyStr); + return mode switch + { + "key" => key?.Key, + "modifiers" => key?.Modifiers, + _ => null + }; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Flow.Launcher/Converters/TextConverter.cs b/Flow.Launcher/Converters/TextConverter.cs index 90d445776..5f0e1ea82 100644 --- a/Flow.Launcher/Converters/TextConverter.cs +++ b/Flow.Launcher/Converters/TextConverter.cs @@ -4,29 +4,27 @@ using System.Windows.Data; using Flow.Launcher.Core.Resource; using Flow.Launcher.ViewModel; -namespace Flow.Launcher.Converters -{ - public class TextConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - var ID = value.ToString(); - switch(ID) - { - case PluginStoreItemViewModel.NewRelease: - return InternationalizationManager.Instance.GetTranslation("pluginStore_NewRelease"); - case PluginStoreItemViewModel.RecentlyUpdated: - return InternationalizationManager.Instance.GetTranslation("pluginStore_RecentlyUpdated"); - case PluginStoreItemViewModel.None: - return InternationalizationManager.Instance.GetTranslation("pluginStore_None"); - case PluginStoreItemViewModel.Installed: - return InternationalizationManager.Instance.GetTranslation("pluginStore_Installed"); - default: - return ID; - } - - } +namespace Flow.Launcher.Converters; - public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) => throw new System.InvalidOperationException(); +public class TextConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var id = value?.ToString(); + var translationKey = id switch + { + PluginStoreItemViewModel.NewRelease => "pluginStore_NewRelease", + PluginStoreItemViewModel.RecentlyUpdated => "pluginStore_RecentlyUpdated", + PluginStoreItemViewModel.None => "pluginStore_None", + PluginStoreItemViewModel.Installed => "pluginStore_Installed", + _ => null + }; + + if (translationKey is null) + return id; + + return InternationalizationManager.Instance.GetTranslation(translationKey); } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new InvalidOperationException(); } diff --git a/Flow.Launcher/CustomQueryHotkeySetting.xaml b/Flow.Launcher/CustomQueryHotkeySetting.xaml index ddf0d0e45..ccf4de870 100644 --- a/Flow.Launcher/CustomQueryHotkeySetting.xaml +++ b/Flow.Launcher/CustomQueryHotkeySetting.xaml @@ -2,10 +2,12 @@ x:Class="Flow.Launcher.CustomQueryHotkeySetting" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:flowlauncher="clr-namespace:Flow.Launcher" Title="{DynamicResource customeQueryHotkeyTitle}" Width="530" Background="{DynamicResource PopuBGColor}" + DataContext="{Binding RelativeSource={RelativeSource Self}}" Foreground="{DynamicResource PopupTextColor}" Icon="Images\app.png" MouseDown="window_MouseDown" @@ -30,14 +32,11 @@ - - - + diff --git a/Flow.Launcher/HotkeyControl.xaml.cs b/Flow.Launcher/HotkeyControl.xaml.cs index d746c8fd2..273d18e3f 100644 --- a/Flow.Launcher/HotkeyControl.xaml.cs +++ b/Flow.Launcher/HotkeyControl.xaml.cs @@ -1,120 +1,310 @@ -using System; +#nullable enable + +using System.Collections.ObjectModel; using System.Threading.Tasks; using System.Windows; -using System.Windows.Controls; using System.Windows.Input; -using System.Windows.Media; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Core.Resource; using Flow.Launcher.Helper; using Flow.Launcher.Infrastructure.Hotkey; -using Flow.Launcher.Plugin; -using System.Threading; -using System.Windows.Interop; +using Flow.Launcher.Infrastructure.UserSettings; namespace Flow.Launcher { - public partial class HotkeyControl : UserControl + public partial class HotkeyControl { - private Brush tbMsgForegroundColorOriginal; - - private string tbMsgTextOriginal; - - public HotkeyModel CurrentHotkey { get; private set; } - public bool CurrentHotkeyAvailable { get; private set; } - - public event EventHandler HotkeyChanged; - - protected virtual void OnHotkeyChanged() => HotkeyChanged?.Invoke(this, EventArgs.Empty); - - public HotkeyControl() - { - InitializeComponent(); - tbMsgTextOriginal = tbMsg.Text; - tbMsgForegroundColorOriginal = tbMsg.Foreground; + public string WindowTitle { + get { return (string)GetValue(WindowTitleProperty); } + set { SetValue(WindowTitleProperty, value); } } - private CancellationTokenSource hotkeyUpdateSource; + public static readonly DependencyProperty WindowTitleProperty = DependencyProperty.Register( + nameof(WindowTitle), + typeof(string), + typeof(HotkeyControl), + new PropertyMetadata(string.Empty) + ); - private void TbHotkey_OnPreviewKeyDown(object sender, KeyEventArgs e) + /// + /// Designed for Preview Hotkey and KeyGesture. + /// + public static readonly DependencyProperty ValidateKeyGestureProperty = DependencyProperty.Register( + nameof(ValidateKeyGesture), + typeof(bool), + typeof(HotkeyControl), + new PropertyMetadata(default(bool)) + ); + + public bool ValidateKeyGesture { - hotkeyUpdateSource?.Cancel(); - hotkeyUpdateSource?.Dispose(); - hotkeyUpdateSource = new(); - var token = hotkeyUpdateSource.Token; - e.Handled = true; + get { return (bool)GetValue(ValidateKeyGestureProperty); } + set { SetValue(ValidateKeyGestureProperty, value); } + } - //when alt is pressed, the real key should be e.SystemKey - Key key = e.Key == Key.System ? e.SystemKey : e.Key; + public static readonly DependencyProperty DefaultHotkeyProperty = DependencyProperty.Register( + nameof(DefaultHotkey), + typeof(string), + typeof(HotkeyControl), + new PropertyMetadata(default(string)) + ); - SpecialKeyState specialKeyState = GlobalHotkey.CheckModifiers(); + public string DefaultHotkey + { + get { return (string)GetValue(DefaultHotkeyProperty); } + set { SetValue(DefaultHotkeyProperty, value); } + } - var hotkeyModel = new HotkeyModel( - specialKeyState.AltPressed, - specialKeyState.ShiftPressed, - specialKeyState.WinPressed, - specialKeyState.CtrlPressed, - key); - - var hotkeyString = hotkeyModel.ToString(); - - if (hotkeyString == tbHotkey.Text) + private static void OnHotkeyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not HotkeyControl hotkeyControl) { return; } - _ = Dispatcher.InvokeAsync(async () => - { - await Task.Delay(500, token); - if (!token.IsCancellationRequested) - await SetHotkeyAsync(hotkeyModel); - }); + hotkeyControl.RefreshHotkeyInterface(hotkeyControl.Hotkey); } - public async Task SetHotkeyAsync(HotkeyModel keyModel, bool triggerValidate = true) + + public static readonly DependencyProperty ChangeHotkeyProperty = DependencyProperty.Register( + nameof(ChangeHotkey), + typeof(ICommand), + typeof(HotkeyControl), + new PropertyMetadata(default(ICommand)) + ); + + public ICommand? ChangeHotkey { - CurrentHotkey = keyModel; + get { return (ICommand)GetValue(ChangeHotkeyProperty); } + set { SetValue(ChangeHotkeyProperty, value); } + } - tbHotkey.Text = CurrentHotkey.ToString(); - tbHotkey.Select(tbHotkey.Text.Length, 0); - if (triggerValidate) + public static readonly DependencyProperty TypeProperty = DependencyProperty.Register( + nameof(Type), + typeof(HotkeyType), + typeof(HotkeyControl), + new FrameworkPropertyMetadata(HotkeyType.Hotkey, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnHotkeyChanged) + ); + + public HotkeyType Type + { + get { return (HotkeyType)GetValue(TypeProperty); } + set { SetValue(TypeProperty, value); } + } + + public enum HotkeyType + { + Hotkey, + PreviewHotkey, + OpenContextMenuHotkey, + SettingWindowHotkey, + CycleHistoryUpHotkey, + CycleHistoryDownHotkey, + SelectPrevPageHotkey, + SelectNextPageHotkey, + AutoCompleteHotkey, + AutoCompleteHotkey2, + SelectPrevItemHotkey, + SelectPrevItemHotkey2, + SelectNextItemHotkey, + SelectNextItemHotkey2 + } + + // We can initialize settings in static field because it has been constructed in App constuctor + // and it will not construct settings instances twice + private static readonly Settings _settings = Ioc.Default.GetRequiredService(); + + public string Hotkey + { + get { - CurrentHotkeyAvailable = CheckHotkeyAvailability(); - if (!CurrentHotkeyAvailable) + return Type switch { - tbMsg.Foreground = new SolidColorBrush(Colors.Red); - tbMsg.Text = InternationalizationManager.Instance.GetTranslation("hotkeyUnavailable"); - } - else + HotkeyType.Hotkey => _settings.Hotkey, + HotkeyType.PreviewHotkey => _settings.PreviewHotkey, + HotkeyType.OpenContextMenuHotkey => _settings.OpenContextMenuHotkey, + HotkeyType.SettingWindowHotkey => _settings.SettingWindowHotkey, + HotkeyType.CycleHistoryUpHotkey => _settings.CycleHistoryUpHotkey, + HotkeyType.CycleHistoryDownHotkey => _settings.CycleHistoryDownHotkey, + HotkeyType.SelectPrevPageHotkey => _settings.SelectPrevPageHotkey, + HotkeyType.SelectNextPageHotkey => _settings.SelectNextPageHotkey, + HotkeyType.AutoCompleteHotkey => _settings.AutoCompleteHotkey, + HotkeyType.AutoCompleteHotkey2 => _settings.AutoCompleteHotkey2, + HotkeyType.SelectPrevItemHotkey => _settings.SelectPrevItemHotkey, + HotkeyType.SelectPrevItemHotkey2 => _settings.SelectPrevItemHotkey2, + HotkeyType.SelectNextItemHotkey => _settings.SelectNextItemHotkey, + HotkeyType.SelectNextItemHotkey2 => _settings.SelectNextItemHotkey2, + _ => string.Empty + }; + } + set + { + switch (Type) { - tbMsg.Foreground = new SolidColorBrush(Colors.Green); - tbMsg.Text = InternationalizationManager.Instance.GetTranslation("success"); + case HotkeyType.Hotkey: + _settings.Hotkey = value; + break; + case HotkeyType.PreviewHotkey: + _settings.PreviewHotkey = value; + break; + case HotkeyType.OpenContextMenuHotkey: + _settings.OpenContextMenuHotkey = value; + break; + case HotkeyType.SettingWindowHotkey: + _settings.SettingWindowHotkey = value; + break; + case HotkeyType.CycleHistoryUpHotkey: + _settings.CycleHistoryUpHotkey = value; + break; + case HotkeyType.CycleHistoryDownHotkey: + _settings.CycleHistoryDownHotkey = value; + break; + case HotkeyType.SelectPrevPageHotkey: + _settings.SelectPrevPageHotkey = value; + break; + case HotkeyType.SelectNextPageHotkey: + _settings.SelectNextPageHotkey = value; + break; + case HotkeyType.AutoCompleteHotkey: + _settings.AutoCompleteHotkey = value; + break; + case HotkeyType.AutoCompleteHotkey2: + _settings.AutoCompleteHotkey2 = value; + break; + case HotkeyType.SelectPrevItemHotkey: + _settings.SelectPrevItemHotkey = value; + break; + case HotkeyType.SelectNextItemHotkey: + _settings.SelectNextItemHotkey = value; + break; + case HotkeyType.SelectPrevItemHotkey2: + _settings.SelectPrevItemHotkey2 = value; + break; + case HotkeyType.SelectNextItemHotkey2: + _settings.SelectNextItemHotkey2 = value; + break; + default: + return; } - tbMsg.Visibility = Visibility.Visible; - OnHotkeyChanged(); - var token = hotkeyUpdateSource.Token; - await Task.Delay(500, token); - if (token.IsCancellationRequested) - return; - FocusManager.SetFocusedElement(FocusManager.GetFocusScope(this), null); - Keyboard.ClearFocus(); + // After setting the hotkey, we need to refresh the interface + RefreshHotkeyInterface(Hotkey); } } - public Task SetHotkeyAsync(string keyStr, bool triggerValidate = true) + public HotkeyControl() { - return SetHotkeyAsync(new HotkeyModel(keyStr), triggerValidate); + InitializeComponent(); + + HotkeyList.ItemsSource = KeysToDisplay; + + RefreshHotkeyInterface(Hotkey); } - private bool CheckHotkeyAvailability() => HotKeyMapper.CheckAvailability(CurrentHotkey); - - public new bool IsFocused => tbHotkey.IsFocused; - - private void tbHotkey_LostFocus(object sender, RoutedEventArgs e) + private void RefreshHotkeyInterface(string hotkey) { - tbMsg.Text = tbMsgTextOriginal; - tbMsg.SetResourceReference(TextBox.ForegroundProperty, "Color05B"); + SetKeysToDisplay(new HotkeyModel(Hotkey)); + CurrentHotkey = new HotkeyModel(Hotkey); + } + + private static bool CheckHotkeyAvailability(HotkeyModel hotkey, bool validateKeyGesture) => + hotkey.Validate(validateKeyGesture) && HotKeyMapper.CheckAvailability(hotkey); + + public string EmptyHotkey => InternationalizationManager.Instance.GetTranslation("none"); + + public ObservableCollection KeysToDisplay { get; set; } = new(); + + public HotkeyModel CurrentHotkey { get; private set; } = new(false, false, false, false, Key.None); + + + public void GetNewHotkey(object sender, RoutedEventArgs e) + { + OpenHotkeyDialog(); + } + + private async Task OpenHotkeyDialog() + { + if (!string.IsNullOrEmpty(Hotkey)) + { + HotKeyMapper.RemoveHotkey(Hotkey); + } + + var dialog = new HotkeyControlDialog(Hotkey, DefaultHotkey, WindowTitle); + await dialog.ShowAsync(); + switch (dialog.ResultType) + { + case HotkeyControlDialog.EResultType.Cancel: + SetHotkey(Hotkey); + return; + case HotkeyControlDialog.EResultType.Save: + SetHotkey(dialog.ResultValue); + break; + case HotkeyControlDialog.EResultType.Delete: + Delete(); + break; + } + } + + + private void SetHotkey(HotkeyModel keyModel, bool triggerValidate = true) + { + if (triggerValidate) + { + bool hotkeyAvailable = false; + // TODO: This is a temporary way to enforce changing only the open flow hotkey to Win, and will be removed by PR #3157 + if (keyModel.ToString() == "LWin" || keyModel.ToString() == "RWin") + { + hotkeyAvailable = true; + } + else + { + hotkeyAvailable = CheckHotkeyAvailability(keyModel, ValidateKeyGesture); + } + + if (!hotkeyAvailable) + { + return; + } + + Hotkey = keyModel.ToString(); + SetKeysToDisplay(CurrentHotkey); + ChangeHotkey?.Execute(keyModel); + } + else + { + Hotkey = keyModel.ToString(); + ChangeHotkey?.Execute(keyModel); + } + } + + public void Delete() + { + if (!string.IsNullOrEmpty(Hotkey)) + HotKeyMapper.RemoveHotkey(Hotkey); + Hotkey = ""; + SetKeysToDisplay(new HotkeyModel(false, false, false, false, Key.None)); + } + + private void SetKeysToDisplay(HotkeyModel? hotkey) + { + KeysToDisplay.Clear(); + + if (hotkey == null || hotkey == default(HotkeyModel)) + { + KeysToDisplay.Add(EmptyHotkey); + return; + } + + foreach (var key in hotkey.Value.EnumerateDisplayKeys()!) + { + KeysToDisplay.Add(key); + } + } + + public void SetHotkey(string? keyStr, bool triggerValidate = true) + { + SetHotkey(new HotkeyModel(keyStr), triggerValidate); } } } diff --git a/Flow.Launcher/HotkeyControlDialog.xaml b/Flow.Launcher/HotkeyControlDialog.xaml new file mode 100644 index 000000000..9a5872f4d --- /dev/null +++ b/Flow.Launcher/HotkeyControlDialog.xaml @@ -0,0 +1,169 @@ + + + 0 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/ReportWindow.xaml.cs b/Flow.Launcher/ReportWindow.xaml.cs index 4899edc14..6fe90783e 100644 --- a/Flow.Launcher/ReportWindow.xaml.cs +++ b/Flow.Launcher/ReportWindow.xaml.cs @@ -1,6 +1,5 @@ using Flow.Launcher.Core.ExternalPlugins; using System; -using System.Diagnostics; using System.Globalization; using System.IO; using System.Text; @@ -23,7 +22,7 @@ namespace Flow.Launcher SetException(exception); } - private static string GetIssueUrl(string website) + private static string GetIssuesUrl(string website) { if (!website.StartsWith("https://github.com")) { @@ -31,10 +30,10 @@ namespace Flow.Launcher } if(website.Contains("Flow-Launcher/Flow.Launcher")) { - return Constant.Issue; + return Constant.IssuesUrl; } var treeIndex = website.IndexOf("tree", StringComparison.Ordinal); - return treeIndex == -1 ? $"{website}/issues/new" : $"{website[..treeIndex]}/issues/new"; + return treeIndex == -1 ? $"{website}/issues" : $"{website[..treeIndex]}/issues"; } private void SetException(Exception exception) @@ -44,20 +43,21 @@ namespace Flow.Launcher var log = directory.GetFiles().OrderByDescending(f => f.LastWriteTime).First(); var websiteUrl = exception switch - { - FlowPluginException pluginException =>GetIssueUrl(pluginException.Metadata.Website), - _ => Constant.Issue - }; - + { + 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()); @@ -66,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 { @@ -80,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(); + } } -} \ No newline at end of file +} diff --git a/Flow.Launcher/Resources/Controls/Card.xaml b/Flow.Launcher/Resources/Controls/Card.xaml new file mode 100644 index 000000000..c29a5f602 --- /dev/null +++ b/Flow.Launcher/Resources/Controls/Card.xaml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/Resources/Controls/Card.xaml.cs b/Flow.Launcher/Resources/Controls/Card.xaml.cs new file mode 100644 index 000000000..c8f788aca --- /dev/null +++ b/Flow.Launcher/Resources/Controls/Card.xaml.cs @@ -0,0 +1,64 @@ +using System.Windows; +using UserControl = System.Windows.Controls.UserControl; + +namespace Flow.Launcher.Resources.Controls +{ + public partial class Card : UserControl + { + public enum CardType + { + Default, + Inside, + InsideFit + } + + public Card() + { + InitializeComponent(); + } + + public string Title + { + get { return (string)GetValue(TitleProperty); } + set { SetValue(TitleProperty, value); } + } + public static readonly DependencyProperty TitleProperty = + DependencyProperty.Register(nameof(Title), typeof(string), typeof(Card), new PropertyMetadata(string.Empty)); + + public string Sub + { + get { return (string)GetValue(SubProperty); } + set { SetValue(SubProperty, value); } + } + public static readonly DependencyProperty SubProperty = + DependencyProperty.Register(nameof(Sub), typeof(string), typeof(Card), new PropertyMetadata(string.Empty)); + + public string Icon + { + get { return (string)GetValue(IconProperty); } + set { SetValue(IconProperty, value); } + } + public static readonly DependencyProperty IconProperty = + DependencyProperty.Register(nameof(Icon), typeof(string), typeof(Card), new PropertyMetadata(string.Empty)); + + /// + /// Gets or sets additional content for the UserControl + /// + public object AdditionalContent + { + get { return (object)GetValue(AdditionalContentProperty); } + set { SetValue(AdditionalContentProperty, value); } + } + public static readonly DependencyProperty AdditionalContentProperty = + DependencyProperty.Register(nameof(AdditionalContent), typeof(object), typeof(Card), + new PropertyMetadata(null)); + public CardType Type + { + get { return (CardType)GetValue(TypeProperty); } + set { SetValue(TypeProperty, value); } + } + public static readonly DependencyProperty TypeProperty = + DependencyProperty.Register(nameof(Type), typeof(CardType), typeof(Card), + new PropertyMetadata(CardType.Default)); + } +} diff --git a/Flow.Launcher/Resources/Controls/CardGroup.xaml b/Flow.Launcher/Resources/Controls/CardGroup.xaml new file mode 100644 index 000000000..f48bf4b6c --- /dev/null +++ b/Flow.Launcher/Resources/Controls/CardGroup.xaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/Flow.Launcher/Resources/Controls/CardGroup.xaml.cs b/Flow.Launcher/Resources/Controls/CardGroup.xaml.cs new file mode 100644 index 000000000..b9588275c --- /dev/null +++ b/Flow.Launcher/Resources/Controls/CardGroup.xaml.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.ObjectModel; +using System.Windows; +using System.Windows.Controls; + +namespace Flow.Launcher.Resources.Controls; + +public partial class CardGroup : UserControl +{ + public enum CardGroupPosition + { + NotInGroup, + First, + Middle, + Last + } + + public new ObservableCollection Content + { + get { return (ObservableCollection)GetValue(ContentProperty); } + set { SetValue(ContentProperty, value); } + } + + public static new readonly DependencyProperty ContentProperty = + DependencyProperty.Register(nameof(Content), typeof(ObservableCollection), typeof(CardGroup)); + + public static readonly DependencyProperty PositionProperty = DependencyProperty.RegisterAttached( + "Position", typeof(CardGroupPosition), typeof(CardGroup), + new FrameworkPropertyMetadata(CardGroupPosition.NotInGroup, FrameworkPropertyMetadataOptions.AffectsRender) + ); + + public static void SetPosition(UIElement element, CardGroupPosition value) + { + element.SetValue(PositionProperty, value); + } + + public static CardGroupPosition GetPosition(UIElement element) + { + return (CardGroupPosition)element.GetValue(PositionProperty); + } + + public CardGroup() + { + InitializeComponent(); + Content = new ObservableCollection(); + } +} diff --git a/Flow.Launcher/Resources/Controls/CardGroupCardStyleSelector.cs b/Flow.Launcher/Resources/Controls/CardGroupCardStyleSelector.cs new file mode 100644 index 000000000..605934e80 --- /dev/null +++ b/Flow.Launcher/Resources/Controls/CardGroupCardStyleSelector.cs @@ -0,0 +1,21 @@ +using System.Windows; +using System.Windows.Controls; + +namespace Flow.Launcher.Resources.Controls; + +public class CardGroupCardStyleSelector : StyleSelector +{ + public Style FirstStyle { get; set; } + public Style MiddleStyle { get; set; } + public Style LastStyle { get; set; } + + public override Style SelectStyle(object item, DependencyObject container) + { + var itemsControl = ItemsControl.ItemsControlFromItemContainer(container); + var index = itemsControl.ItemContainerGenerator.IndexFromContainer(container); + + if (index == 0) return FirstStyle; + if (index == itemsControl.Items.Count - 1) return LastStyle; + return MiddleStyle; + } +} diff --git a/Flow.Launcher/Resources/Controls/ExCard.xaml b/Flow.Launcher/Resources/Controls/ExCard.xaml new file mode 100644 index 000000000..a70c0f4ea --- /dev/null +++ b/Flow.Launcher/Resources/Controls/ExCard.xaml @@ -0,0 +1,312 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/Resources/Controls/ExCard.xaml.cs b/Flow.Launcher/Resources/Controls/ExCard.xaml.cs new file mode 100644 index 000000000..f149951f0 --- /dev/null +++ b/Flow.Launcher/Resources/Controls/ExCard.xaml.cs @@ -0,0 +1,57 @@ +using System.Windows; +using System.Windows.Controls; + +namespace Flow.Launcher.Resources.Controls +{ + public partial class ExCard : UserControl + { + public ExCard() + { + InitializeComponent(); + } + public string Title + { + get { return (string)GetValue(TitleProperty); } + set { SetValue(TitleProperty, value); } + } + public static readonly DependencyProperty TitleProperty = + DependencyProperty.Register(nameof(Title), typeof(string), typeof(ExCard), new PropertyMetadata(string.Empty)); + + public string Sub + { + get { return (string)GetValue(SubProperty); } + set { SetValue(SubProperty, value); } + } + public static readonly DependencyProperty SubProperty = + DependencyProperty.Register(nameof(Sub), typeof(string), typeof(ExCard), new PropertyMetadata(string.Empty)); + + public string Icon + { + get { return (string)GetValue(IconProperty); } + set { SetValue(IconProperty, value); } + } + public static readonly DependencyProperty IconProperty = + DependencyProperty.Register(nameof(Icon), typeof(string), typeof(ExCard), new PropertyMetadata(string.Empty)); + + /// + /// Gets or sets additional content for the UserControl + /// + public object AdditionalContent + { + get { return (object)GetValue(AdditionalContentProperty); } + set { SetValue(AdditionalContentProperty, value); } + } + public static readonly DependencyProperty AdditionalContentProperty = + DependencyProperty.Register(nameof(AdditionalContent), typeof(object), typeof(ExCard), + new PropertyMetadata(null)); + + public object SideContent + { + get { return (object)GetValue(SideContentProperty); } + set { SetValue(SideContentProperty, value); } + } + public static readonly DependencyProperty SideContentProperty = + DependencyProperty.Register(nameof(SideContent), typeof(object), typeof(ExCard), + new PropertyMetadata(null)); + } +} diff --git a/Flow.Launcher/Resources/Controls/HotkeyDisplay.xaml b/Flow.Launcher/Resources/Controls/HotkeyDisplay.xaml new file mode 100644 index 000000000..448efb9cf --- /dev/null +++ b/Flow.Launcher/Resources/Controls/HotkeyDisplay.xaml @@ -0,0 +1,74 @@ + + + + + diff --git a/Flow.Launcher/Resources/Controls/HotkeyDisplay.xaml.cs b/Flow.Launcher/Resources/Controls/HotkeyDisplay.xaml.cs new file mode 100644 index 000000000..9b19ffd86 --- /dev/null +++ b/Flow.Launcher/Resources/Controls/HotkeyDisplay.xaml.cs @@ -0,0 +1,63 @@ +using System.Collections.ObjectModel; +using System.Windows; +using System.Windows.Controls; + +namespace Flow.Launcher.Resources.Controls +{ + public partial class HotkeyDisplay : UserControl + { + public enum DisplayType + { + Default, + Small + } + + public HotkeyDisplay() + { + InitializeComponent(); + //List stringList =e.NewValue.Split('+').ToList(); + Values = new ObservableCollection(); + KeysControl.ItemsSource = Values; + } + + public string Keys + { + get { return (string)GetValue(KeysProperty); } + set { SetValue(KeysProperty, value); } + } + + public static readonly DependencyProperty KeysProperty = + DependencyProperty.Register(nameof(Keys), typeof(string), typeof(HotkeyDisplay), + new PropertyMetadata(string.Empty, keyChanged)); + + public DisplayType Type + { + get { return (DisplayType)GetValue(TypeProperty); } + set { SetValue(TypeProperty, value); } + } + + public static readonly DependencyProperty TypeProperty = + DependencyProperty.Register(nameof(Type), typeof(DisplayType), typeof(HotkeyDisplay), + new PropertyMetadata(DisplayType.Default)); + + private static void keyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = d as UserControl; + if (null == control) return; // This should not be possible + + var newValue = e.NewValue as string; + if (null == newValue) return; + + if (d is not HotkeyDisplay hotkeyDisplay) + return; + + hotkeyDisplay.Values.Clear(); + foreach (var key in newValue.Split('+')) + { + hotkeyDisplay.Values.Add(key); + } + } + + public ObservableCollection Values { get; set; } + } +} diff --git a/Flow.Launcher/Resources/Controls/HyperLink.xaml b/Flow.Launcher/Resources/Controls/HyperLink.xaml new file mode 100644 index 000000000..9ea550afd --- /dev/null +++ b/Flow.Launcher/Resources/Controls/HyperLink.xaml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/Flow.Launcher/Resources/Controls/HyperLink.xaml.cs b/Flow.Launcher/Resources/Controls/HyperLink.xaml.cs new file mode 100644 index 000000000..855cccdbd --- /dev/null +++ b/Flow.Launcher/Resources/Controls/HyperLink.xaml.cs @@ -0,0 +1,39 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Navigation; + +namespace Flow.Launcher.Resources.Controls; + +public partial class HyperLink : UserControl +{ + public static readonly DependencyProperty UriProperty = DependencyProperty.Register( + nameof(Uri), typeof(string), typeof(HyperLink), new PropertyMetadata(default(string)) + ); + + public string Uri + { + get => (string)GetValue(UriProperty); + set => SetValue(UriProperty, value); + } + + public static readonly DependencyProperty TextProperty = DependencyProperty.Register( + nameof(Text), typeof(string), typeof(HyperLink), new PropertyMetadata(default(string)) + ); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public HyperLink() + { + InitializeComponent(); + } + + private void Hyperlink_OnRequestNavigate(object sender, RequestNavigateEventArgs e) + { + App.API.OpenUrl(e.Uri); + e.Handled = true; + } +} diff --git a/Flow.Launcher/Resources/Controls/InstalledPluginDisplay.xaml b/Flow.Launcher/Resources/Controls/InstalledPluginDisplay.xaml new file mode 100644 index 000000000..ed3c29690 --- /dev/null +++ b/Flow.Launcher/Resources/Controls/InstalledPluginDisplay.xaml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/Resources/Controls/InstalledPluginDisplay.xaml.cs b/Flow.Launcher/Resources/Controls/InstalledPluginDisplay.xaml.cs new file mode 100644 index 000000000..dfa03a204 --- /dev/null +++ b/Flow.Launcher/Resources/Controls/InstalledPluginDisplay.xaml.cs @@ -0,0 +1,9 @@ +namespace Flow.Launcher.Resources.Controls; + +public partial class InstalledPluginDisplay +{ + public InstalledPluginDisplay() + { + InitializeComponent(); + } +} diff --git a/Flow.Launcher/Resources/Controls/InstalledPluginDisplayBottomData.xaml b/Flow.Launcher/Resources/Controls/InstalledPluginDisplayBottomData.xaml new file mode 100644 index 000000000..83a771728 --- /dev/null +++ b/Flow.Launcher/Resources/Controls/InstalledPluginDisplayBottomData.xaml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/Resources/Controls/InstalledPluginDisplayBottomData.xaml.cs b/Flow.Launcher/Resources/Controls/InstalledPluginDisplayBottomData.xaml.cs new file mode 100644 index 000000000..f8d0afe61 --- /dev/null +++ b/Flow.Launcher/Resources/Controls/InstalledPluginDisplayBottomData.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace Flow.Launcher.Resources.Controls; + +public partial class InstalledPluginDisplayBottomData : UserControl +{ + public InstalledPluginDisplayBottomData() + { + InitializeComponent(); + } +} diff --git a/Flow.Launcher/Resources/Controls/InstalledPluginDisplayKeyword.xaml b/Flow.Launcher/Resources/Controls/InstalledPluginDisplayKeyword.xaml new file mode 100644 index 000000000..ff2f14c4b --- /dev/null +++ b/Flow.Launcher/Resources/Controls/InstalledPluginDisplayKeyword.xaml @@ -0,0 +1,47 @@ + + + + +  + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/SettingPages/Views/SettingsPaneAbout.xaml.cs b/Flow.Launcher/SettingPages/Views/SettingsPaneAbout.xaml.cs new file mode 100644 index 000000000..de6a4df5e --- /dev/null +++ b/Flow.Launcher/SettingPages/Views/SettingsPaneAbout.xaml.cs @@ -0,0 +1,29 @@ +using System; +using System.Windows.Navigation; +using Flow.Launcher.SettingPages.ViewModels; + +namespace Flow.Launcher.SettingPages.Views; + +public partial class SettingsPaneAbout +{ + private SettingsPaneAboutViewModel _viewModel = null!; + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + if (!IsInitialized) + { + if (e.ExtraData is not SettingWindow.PaneData { Settings: { } settings, Updater: { } updater }) + throw new ArgumentException("Settings are required for SettingsPaneAbout."); + _viewModel = new SettingsPaneAboutViewModel(settings, updater); + DataContext = _viewModel; + InitializeComponent(); + } + base.OnNavigatedTo(e); + } + + private void OnRequestNavigate(object sender, RequestNavigateEventArgs e) + { + App.API.OpenUrl(e.Uri.AbsoluteUri); + e.Handled = true; + } +} diff --git a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml new file mode 100644 index 000000000..a80e618e8 --- /dev/null +++ b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml @@ -0,0 +1,283 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs b/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs new file mode 100644 index 000000000..db4763319 --- /dev/null +++ b/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs @@ -0,0 +1,65 @@ +using System; +using System.ComponentModel; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Navigation; +using Flow.Launcher.SettingPages.ViewModels; +using Flow.Launcher.ViewModel; + +namespace Flow.Launcher.SettingPages.Views; + +public partial class SettingsPanePluginStore +{ + private SettingsPanePluginStoreViewModel _viewModel = null!; + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + 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; + base.OnNavigatedTo(e); + } + + private void ViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SettingsPanePluginStoreViewModel.FilterText)) + { + ((CollectionViewSource)FindResource("PluginStoreCollectionView")).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; + PluginStoreFilterTextbox.Focus(); + } + + private void Hyperlink_OnRequestNavigate(object sender, RequestNavigateEventArgs e) + { + App.API.OpenUrl(e.Uri.AbsoluteUri); + e.Handled = true; + } + + private void PluginStoreCollectionView_OnFilter(object sender, FilterEventArgs e) + { + if (e.Item is not PluginStoreItemViewModel plugin) + { + e.Accepted = false; + return; + } + + e.Accepted = _viewModel.SatisfiesFilter(plugin); + } +} diff --git a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml new file mode 100644 index 000000000..37079a46f --- /dev/null +++ b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml.cs b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml.cs new file mode 100644 index 000000000..d48505c3d --- /dev/null +++ b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml.cs @@ -0,0 +1,30 @@ +using System; +using System.Windows.Input; +using System.Windows.Navigation; +using Flow.Launcher.SettingPages.ViewModels; + +namespace Flow.Launcher.SettingPages.Views; + +public partial class SettingsPanePlugins +{ + private SettingsPanePluginsViewModel _viewModel = null!; + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + 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(); + } + base.OnNavigatedTo(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(); + } +} diff --git a/Flow.Launcher/SettingPages/Views/SettingsPaneProxy.xaml b/Flow.Launcher/SettingPages/Views/SettingsPaneProxy.xaml new file mode 100644 index 000000000..768abbf97 --- /dev/null +++ b/Flow.Launcher/SettingPages/Views/SettingsPaneProxy.xaml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + @@ -610,2417 +197,72 @@ Grid.Row="0" Width="50" Height="50" + RenderOptions.BitmapScalingMode="HighQuality" Source="images/app.png" /> + TextAlignment="Center" /> - - - - - - - - - - - -  - - - - + + - - - + + + + + - - - - - - - -  - - - + + + + + - - - - - - - -  - - - + + + + + - - - - - - - - + + + + + + + + + + - - - - - - - - - + + + + + - - - - - - - -  - - - - - - - - - - - - -  - - - - - - - - - - - - -  - - - - - - - - - - - -  - - - - - - - - - - - - -  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  - - - - - - - - - - - - - - - - - - - -  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  - - - - - - - - - - - - - - -  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - - - - - - - - - - -  - - - - - - - - - - - - - - - - - - - - - - - - -  - - - - - - - - - - - - - - - - - - - - - - - - -  - - - - - - - - - - - - - - - - - - - - - -  - - - - - - - - - - - - - - - -  - - - - - - - - - - - - - - - - -  - - - - - - - - - - - - -  - - - - - - - - - - - - - -  - - - - - - - - - - - - -  - - - - - - - - - - - - + diff --git a/Flow.Launcher/SettingWindow.xaml.cs b/Flow.Launcher/SettingWindow.xaml.cs index 9ceb9789d..f87194c30 100644 --- a/Flow.Launcher/SettingWindow.xaml.cs +++ b/Flow.Launcher/SettingWindow.xaml.cs @@ -1,564 +1,216 @@ -using Flow.Launcher.Core.Plugin; -using Flow.Launcher.Core.Resource; -using Flow.Launcher.Helper; -using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Hotkey; -using Flow.Launcher.Infrastructure.UserSettings; -using Flow.Launcher.Plugin; -using Flow.Launcher.Plugin.SharedCommands; -using Flow.Launcher.ViewModel; -using ModernWpf; -using ModernWpf.Controls; -using System; -using System.IO; +using System; using System.Windows; -using System.Windows.Data; using System.Windows.Forms; using System.Windows.Input; using System.Windows.Interop; -using System.Windows.Media; -using System.Windows.Navigation; -using Button = System.Windows.Controls.Button; -using Control = System.Windows.Controls.Control; -using KeyEventArgs = System.Windows.Input.KeyEventArgs; -using MessageBox = System.Windows.MessageBox; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Core; +using Flow.Launcher.Core.Configuration; +using Flow.Launcher.Helper; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.SettingPages.Views; +using Flow.Launcher.ViewModel; +using ModernWpf.Controls; using TextBox = System.Windows.Controls.TextBox; -using ThemeManager = ModernWpf.ThemeManager; -namespace Flow.Launcher +namespace Flow.Launcher; + +public partial class SettingWindow { - public partial class SettingWindow + private readonly Updater _updater; + private readonly IPortable _portable; + private readonly IPublicAPI _api; + private readonly Settings _settings; + private readonly SettingWindowViewModel _viewModel; + + public SettingWindow() { - public readonly IPublicAPI API; - private Settings settings; - private SettingWindowViewModel viewModel; + var viewModel = Ioc.Default.GetRequiredService(); + _settings = Ioc.Default.GetRequiredService(); + DataContext = viewModel; + _viewModel = viewModel; + _updater = Ioc.Default.GetRequiredService(); + _portable = Ioc.Default.GetRequiredService(); + _api = Ioc.Default.GetRequiredService(); + InitializePosition(); + InitializeComponent(); + } - public SettingWindow(IPublicAPI api, SettingWindowViewModel viewModel) + private void OnLoaded(object sender, RoutedEventArgs e) + { + RefreshMaximizeRestoreButton(); + // Fix (workaround) for the window freezes after lock screen (Win+L) or sleep + // https://stackoverflow.com/questions/4951058/software-rendering-mode-wpf + HwndSource hwndSource = PresentationSource.FromVisual(this) as HwndSource; + HwndTarget hwndTarget = hwndSource.CompositionTarget; + hwndTarget.RenderMode = RenderMode.SoftwareOnly; // Must use software only render mode here + + InitializePosition(); + } + + private void OnClosed(object sender, EventArgs e) + { + _settings.SettingWindowState = WindowState; + _settings.SettingWindowTop = Top; + _settings.SettingWindowLeft = Left; + _viewModel.Save(); + _api.SavePluginSettings(); + } + + private void OnCloseExecuted(object sender, ExecutedRoutedEventArgs e) + { + Close(); + } + + private void window_MouseDown(object sender, MouseButtonEventArgs e) /* for close hotkey popup */ + { + if (Keyboard.FocusedElement is not TextBox textBox) { - settings = viewModel.Settings; - DataContext = viewModel; - this.viewModel = viewModel; - API = api; - InitializePosition(); - InitializeComponent(); - + return; } + var tRequest = new TraversalRequest(FocusNavigationDirection.Next); + textBox.MoveFocus(tRequest); + } - #region General + /* Custom TitleBar */ - private void OnLoaded(object sender, RoutedEventArgs e) + private void OnMinimizeButtonClick(object sender, RoutedEventArgs e) + { + WindowState = WindowState.Minimized; + } + + private void OnMaximizeRestoreButtonClick(object sender, RoutedEventArgs e) + { + WindowState = WindowState switch { - RefreshMaximizeRestoreButton(); - // Fix (workaround) for the window freezes after lock screen (Win+L) - // https://stackoverflow.com/questions/4951058/software-rendering-mode-wpf - HwndSource hwndSource = PresentationSource.FromVisual(this) as HwndSource; - HwndTarget hwndTarget = hwndSource.CompositionTarget; - hwndTarget.RenderMode = RenderMode.SoftwareOnly; + WindowState.Maximized => WindowState.Normal, + _ => WindowState.Maximized + }; + } - pluginListView = (CollectionView)CollectionViewSource.GetDefaultView(Plugins.ItemsSource); - pluginListView.Filter = PluginListFilter; + private void OnCloseButtonClick(object sender, RoutedEventArgs e) + { + Close(); + } - pluginStoreView = (CollectionView)CollectionViewSource.GetDefaultView(StoreListBox.ItemsSource); - pluginStoreView.Filter = PluginStoreFilter; - - InitializePosition(); - } - - private void OnSelectPythonDirectoryClick(object sender, RoutedEventArgs e) + private void RefreshMaximizeRestoreButton() + { + if (WindowState == WindowState.Maximized) { - var dlg = new FolderBrowserDialog - { - SelectedPath = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) - }; - - var result = dlg.ShowDialog(); - if (result == System.Windows.Forms.DialogResult.OK) - { - string pythonDirectory = dlg.SelectedPath; - if (!string.IsNullOrEmpty(pythonDirectory)) - { - var pythonPath = Path.Combine(pythonDirectory, PluginsLoader.PythonExecutable); - if (File.Exists(pythonPath)) - { - settings.PluginSettings.PythonDirectory = pythonDirectory; - MessageBox.Show("Remember to restart Flow Launcher use new Python path"); - } - else - { - MessageBox.Show("Can't find python in given directory"); - } - } - } + MaximizeButton.Visibility = Visibility.Collapsed; + RestoreButton.Visibility = Visibility.Visible; } - - private void OnSelectFileManagerClick(object sender, RoutedEventArgs e) + else { - SelectFileManagerWindow fileManagerChangeWindow = new SelectFileManagerWindow(settings); - fileManagerChangeWindow.ShowDialog(); - } - - private void OnSelectDefaultBrowserClick(object sender, RoutedEventArgs e) - { - var browserWindow = new SelectBrowserWindow(settings); - browserWindow.ShowDialog(); - } - - #endregion - - #region Hotkey - - private void OnHotkeyControlLoaded(object sender, RoutedEventArgs e) - { - _ = HotkeyControl.SetHotkeyAsync(viewModel.Settings.Hotkey, false); - } - - private void OnHotkeyControlFocused(object sender, RoutedEventArgs e) - { - HotKeyMapper.RemoveHotkey(settings.Hotkey); - } - - private void OnHotkeyControlFocusLost(object sender, RoutedEventArgs e) - { - if (HotkeyControl.CurrentHotkeyAvailable) - { - HotKeyMapper.SetHotkey(HotkeyControl.CurrentHotkey, HotKeyMapper.OnToggleHotkey); - HotKeyMapper.RemoveHotkey(settings.Hotkey); - settings.Hotkey = HotkeyControl.CurrentHotkey.ToString(); - } - else - { - HotKeyMapper.SetHotkey(new HotkeyModel(settings.Hotkey), HotKeyMapper.OnToggleHotkey); - } - } - - private void OnDeleteCustomHotkeyClick(object sender, RoutedEventArgs e) - { - var item = viewModel.SelectedCustomPluginHotkey; - if (item == null) - { - MessageBox.Show(InternationalizationManager.Instance.GetTranslation("pleaseSelectAnItem")); - return; - } - - string deleteWarning = - string.Format(InternationalizationManager.Instance.GetTranslation("deleteCustomHotkeyWarning"), - item.Hotkey); - if ( - MessageBox.Show(deleteWarning, InternationalizationManager.Instance.GetTranslation("delete"), - MessageBoxButton.YesNo) == MessageBoxResult.Yes) - { - settings.CustomPluginHotkeys.Remove(item); - HotKeyMapper.RemoveHotkey(item.Hotkey); - } - } - - private void OnEditCustomHotkeyClick(object sender, RoutedEventArgs e) - { - var item = viewModel.SelectedCustomPluginHotkey; - if (item != null) - { - CustomQueryHotkeySetting window = new CustomQueryHotkeySetting(this, settings); - window.UpdateItem(item); - window.ShowDialog(); - } - else - { - MessageBox.Show(InternationalizationManager.Instance.GetTranslation("pleaseSelectAnItem")); - } - } - - private void OnAddCustomHotkeyClick(object sender, RoutedEventArgs e) - { - new CustomQueryHotkeySetting(this, settings).ShowDialog(); - } - - #endregion - - #region Plugin - - private void OnPluginToggled(object sender, RoutedEventArgs e) - { - var id = viewModel.SelectedPlugin.PluginPair.Metadata.ID; - // used to sync the current status from the plugin manager into the setting to keep consistency after save - settings.PluginSettings.Plugins[id].Disabled = viewModel.SelectedPlugin.PluginPair.Metadata.Disabled; - } - - private void OnPluginPriorityClick(object sender, RoutedEventArgs e) - { - if (sender is Control { DataContext: PluginViewModel pluginViewModel }) - { - PriorityChangeWindow priorityChangeWindow = new PriorityChangeWindow(pluginViewModel.PluginPair.Metadata.ID, settings, pluginViewModel); - priorityChangeWindow.ShowDialog(); - } - } - - private void OnPluginActionKeywordsClick(object sender, RoutedEventArgs e) - { - var id = viewModel.SelectedPlugin.PluginPair.Metadata.ID; - ActionKeywords changeKeywordsWindow = new ActionKeywords(id, settings, viewModel.SelectedPlugin); - changeKeywordsWindow.ShowDialog(); - } - - private void OnPluginNameClick(object sender, MouseButtonEventArgs e) - { - if (e.ChangedButton == MouseButton.Left) - { - var website = viewModel.SelectedPlugin.PluginPair.Metadata.Website; - if (!string.IsNullOrEmpty(website)) - { - var uri = new Uri(website); - if (Uri.CheckSchemeName(uri.Scheme)) - { - website.OpenInBrowserTab(); - } - } - } - } - - private void OnPluginDirecotyClick(object sender, MouseButtonEventArgs e) - { - if (e.ChangedButton == MouseButton.Left) - { - var directory = viewModel.SelectedPlugin.PluginPair.Metadata.PluginDirectory; - if (!string.IsNullOrEmpty(directory)) - PluginManager.API.OpenDirectory(directory); - } - } - - #endregion - - #region Proxy - - private void OnTestProxyClick(object sender, RoutedEventArgs e) - { // TODO: change to command - var msg = viewModel.TestProxy(); - MessageBox.Show(msg); // TODO: add message box service - } - - #endregion - - private void OnCheckUpdates(object sender, RoutedEventArgs e) - { - viewModel.UpdateApp(); // TODO: change to command - } - - private void OnRequestNavigate(object sender, RequestNavigateEventArgs e) - { - API.OpenUrl(e.Uri.AbsoluteUri); - e.Handled = true; - } - - private void OnClosed(object sender, EventArgs e) - { - settings.SettingWindowState = WindowState; - settings.SettingWindowTop = Top; - settings.SettingWindowLeft = Left; - viewModel.Save(); - } - - private void OnCloseExecuted(object sender, ExecutedRoutedEventArgs e) - { - Close(); - } - - private void OpenThemeFolder(object sender, RoutedEventArgs e) - { - PluginManager.API.OpenDirectory(Path.Combine(DataLocation.DataDirectory(), Constant.Themes)); - } - - private void OpenSettingFolder(object sender, RoutedEventArgs e) - { - PluginManager.API.OpenDirectory(Path.Combine(DataLocation.DataDirectory(), Constant.Settings)); - } - - private void OpenWelcomeWindow(object sender, RoutedEventArgs e) - { - var WelcomeWindow = new WelcomeWindow(settings); - WelcomeWindow.ShowDialog(); - } - private void OpenLogFolder(object sender, RoutedEventArgs e) - { - PluginManager.API.OpenDirectory(Path.Combine(DataLocation.DataDirectory(), Constant.Logs, Constant.Version)); - } - private void ClearLogFolder(object sender, RoutedEventArgs e) - { - var confirmResult = MessageBox.Show( - InternationalizationManager.Instance.GetTranslation("clearlogfolderMessage"), - InternationalizationManager.Instance.GetTranslation("clearlogfolder"), - MessageBoxButton.YesNo); - - if (confirmResult == MessageBoxResult.Yes) - { - viewModel.ClearLogFolder(); - - ClearLogFolderBtn.Content = viewModel.CheckLogFolder; - } - } - - private static T FindParent(DependencyObject child) where T : DependencyObject - { - //get parent item - DependencyObject parentObject = VisualTreeHelper.GetParent(child); - - //we've reached the end of the tree - if (parentObject == null) return null; - - //check if the parent matches the type we're looking for - T parent = parentObject as T; - if (parent != null) - return parent; - else - return FindParent(parentObject); - } - - private void OnExternalPluginInstallClick(object sender, RoutedEventArgs e) - { - if (sender is not Button { DataContext: PluginStoreItemViewModel plugin } button) - { - return; - } - - if (storeClickedButton != null) - { - FlyoutService.GetFlyout(storeClickedButton).Hide(); - } - - viewModel.DisplayPluginQuery($"install {plugin.Name}", PluginManager.GetPluginForId("9f8f9b14-2518-4907-b211-35ab6290dee7")); - } - - private void OnExternalPluginUninstallClick(object sender, MouseButtonEventArgs e) - { - if (e.ChangedButton == MouseButton.Left) - { - var name = viewModel.SelectedPlugin.PluginPair.Metadata.Name; - viewModel.DisplayPluginQuery($"uninstall {name}", PluginManager.GetPluginForId("9f8f9b14-2518-4907-b211-35ab6290dee7")); - } - - - } - - private void OnExternalPluginUninstallClick(object sender, RoutedEventArgs e) - { - if (storeClickedButton != null) - { - FlyoutService.GetFlyout(storeClickedButton).Hide(); - } - - if (sender is Button { DataContext: PluginStoreItemViewModel plugin }) - viewModel.DisplayPluginQuery($"uninstall {plugin.Name}", PluginManager.GetPluginForId("9f8f9b14-2518-4907-b211-35ab6290dee7")); - - } - - private void OnExternalPluginUpdateClick(object sender, RoutedEventArgs e) - { - if (storeClickedButton != null) - { - FlyoutService.GetFlyout(storeClickedButton).Hide(); - } - if (sender is Button { DataContext: PluginStoreItemViewModel plugin }) - viewModel.DisplayPluginQuery($"update {plugin.Name}", PluginManager.GetPluginForId("9f8f9b14-2518-4907-b211-35ab6290dee7")); - - } - - private void window_MouseDown(object sender, MouseButtonEventArgs e) /* for close hotkey popup */ - { - if (Keyboard.FocusedElement is not TextBox textBox) - { - return; - } - var tRequest = new TraversalRequest(FocusNavigationDirection.Next); - textBox.MoveFocus(tRequest); - } - - private void ColorSchemeSelectedIndexChanged(object sender, EventArgs e) - => ThemeManager.Current.ApplicationTheme = settings.ColorScheme switch - { - Constant.Light => ApplicationTheme.Light, - Constant.Dark => ApplicationTheme.Dark, - Constant.System => null, - _ => ThemeManager.Current.ApplicationTheme - }; - - /* Custom TitleBar */ - - private void OnMinimizeButtonClick(object sender, RoutedEventArgs e) - { - WindowState = WindowState.Minimized; - } - - private void OnMaximizeRestoreButtonClick(object sender, RoutedEventArgs e) - { - WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; - } - - private void OnCloseButtonClick(object sender, RoutedEventArgs e) - { - - Close(); - } - - private void RefreshMaximizeRestoreButton() - { - if (WindowState == WindowState.Maximized) - { - maximizeButton.Visibility = Visibility.Collapsed; - restoreButton.Visibility = Visibility.Visible; - } - else - { - maximizeButton.Visibility = Visibility.Visible; - restoreButton.Visibility = Visibility.Collapsed; - } - } - - private void Window_StateChanged(object sender, EventArgs e) - { - RefreshMaximizeRestoreButton(); - } - - #region Shortcut - - private void OnDeleteCustomShortCutClick(object sender, RoutedEventArgs e) - { - viewModel.DeleteSelectedCustomShortcut(); - } - - private void OnEditCustomShortCutClick(object sender, RoutedEventArgs e) - { - if (viewModel.EditSelectedCustomShortcut()) - { - customShortcutView.Items.Refresh(); - } - } - - private void OnAddCustomShortCutClick(object sender, RoutedEventArgs e) - { - viewModel.AddCustomShortcut(); - } - - #endregion - - private CollectionView pluginListView; - private CollectionView pluginStoreView; - - private bool PluginListFilter(object item) - { - if (string.IsNullOrEmpty(pluginFilterTxb.Text)) - return true; - if (item is PluginViewModel model) - { - return StringMatcher.FuzzySearch(pluginFilterTxb.Text, model.PluginPair.Metadata.Name).IsSearchPrecisionScoreMet(); - } - return false; - } - - private bool PluginStoreFilter(object item) - { - if (string.IsNullOrEmpty(pluginStoreFilterTxb.Text)) - return true; - if (item is PluginStoreItemViewModel model) - { - return StringMatcher.FuzzySearch(pluginStoreFilterTxb.Text, model.Name).IsSearchPrecisionScoreMet() - || StringMatcher.FuzzySearch(pluginStoreFilterTxb.Text, model.Description).IsSearchPrecisionScoreMet(); - } - return false; - } - - private string lastPluginListSearch = ""; - private string lastPluginStoreSearch = ""; - - private void RefreshPluginListEventHandler(object sender, RoutedEventArgs e) - { - if (pluginFilterTxb.Text != lastPluginListSearch) - { - lastPluginListSearch = pluginFilterTxb.Text; - pluginListView.Refresh(); - } - } - - private void RefreshPluginStoreEventHandler(object sender, RoutedEventArgs e) - { - if (pluginStoreFilterTxb.Text != lastPluginStoreSearch) - { - lastPluginStoreSearch = pluginStoreFilterTxb.Text; - pluginStoreView.Refresh(); - } - } - - private void PluginFilterTxb_OnKeyDown(object sender, KeyEventArgs e) - { - if (e.Key == Key.Enter) - RefreshPluginListEventHandler(sender, e); - } - - private void PluginStoreFilterTxb_OnKeyDown(object sender, KeyEventArgs e) - { - if (e.Key == Key.Enter) - RefreshPluginStoreEventHandler(sender, e); - } - - private void OnPluginSettingKeydown(object sender, KeyEventArgs e) - { - if ((Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control && e.Key == Key.F) - pluginFilterTxb.Focus(); - } - - private void PluginStore_OnKeyDown(object sender, KeyEventArgs e) - { - if (e.Key == Key.F && (Keyboard.Modifiers & ModifierKeys.Control) != 0) - { - pluginStoreFilterTxb.Focus(); - } - } - - public void InitializePosition() - { - if (settings.SettingWindowTop >= 0 && settings.SettingWindowLeft >= 0) - { - Top = settings.SettingWindowTop; - Left = settings.SettingWindowLeft; - } - else - { - Top = WindowTop(); - Left = WindowLeft(); - } - WindowState = settings.SettingWindowState; - } - - public double WindowLeft() - { - var screen = Screen.FromPoint(System.Windows.Forms.Cursor.Position); - var dip1 = WindowsInteropHelper.TransformPixelsToDIP(this, screen.WorkingArea.X, 0); - var dip2 = WindowsInteropHelper.TransformPixelsToDIP(this, screen.WorkingArea.Width, 0); - var left = (dip2.X - this.ActualWidth) / 2 + dip1.X; - return left; - } - - public double WindowTop() - { - var screen = Screen.FromPoint(System.Windows.Forms.Cursor.Position); - var dip1 = WindowsInteropHelper.TransformPixelsToDIP(this, 0, screen.WorkingArea.Y); - var dip2 = WindowsInteropHelper.TransformPixelsToDIP(this, 0, screen.WorkingArea.Height); - var top = (dip2.Y - this.ActualHeight) / 2 + dip1.Y - 20; - return top; - } - - private Button storeClickedButton; - - private void StoreListItem_Click(object sender, RoutedEventArgs e) - { - if (sender is not Button button) - return; - - storeClickedButton = button; - - var flyout = FlyoutService.GetFlyout(button); - flyout.Closed += (_, _) => - { - storeClickedButton = null; - }; - + MaximizeButton.Visibility = Visibility.Visible; + RestoreButton.Visibility = Visibility.Collapsed; } } + + private void Window_StateChanged(object sender, EventArgs e) + { + RefreshMaximizeRestoreButton(); + } + + public void InitializePosition() + { + var previousTop = _settings.SettingWindowTop; + var previousLeft = _settings.SettingWindowLeft; + + if (previousTop == null || previousLeft == null || !IsPositionValid(previousTop.Value, previousLeft.Value)) + { + Top = WindowTop(); + Left = WindowLeft(); + } + else + { + Top = previousTop.Value; + Left = previousLeft.Value; + } + WindowState = _settings.SettingWindowState; + } + + private static bool IsPositionValid(double top, double left) + { + foreach (var screen in Screen.AllScreens) + { + var workingArea = screen.WorkingArea; + + if (left >= workingArea.Left && left < workingArea.Right && + top >= workingArea.Top && top < workingArea.Bottom) + { + return true; + } + } + return false; + } + + private double WindowLeft() + { + var screen = Screen.FromPoint(System.Windows.Forms.Cursor.Position); + var dip1 = WindowsInteropHelper.TransformPixelsToDIP(this, screen.WorkingArea.X, 0); + var dip2 = WindowsInteropHelper.TransformPixelsToDIP(this, screen.WorkingArea.Width, 0); + var left = (dip2.X - ActualWidth) / 2 + dip1.X; + return left; + } + + private double WindowTop() + { + var screen = Screen.FromPoint(System.Windows.Forms.Cursor.Position); + var dip1 = WindowsInteropHelper.TransformPixelsToDIP(this, 0, screen.WorkingArea.Y); + var dip2 = WindowsInteropHelper.TransformPixelsToDIP(this, 0, screen.WorkingArea.Height); + var top = (dip2.Y - ActualHeight) / 2 + dip1.Y - 20; + return top; + } + + private void NavigationView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) + { + var paneData = new PaneData(_settings, _updater, _portable); + if (args.IsSettingsSelected) + { + ContentFrame.Navigate(typeof(SettingsPaneGeneral), paneData); + } + else + { + var selectedItem = (NavigationViewItem)args.SelectedItem; + if (selectedItem == null) + { + NavView_Loaded(sender, null); /* Reset First Page */ + return; + } + + var pageType = selectedItem.Name switch + { + nameof(General) => typeof(SettingsPaneGeneral), + nameof(Plugins) => typeof(SettingsPanePlugins), + nameof(PluginStore) => typeof(SettingsPanePluginStore), + nameof(Theme) => typeof(SettingsPaneTheme), + nameof(Hotkey) => typeof(SettingsPaneHotkey), + nameof(Proxy) => typeof(SettingsPaneProxy), + nameof(About) => typeof(SettingsPaneAbout), + _ => typeof(SettingsPaneGeneral) + }; + ContentFrame.Navigate(pageType, paneData); + } + } + + private void NavView_Loaded(object sender, RoutedEventArgs e) + { + if (ContentFrame.IsLoaded) + { + ContentFrame_Loaded(sender, e); + } + else + { + ContentFrame.Loaded += ContentFrame_Loaded; + } + } + + private void ContentFrame_Loaded(object sender, RoutedEventArgs e) + { + NavView.SelectedItem ??= NavView.MenuItems[0]; /* Set First Page */ + } + + public record PaneData(Settings Settings, Updater Updater, IPortable Portable); } diff --git a/Flow.Launcher/Settings.cs b/Flow.Launcher/Settings.cs index c5294b372..8f53c061f 100644 --- a/Flow.Launcher/Settings.cs +++ b/Flow.Launcher/Settings.cs @@ -1,6 +1,7 @@ -namespace Flow.Launcher.Properties { - - +namespace Flow.Launcher.Properties +{ + + // This class allows you to handle specific events on the settings class: // The SettingChanging event is raised before a setting's value is changed. // The PropertyChanged event is raised after a setting's value is changed. diff --git a/Flow.Launcher/Storage/TopMostRecord.cs b/Flow.Launcher/Storage/TopMostRecord.cs index 052c296cd..7f35904a5 100644 --- a/Flow.Launcher/Storage/TopMostRecord.cs +++ b/Flow.Launcher/Storage/TopMostRecord.cs @@ -1,61 +1,81 @@ -using System.Collections.Generic; +using System.Collections.Concurrent; +using System.Collections.Generic; using System.Text.Json.Serialization; using Flow.Launcher.Plugin; namespace Flow.Launcher.Storage { - // todo this class is not thread safe.... but used from multiple threads. public class TopMostRecord { [JsonInclude] - public Dictionary records { get; private set; } = new Dictionary(); + public ConcurrentDictionary records { get; private set; } = new ConcurrentDictionary(); internal bool IsTopMost(Result result) { - if (records.Count == 0 || !records.ContainsKey(result.OriginQuery.RawQuery)) + // origin query is null when user select the context menu item directly of one item from query list + // in this case, we do not need to check if the result is top most + if (records.IsEmpty || result.OriginQuery == null || + !records.TryGetValue(result.OriginQuery.RawQuery, out var value)) { return false; } // since this dictionary should be very small (or empty) going over it should be pretty fast. - return records[result.OriginQuery.RawQuery].Equals(result); + return value.Equals(result); } internal void Remove(Result result) { - records.Remove(result.OriginQuery.RawQuery); + // origin query is null when user select the context menu item directly of one item from query list + // in this case, we do not need to remove the record + if (result.OriginQuery == null) + { + return; + } + + records.Remove(result.OriginQuery.RawQuery, out _); } internal void AddOrUpdate(Result result) { + // origin query is null when user select the context menu item directly of one item from query list + // in this case, we do not need to add or update the record + if (result.OriginQuery == null) + { + return; + } + var record = new Record { PluginID = result.PluginID, Title = result.Title, - SubTitle = result.SubTitle + SubTitle = result.SubTitle, + RecordKey = result.RecordKey }; - records[result.OriginQuery.RawQuery] = record; - - } - - public void Load(Dictionary dictionary) - { - records = dictionary; + records.AddOrUpdate(result.OriginQuery.RawQuery, record, (key, oldValue) => record); } } - public class Record { public string Title { get; set; } public string SubTitle { get; set; } public string PluginID { get; set; } + public string RecordKey { get; set; } public bool Equals(Result r) { - return Title == r.Title - && SubTitle == r.SubTitle - && PluginID == r.PluginID; + if (string.IsNullOrEmpty(RecordKey) || string.IsNullOrEmpty(r.RecordKey)) + { + return Title == r.Title + && SubTitle == r.SubTitle + && PluginID == r.PluginID; + } + else + { + return RecordKey == r.RecordKey + && PluginID == r.PluginID; + } } } } diff --git a/Flow.Launcher/Storage/UserSelectedRecord.cs b/Flow.Launcher/Storage/UserSelectedRecord.cs index 23d8c4faf..d30dccd26 100644 --- a/Flow.Launcher/Storage/UserSelectedRecord.cs +++ b/Flow.Launcher/Storage/UserSelectedRecord.cs @@ -1,11 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Generic; using System.Text.Json.Serialization; -using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Storage; using Flow.Launcher.Plugin; -using Flow.Launcher.ViewModel; namespace Flow.Launcher.Storage { @@ -20,7 +15,6 @@ namespace Flow.Launcher.Storage [JsonInclude, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Dictionary records { get; private set; } - public UserSelectedRecord() { recordsWithQuery = new Dictionary(); @@ -50,16 +44,38 @@ namespace Flow.Launcher.Storage private static int GenerateResultHashCode(Result result) { - int hashcode = GenerateStaticHashCode(result.Title); - return GenerateStaticHashCode(result.SubTitle, hashcode); + if (string.IsNullOrEmpty(result.RecordKey)) + { + int hashcode = GenerateStaticHashCode(result.Title); + return GenerateStaticHashCode(result.SubTitle, hashcode); + } + else + { + return GenerateStaticHashCode(result.RecordKey); + } } private static int GenerateQueryAndResultHashCode(Query query, Result result) { + // query is null when user select the context menu item directly of one item from query list + // so we only need to consider the result + if (query == null) + { + return GenerateResultHashCode(result); + } + int hashcode = GenerateStaticHashCode(query.ActionKeyword); hashcode = GenerateStaticHashCode(query.Search, hashcode); - hashcode = GenerateStaticHashCode(result.Title, hashcode); - hashcode = GenerateStaticHashCode(result.SubTitle, hashcode); + + if (string.IsNullOrEmpty(result.RecordKey)) + { + hashcode = GenerateStaticHashCode(result.Title, hashcode); + hashcode = GenerateStaticHashCode(result.SubTitle, hashcode); + } + else + { + hashcode = GenerateStaticHashCode(result.RecordKey, hashcode); + } return hashcode; } @@ -106,4 +122,4 @@ namespace Flow.Launcher.Storage return selectedCount; } } -} \ No newline at end of file +} diff --git a/Flow.Launcher/Themes/Atom.xaml b/Flow.Launcher/Themes/Atom.xaml deleted file mode 100644 index f532c97d5..000000000 --- a/Flow.Launcher/Themes/Atom.xaml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - #2c313c - - - - - - - - - - - diff --git a/Flow.Launcher/Themes/Base.xaml b/Flow.Launcher/Themes/Base.xaml index cb0921b80..b96cb661e 100644 --- a/Flow.Launcher/Themes/Base.xaml +++ b/Flow.Launcher/Themes/Base.xaml @@ -3,18 +3,38 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:system="clr-namespace:System;assembly=mscorlib" xmlns:userSettings="clr-namespace:Flow.Launcher.Infrastructure.UserSettings;assembly=Flow.Launcher.Infrastructure"> + 0 + 0 + 0 + + + 52 + + - + + @@ -86,18 +117,20 @@ - + @@ -106,10 +139,11 @@ - + + @@ -159,7 +193,7 @@ + @@ -242,6 +285,11 @@ + + + + + @@ -264,12 +312,12 @@ Content="{TemplateBinding Content}" ContentTemplate="{TemplateBinding ContentTemplate}" /> - + + + + + + + + + + + + + + @@ -371,7 +508,7 @@ x:Key="ClockPanelPosition" BasedOn="{StaticResource BaseClockPanelPosition}" TargetType="{x:Type Canvas}"> - + + + + + + + + + + diff --git a/Flow.Launcher/Themes/BlackAndWhite.xaml b/Flow.Launcher/Themes/BlackAndWhite.xaml deleted file mode 100644 index 395f5d614..000000000 --- a/Flow.Launcher/Themes/BlackAndWhite.xaml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - #494949 - - - - \ No newline at end of file diff --git a/Flow.Launcher/Themes/BlurBlack Darker.xaml b/Flow.Launcher/Themes/BlurBlack Darker.xaml index 6dc0db45e..286419c70 100644 --- a/Flow.Launcher/Themes/BlurBlack Darker.xaml +++ b/Flow.Launcher/Themes/BlurBlack Darker.xaml @@ -1,4 +1,9 @@ - + @@ -37,7 +42,6 @@ x:Key="WindowBorderStyle" BasedOn="{StaticResource BaseWindowBorderStyle}" TargetType="{x:Type Border}"> - @@ -48,6 +52,15 @@ + + + + + + diff --git a/Flow.Launcher/Themes/BlurBlack.xaml b/Flow.Launcher/Themes/BlurBlack.xaml index 5a0023d4a..5d0fc2c3c 100644 --- a/Flow.Launcher/Themes/BlurBlack.xaml +++ b/Flow.Launcher/Themes/BlurBlack.xaml @@ -1,4 +1,9 @@ - + @@ -45,6 +50,15 @@ + + - \ No newline at end of file + + + + + diff --git a/Flow.Launcher/Themes/BlurWhite.xaml b/Flow.Launcher/Themes/BlurWhite.xaml index 6308f9e47..a13f3bfcc 100644 --- a/Flow.Launcher/Themes/BlurWhite.xaml +++ b/Flow.Launcher/Themes/BlurWhite.xaml @@ -1,4 +1,9 @@ - + @@ -65,7 +70,6 @@ x:Key="ItemTitleStyle" BasedOn="{StaticResource BaseItemTitleStyle}" TargetType="{x:Type TextBlock}"> - + + + + diff --git a/Flow.Launcher/Themes/Circle System.xaml b/Flow.Launcher/Themes/Circle System.xaml new file mode 100644 index 000000000..343e7c5bc --- /dev/null +++ b/Flow.Launcher/Themes/Circle System.xaml @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + 8 + 10 0 10 0 + 0 0 0 10 + + + + + + + + diff --git a/Flow.Launcher/Themes/Cyan Dark.xaml b/Flow.Launcher/Themes/Cyan Dark.xaml new file mode 100644 index 000000000..106b1b6d9 --- /dev/null +++ b/Flow.Launcher/Themes/Cyan Dark.xaml @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + #1e292f + + + + + + + + + + 0 + 0 + 0 0 0 4 + + + + + + + + \ No newline at end of file diff --git a/Flow.Launcher/Themes/Darker Glass.xaml b/Flow.Launcher/Themes/Darker Glass.xaml index 13c9e2bc5..2faddd38b 100644 --- a/Flow.Launcher/Themes/Darker Glass.xaml +++ b/Flow.Launcher/Themes/Darker Glass.xaml @@ -1,11 +1,11 @@ - - + 0 0 0 8 - + + + + + + \ No newline at end of file diff --git a/Flow.Launcher/Themes/Darker.xaml b/Flow.Launcher/Themes/Darker.xaml index b255c4d9e..d1abbe978 100644 --- a/Flow.Launcher/Themes/Darker.xaml +++ b/Flow.Launcher/Themes/Darker.xaml @@ -5,6 +5,7 @@ + 0 0 0 8 diff --git a/Flow.Launcher/Themes/Discord Dark.xaml b/Flow.Launcher/Themes/Discord Dark.xaml index 92b8d8da7..9e39ee5bd 100644 --- a/Flow.Launcher/Themes/Discord Dark.xaml +++ b/Flow.Launcher/Themes/Discord Dark.xaml @@ -1,10 +1,11 @@ - + 0 0 0 6 - + + + + + \ No newline at end of file diff --git a/Flow.Launcher/Themes/Dracula.xaml b/Flow.Launcher/Themes/Dracula.xaml index 146038ba9..eb8cc9557 100644 --- a/Flow.Launcher/Themes/Dracula.xaml +++ b/Flow.Launcher/Themes/Dracula.xaml @@ -5,6 +5,7 @@ + 0 0 0 6 + + + + diff --git a/Flow.Launcher/Themes/Gray.xaml b/Flow.Launcher/Themes/Gray.xaml index 1cacc8ec2..84570e9d6 100644 --- a/Flow.Launcher/Themes/Gray.xaml +++ b/Flow.Launcher/Themes/Gray.xaml @@ -1,36 +1,45 @@ - - + + - + + - + + - #787878 + #797d86 + + - + TargetType="{x:Type ScrollBar}" /> + + + + 0 + 0 + 0 0 0 0 + + + + + + diff --git a/Flow.Launcher/Themes/League.xaml b/Flow.Launcher/Themes/League.xaml index 771d38c39..f1c8ba192 100644 --- a/Flow.Launcher/Themes/League.xaml +++ b/Flow.Launcher/Themes/League.xaml @@ -1,4 +1,4 @@ - + @@ -9,7 +9,6 @@ x:Key="QueryBoxStyle" BasedOn="{StaticResource BaseQueryBoxStyle}" TargetType="{x:Type TextBox}"> - @@ -52,6 +51,14 @@ TargetType="{x:Type Line}"> + + + + + \ No newline at end of file diff --git a/Flow.Launcher/Themes/Light.xaml b/Flow.Launcher/Themes/Light.xaml deleted file mode 100644 index 10c995941..000000000 --- a/Flow.Launcher/Themes/Light.xaml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - #d9d9d9 - - - - - - - - - \ No newline at end of file diff --git a/Flow.Launcher/Themes/Metro Server.xaml b/Flow.Launcher/Themes/Metro Server.xaml deleted file mode 100644 index 65659aec5..000000000 --- a/Flow.Launcher/Themes/Metro Server.xaml +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - - - - - - - - - #04152E - - - - - - - \ No newline at end of file diff --git a/Flow.Launcher/Themes/Win11Dark.xaml b/Flow.Launcher/Themes/Midnight.xaml similarity index 51% rename from Flow.Launcher/Themes/Win11Dark.xaml rename to Flow.Launcher/Themes/Midnight.xaml index aa510acaf..91ff620d5 100644 --- a/Flow.Launcher/Themes/Win11Dark.xaml +++ b/Flow.Launcher/Themes/Midnight.xaml @@ -2,6 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:system="clr-namespace:System;assembly=mscorlib"> + @@ -9,125 +10,94 @@ x:Key="ItemGlyph" BasedOn="{StaticResource BaseGlyphStyle}" TargetType="{x:Type TextBlock}"> - + + + - + TargetType="{x:Type Window}" /> + TargetType="{x:Type Line}" /> + - #198F8F8F - - - - + #202938 + + + + + + + 8 + 10 0 10 0 + 0 0 0 10 + + + + + diff --git a/Flow.Launcher/Themes/Nord Darker.xaml b/Flow.Launcher/Themes/Nord Darker.xaml index 7c29eda22..bb5dbf87f 100644 --- a/Flow.Launcher/Themes/Nord Darker.xaml +++ b/Flow.Launcher/Themes/Nord Darker.xaml @@ -3,6 +3,7 @@ + 0 0 0 8 @@ -22,7 +22,6 @@ x:Key="QuerySuggestionBoxStyle" BasedOn="{StaticResource BaseQuerySuggestionBoxStyle}" TargetType="{x:Type TextBox}"> - @@ -35,6 +34,14 @@ + + + + + diff --git a/Flow.Launcher/Themes/Nord.xaml b/Flow.Launcher/Themes/Nord.xaml deleted file mode 100644 index 735041656..000000000 --- a/Flow.Launcher/Themes/Nord.xaml +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - - - - - - - - - - - - - #596479 - - - - - - - - diff --git a/Flow.Launcher/Themes/Pink.xaml b/Flow.Launcher/Themes/Pink.xaml index c5f701c46..d6f9813d0 100644 --- a/Flow.Launcher/Themes/Pink.xaml +++ b/Flow.Launcher/Themes/Pink.xaml @@ -2,37 +2,46 @@ + 0 0 0 0 + - #cc1081 + #0e172c + + + + \ No newline at end of file diff --git a/Flow.Launcher/Themes/SlimLight.xaml b/Flow.Launcher/Themes/SlimLight.xaml new file mode 100644 index 000000000..078b07048 --- /dev/null +++ b/Flow.Launcher/Themes/SlimLight.xaml @@ -0,0 +1,230 @@ + + + + + + 38 + + + + + + + + + + + + + + #d6d4d7 + + + + + + + + + + + 6 + 4 0 4 0 + 0 0 0 4 + + + + + + + + diff --git a/Flow.Launcher/Themes/Sublime.xaml b/Flow.Launcher/Themes/Sublime.xaml index 1b6f25e83..6cf716d4e 100644 --- a/Flow.Launcher/Themes/Sublime.xaml +++ b/Flow.Launcher/Themes/Sublime.xaml @@ -5,6 +5,7 @@ + 0 0 0 8 + + + + diff --git a/Flow.Launcher/Themes/Ubuntu.xaml b/Flow.Launcher/Themes/Ubuntu.xaml new file mode 100644 index 000000000..3c9e0a125 --- /dev/null +++ b/Flow.Launcher/Themes/Ubuntu.xaml @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + #4d4d4d + + + + + + + + + + 0 + 0 0 0 0 + 0 0 0 8 + + + + + + + + \ No newline at end of file diff --git a/Flow.Launcher/Themes/Win10Light.xaml b/Flow.Launcher/Themes/Win10Light.xaml deleted file mode 100644 index 6a4a07c56..000000000 --- a/Flow.Launcher/Themes/Win10Light.xaml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - #ccd0d4 - - - - - - - - - - - diff --git a/Flow.Launcher/Themes/Win11System.xaml b/Flow.Launcher/Themes/Win10System.xaml similarity index 73% rename from Flow.Launcher/Themes/Win11System.xaml rename to Flow.Launcher/Themes/Win10System.xaml index 3cf7fd123..4f8ea5532 100644 --- a/Flow.Launcher/Themes/Win11System.xaml +++ b/Flow.Launcher/Themes/Win10System.xaml @@ -1,3 +1,8 @@ + + 0 0 0 8 + + + + + + diff --git a/Flow.Launcher/Themes/Win11Light.xaml b/Flow.Launcher/Themes/Win11Light.xaml index 39c13d28c..4fe3c0495 100644 --- a/Flow.Launcher/Themes/Win11Light.xaml +++ b/Flow.Launcher/Themes/Win11Light.xaml @@ -1,65 +1,77 @@ + + + + + + - + TargetType="{x:Type Window}" /> + - #198F8F8F - - - - + + + + + + + + 5 + 10 0 10 0 + 0 10 0 10 + + + + + diff --git a/Flow.Launcher/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs index 29aef88fd..6b0144a03 100644 --- a/Flow.Launcher/ViewModel/MainViewModel.cs +++ b/Flow.Launcher/ViewModel/MainViewModel.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Windows; -using System.Windows.Input; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Core.Resource; using Flow.Launcher.Helper; @@ -20,10 +19,13 @@ using Microsoft.VisualStudio.Threading; using System.Text; using System.Threading.Channels; using ISavable = Flow.Launcher.Plugin.ISavable; -using System.IO; -using System.Collections.Specialized; using CommunityToolkit.Mvvm.Input; using System.Globalization; +using System.Windows.Input; +using System.ComponentModel; +using Flow.Launcher.Infrastructure.Image; +using System.Windows.Media; +using CommunityToolkit.Mvvm.DependencyInjection; namespace Flow.Launcher.ViewModel { @@ -31,8 +33,6 @@ namespace Flow.Launcher.ViewModel { #region Private Fields - private const string DefaultOpenResultModifiers = "Alt"; - private bool _isQueryRunning; private Query _lastQuery; private string _queryTextBeforeLeaveResults; @@ -41,6 +41,7 @@ namespace Flow.Launcher.ViewModel private readonly FlowLauncherJsonStorage _userSelectedRecordStorage; private readonly FlowLauncherJsonStorage _topMostRecordStorage; private readonly History _history; + private int lastHistoryIndex = 1; private readonly UserSelectedRecord _userSelectedRecord; private readonly TopMostRecord _topMostRecord; @@ -56,18 +57,80 @@ namespace Flow.Launcher.ViewModel #region Constructor - public MainViewModel(Settings settings) + public MainViewModel() { _queryTextBeforeLeaveResults = ""; _queryText = ""; _lastQuery = new Query(); - Settings = settings; + Settings = Ioc.Default.GetRequiredService(); Settings.PropertyChanged += (_, args) => { - if (args.PropertyName == nameof(Settings.WindowSize)) + switch (args.PropertyName) { - OnPropertyChanged(nameof(MainWindowWidth)); + case nameof(Settings.WindowSize): + OnPropertyChanged(nameof(MainWindowWidth)); + break; + case nameof(Settings.WindowHeightSize): + OnPropertyChanged(nameof(MainWindowHeight)); + break; + case nameof(Settings.QueryBoxFontSize): + OnPropertyChanged(nameof(QueryBoxFontSize)); + break; + case nameof(Settings.ItemHeightSize): + OnPropertyChanged(nameof(ItemHeightSize)); + break; + case nameof(Settings.ResultItemFontSize): + OnPropertyChanged(nameof(ResultItemFontSize)); + break; + case nameof(Settings.ResultSubItemFontSize): + OnPropertyChanged(nameof(ResultSubItemFontSize)); + break; + case nameof(Settings.AlwaysStartEn): + OnPropertyChanged(nameof(StartWithEnglishMode)); + break; + case nameof(Settings.OpenResultModifiers): + OnPropertyChanged(nameof(OpenResultCommandModifiers)); + break; + case nameof(Settings.PreviewHotkey): + OnPropertyChanged(nameof(PreviewHotkey)); + break; + case nameof(Settings.AutoCompleteHotkey): + OnPropertyChanged(nameof(AutoCompleteHotkey)); + break; + case nameof(Settings.CycleHistoryUpHotkey): + OnPropertyChanged(nameof(CycleHistoryUpHotkey)); + break; + case nameof(Settings.CycleHistoryDownHotkey): + OnPropertyChanged(nameof(CycleHistoryDownHotkey)); + break; + case nameof(Settings.AutoCompleteHotkey2): + OnPropertyChanged(nameof(AutoCompleteHotkey2)); + break; + case nameof(Settings.SelectNextItemHotkey): + OnPropertyChanged(nameof(SelectNextItemHotkey)); + break; + case nameof(Settings.SelectNextItemHotkey2): + OnPropertyChanged(nameof(SelectNextItemHotkey2)); + break; + case nameof(Settings.SelectPrevItemHotkey): + OnPropertyChanged(nameof(SelectPrevItemHotkey)); + break; + case nameof(Settings.SelectPrevItemHotkey2): + OnPropertyChanged(nameof(SelectPrevItemHotkey2)); + break; + case nameof(Settings.SelectNextPageHotkey): + OnPropertyChanged(nameof(SelectNextPageHotkey)); + break; + case nameof(Settings.SelectPrevPageHotkey): + OnPropertyChanged(nameof(SelectPrevPageHotkey)); + break; + case nameof(Settings.OpenContextMenuHotkey): + OnPropertyChanged(nameof(OpenContextMenuHotkey)); + break; + case nameof(Settings.SettingWindowHotkey): + OnPropertyChanged(nameof(SettingWindowHotkey)); + break; } }; @@ -81,24 +144,37 @@ namespace Flow.Launcher.ViewModel ContextMenu = new ResultsViewModel(Settings) { - LeftClickResultCommand = OpenResultCommand, RightClickResultCommand = LoadContextMenuCommand + LeftClickResultCommand = OpenResultCommand, + RightClickResultCommand = LoadContextMenuCommand, + IsPreviewOn = Settings.AlwaysPreview }; Results = new ResultsViewModel(Settings) { - LeftClickResultCommand = OpenResultCommand, RightClickResultCommand = LoadContextMenuCommand + LeftClickResultCommand = OpenResultCommand, + RightClickResultCommand = LoadContextMenuCommand, + IsPreviewOn = Settings.AlwaysPreview }; History = new ResultsViewModel(Settings) { - LeftClickResultCommand = OpenResultCommand, RightClickResultCommand = LoadContextMenuCommand + LeftClickResultCommand = OpenResultCommand, + RightClickResultCommand = LoadContextMenuCommand, + IsPreviewOn = Settings.AlwaysPreview }; _selectedResults = Results; + Results.PropertyChanged += (_, args) => + { + switch (args.PropertyName) + { + case nameof(Results.SelectedItem): + UpdatePreview(); + break; + } + }; RegisterViewUpdate(); RegisterResultsUpdatedEvent(); - RegisterClockAndDateUpdateAsync(); - - SetOpenResultModifiers(); + _ = RegisterClockAndDateUpdateAsync(); } private void RegisterViewUpdate() @@ -130,8 +206,6 @@ namespace Flow.Launcher.ViewModel Log.Error("MainViewModel", "Unexpected ResultViewUpdate ends"); } - ; - void continueAction(Task t) { #if DEBUG @@ -158,8 +232,12 @@ namespace Flow.Launcher.ViewModel var token = e.Token == default ? _updateToken : e.Token; - PluginManager.UpdatePluginMetadata(e.Results, pair.Metadata, e.Query); - if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(e.Results, pair.Metadata, e.Query, token))) + // make a clone to avoid possible issue that plugin will also change the list and items when updating view model + var resultsCopy = DeepCloneResults(e.Results, token); + + PluginManager.UpdatePluginMetadata(resultsCopy, pair.Metadata, e.Query); + if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, pair.Metadata, e.Query, + token))) { Log.Error("MainViewModel", "Unable to add item to Result Update Queue"); } @@ -173,8 +251,10 @@ namespace Flow.Launcher.ViewModel Hide(); await PluginManager.ReloadDataAsync().ConfigureAwait(false); - Notification.Show(InternationalizationManager.Instance.GetTranslation("success"), InternationalizationManager.Instance.GetTranslation("completedSuccessfully")); + Notification.Show(InternationalizationManager.Instance.GetTranslation("success"), + InternationalizationManager.Instance.GetTranslation("completedSuccessfully")); } + [RelayCommand] private void LoadHistory() { @@ -188,6 +268,48 @@ namespace Flow.Launcher.ViewModel SelectedResults = Results; } } + + [RelayCommand] + public void ReQuery() + { + if (SelectedIsFromQueryResults()) + { + QueryResults(isReQuery: true); + } + } + + public void ReQuery(bool reselect) + { + BackToQueryResults(); + QueryResults(isReQuery: true, reSelect: reselect); + } + + [RelayCommand] + public void ReverseHistory() + { + if (_history.Items.Count > 0) + { + ChangeQueryText(_history.Items[_history.Items.Count - lastHistoryIndex].Query.ToString()); + if (lastHistoryIndex < _history.Items.Count) + { + lastHistoryIndex++; + } + } + } + + [RelayCommand] + public void ForwardHistory() + { + if (_history.Items.Count > 0) + { + ChangeQueryText(_history.Items[_history.Items.Count - lastHistoryIndex].Query.ToString()); + if (lastHistoryIndex > 1) + { + lastHistoryIndex--; + } + } + } + [RelayCommand] private void LoadContextMenu() { @@ -203,6 +325,7 @@ namespace Flow.Launcher.ViewModel SelectedResults = Results; } } + [RelayCommand] private void Backspace(object index) { @@ -215,6 +338,7 @@ namespace Flow.Launcher.ViewModel ChangeQueryText($"{actionKeyword}{path}"); } + [RelayCommand] private void AutocompleteQuery() { @@ -229,6 +353,13 @@ namespace Flow.Launcher.ViewModel } else if (!string.IsNullOrEmpty(SelectedResults.SelectedItem?.QuerySuggestionText)) { + var defaultSuggestion = SelectedResults.SelectedItem.QuerySuggestionText; + // check if result.actionkeywordassigned is empty + if (!string.IsNullOrEmpty(result.ActionKeywordAssigned)) + { + autoCompleteText = $"{result.ActionKeywordAssigned} {defaultSuggestion}"; + } + autoCompleteText = SelectedResults.SelectedItem.QuerySuggestionText; } @@ -241,6 +372,7 @@ namespace Flow.Launcher.ViewModel ChangeQueryText(autoCompleteText); } } + [RelayCommand] private async Task OpenResultAsync(string index) { @@ -249,32 +381,58 @@ namespace Flow.Launcher.ViewModel { results.SelectedIndex = int.Parse(index); } + var result = results.SelectedItem?.Result; if (result == null) { return; } + var hideWindow = await result.ExecuteAsync(new ActionContext { - SpecialKeyState = GlobalHotkey.CheckModifiers() + // not null means pressing modifier key + number, should ignore the modifier key + SpecialKeyState = index is not null ? SpecialKeyState.Default : GlobalHotkey.CheckModifiers() }) .ConfigureAwait(false); + if (SelectedIsFromQueryResults()) + { + _userSelectedRecord.Add(result); + // origin query is null when user select the context menu item directly of one item from query list + // so we don't want to add it to history + if (result.OriginQuery != null) + { + _history.Add(result.OriginQuery.RawQuery); + } + lastHistoryIndex = 1; + } + if (hideWindow) { Hide(); } - - if (SelectedIsFromQueryResults()) - { - _userSelectedRecord.Add(result); - _history.Add(result.OriginQuery.RawQuery); - } - else - { - SelectedResults = Results; - } } + + private static IReadOnlyList DeepCloneResults(IReadOnlyList results, CancellationToken token = default) + { + var resultsCopy = new List(); + foreach (var result in results.ToList()) + { + if (token.IsCancellationRequested) + { + break; + } + + var resultCopy = result.Clone(); + resultsCopy.Add(resultCopy); + } + return resultsCopy; + } + + #endregion + + #region BasicCommands + [RelayCommand] private void OpenSetting() { @@ -284,7 +442,7 @@ namespace Flow.Launcher.ViewModel [RelayCommand] private void SelectHelp() { - PluginManager.API.OpenUrl("https://www.flowlauncher.com/docs/#/usage-tips"); + App.API.OpenUrl("https://www.flowlauncher.com/docs/#/usage-tips"); } [RelayCommand] @@ -292,6 +450,14 @@ namespace Flow.Launcher.ViewModel { SelectedResults.SelectFirstResult(); } + + [RelayCommand] + private void SelectLastResult() + { + SelectedResults.SelectLastResult(); + } + + [RelayCommand] private void SelectPrevPage() { @@ -303,11 +469,24 @@ namespace Flow.Launcher.ViewModel { SelectedResults.SelectNextPage(); } + [RelayCommand] private void SelectPrevItem() { - SelectedResults.SelectPrevResult(); + if (_history.Items.Count > 0 + && QueryText == string.Empty + && SelectedIsFromQueryResults()) + { + lastHistoryIndex = 1; + ReverseHistory(); + } + else + { + SelectedResults.SelectPrevResult(); + } + } + [RelayCommand] private void SelectNextItem() { @@ -327,6 +506,31 @@ namespace Flow.Launcher.ViewModel } } + public void BackToQueryResults() + { + if (!SelectedIsFromQueryResults()) + { + SelectedResults = Results; + } + } + + [RelayCommand] + public void ToggleGameMode() + { + GameModeStatus = !GameModeStatus; + } + + [RelayCommand] + public void CopyAlternative() + { + var result = Results.SelectedItem?.Result?.CopyText; + + if (result != null) + { + App.API.CopyToClipboard(result, directCopy: false); + } + } + #endregion #region ViewModel Properties @@ -335,7 +539,6 @@ namespace Flow.Launcher.ViewModel public string ClockText { get; private set; } public string DateText { get; private set; } - public CultureInfo cultureInfo => new CultureInfo(Settings.Language); private async Task RegisterClockAndDateUpdateAsync() { var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); @@ -343,44 +546,39 @@ namespace Flow.Launcher.ViewModel while (await timer.WaitForNextTickAsync().ConfigureAwait(false)) { if (Settings.UseClock) - ClockText = DateTime.Now.ToString(Settings.TimeFormat, cultureInfo); + ClockText = DateTime.Now.ToString(Settings.TimeFormat, CultureInfo.CurrentCulture); if (Settings.UseDate) - DateText = DateTime.Now.ToString(Settings.DateFormat, cultureInfo); + DateText = DateTime.Now.ToString(Settings.DateFormat, CultureInfo.CurrentCulture); } } + public ResultsViewModel Results { get; private set; } public ResultsViewModel ContextMenu { get; private set; } public ResultsViewModel History { get; private set; } - public bool GameModeStatus { get; set; } + public bool GameModeStatus { get; set; } = false; private string _queryText; + public string QueryText { get => _queryText; set { _queryText = value; + OnPropertyChanged(); Query(); } } - [RelayCommand] private void IncreaseWidth() { - if (MainWindowWidth + 100 > 1920 || Settings.WindowSize == 1920) - { - Settings.WindowSize = 1920; - } - else - { - Settings.WindowSize += 100; - Settings.WindowLeft -= 50; - } - OnPropertyChanged(); + Settings.WindowSize += 100; + Settings.WindowLeft -= 50; + OnPropertyChanged(nameof(MainWindowWidth)); } [RelayCommand] @@ -395,7 +593,8 @@ namespace Flow.Launcher.ViewModel Settings.WindowLeft += 50; Settings.WindowSize -= 100; } - OnPropertyChanged(); + + OnPropertyChanged(nameof(MainWindowWidth)); } [RelayCommand] @@ -421,22 +620,28 @@ namespace Flow.Launcher.ViewModel /// but we don't want to move cursor to end when query is updated from TextBox /// /// - /// Force query even when Query Text doesn't change - public void ChangeQueryText(string queryText, bool reQuery = false) + /// Force query even when Query Text doesn't change + public void ChangeQueryText(string queryText, bool isReQuery = false) { - if (QueryText != queryText) + Application.Current.Dispatcher.Invoke(() => { - // re-query is done in QueryText's setter method - QueryText = queryText; - // set to false so the subsequent set true triggers - // PropertyChanged and MoveQueryTextToEnd is called - QueryTextCursorMovedToEnd = false; - } - else if (reQuery) - { - Query(); - } - QueryTextCursorMovedToEnd = true; + BackToQueryResults(); + + if (QueryText != queryText) + { + // re-query is done in QueryText's setter method + QueryText = queryText; + // set to false so the subsequent set true triggers + // PropertyChanged and MoveQueryTextToEnd is called + QueryTextCursorMovedToEnd = false; + } + else if (isReQuery) + { + Query(isReQuery: true); + } + + QueryTextCursorMovedToEnd = true; + }); } public bool LastQuerySelected { get; set; } @@ -451,16 +656,31 @@ namespace Flow.Launcher.ViewModel get { return _selectedResults; } set { + var isReturningFromContextMenu = ContextMenuSelected(); _selectedResults = value; if (SelectedIsFromQueryResults()) { - ContextMenu.Visbility = Visibility.Collapsed; - History.Visbility = Visibility.Collapsed; - ChangeQueryText(_queryTextBeforeLeaveResults); + ContextMenu.Visibility = Visibility.Collapsed; + History.Visibility = Visibility.Collapsed; + + // QueryText setter (used in ChangeQueryText) runs the query again, resetting the selected + // result from the one that was selected before going into the context menu to the first result. + // The code below correctly restores QueryText and puts the text caret at the end without + // running the query again when returning from the context menu. + if (isReturningFromContextMenu) + { + _queryText = _queryTextBeforeLeaveResults; + OnPropertyChanged(nameof(QueryText)); + QueryTextCursorMovedToEnd = true; + } + else + { + ChangeQueryText(_queryTextBeforeLeaveResults); + } } else { - Results.Visbility = Visibility.Collapsed; + Results.Visibility = Visibility.Collapsed; _queryTextBeforeLeaveResults = QueryText; @@ -478,7 +698,7 @@ namespace Flow.Launcher.ViewModel } } - _selectedResults.Visbility = Visibility.Visible; + _selectedResults.Visibility = Visibility.Visible; } } @@ -490,27 +710,279 @@ namespace Flow.Launcher.ViewModel // because it is more accurate and reliable representation than using Visibility as a condition check public bool MainWindowVisibilityStatus { get; set; } = true; + public event VisibilityChangedEventHandler VisibilityChanged; + public Visibility SearchIconVisibility { get; set; } public double MainWindowWidth { get => Settings.WindowSize; - set => Settings.WindowSize = value; + set + { + if (!MainWindowVisibilityStatus) return; + Settings.WindowSize = value; + } } + public double MainWindowHeight + { + get => Settings.WindowHeightSize; + set => Settings.WindowHeightSize = value; + } + + public double QueryBoxFontSize + { + get => Settings.QueryBoxFontSize; + set => Settings.QueryBoxFontSize = value; + } + + public double ItemHeightSize + { + get => Settings.ItemHeightSize; + set => Settings.ItemHeightSize = value; + } + + public double ResultItemFontSize + { + get => Settings.ResultItemFontSize; + set => Settings.ResultItemFontSize = value; + } + + public double ResultSubItemFontSize + { + get => Settings.ResultSubItemFontSize; + set => Settings.ResultSubItemFontSize = value; + } + + public ImageSource PluginIconSource { get; private set; } = null; + public string PluginIconPath { get; set; } = null; - public string OpenResultCommandModifiers { get; private set; } + public string OpenResultCommandModifiers => Settings.OpenResultModifiers; + + public string VerifyOrSetDefaultHotkey(string hotkey, string defaultHotkey) + { + try + { + var converter = new KeyGestureConverter(); + var key = (KeyGesture)converter.ConvertFromString(hotkey); + } + catch (Exception e) when (e is NotSupportedException || e is InvalidEnumArgumentException) + { + return defaultHotkey; + } + + return hotkey; + } + + public string PreviewHotkey => VerifyOrSetDefaultHotkey(Settings.PreviewHotkey, "F1"); + public string AutoCompleteHotkey => VerifyOrSetDefaultHotkey(Settings.AutoCompleteHotkey, "Ctrl+Tab"); + public string AutoCompleteHotkey2 => VerifyOrSetDefaultHotkey(Settings.AutoCompleteHotkey2, ""); + public string SelectNextItemHotkey => VerifyOrSetDefaultHotkey(Settings.SelectNextItemHotkey, "Tab"); + public string SelectNextItemHotkey2 => VerifyOrSetDefaultHotkey(Settings.SelectNextItemHotkey2, ""); + public string SelectPrevItemHotkey => VerifyOrSetDefaultHotkey(Settings.SelectPrevItemHotkey, "Shift+Tab"); + public string SelectPrevItemHotkey2 => VerifyOrSetDefaultHotkey(Settings.SelectPrevItemHotkey2, ""); + public string SelectNextPageHotkey => VerifyOrSetDefaultHotkey(Settings.SelectNextPageHotkey, ""); + public string SelectPrevPageHotkey => VerifyOrSetDefaultHotkey(Settings.SelectPrevPageHotkey, ""); + public string OpenContextMenuHotkey => VerifyOrSetDefaultHotkey(Settings.OpenContextMenuHotkey, "Ctrl+O"); + public string SettingWindowHotkey => VerifyOrSetDefaultHotkey(Settings.SettingWindowHotkey, "Ctrl+I"); + public string CycleHistoryUpHotkey => VerifyOrSetDefaultHotkey(Settings.CycleHistoryUpHotkey, "Alt+Up"); + public string CycleHistoryDownHotkey => VerifyOrSetDefaultHotkey(Settings.CycleHistoryDownHotkey, "Alt+Down"); + public string Image => Constant.QueryTextBoxIconImagePath; + public bool StartWithEnglishMode => Settings.AlwaysStartEn; + #endregion - public void Query() + #region Preview + + public bool InternalPreviewVisible + { + get + { + if (ResultAreaColumn == ResultAreaColumnPreviewShown) + return true; + + if (ResultAreaColumn == ResultAreaColumnPreviewHidden) + return false; +#if DEBUG + throw new NotImplementedException("ResultAreaColumn should match ResultAreaColumnPreviewShown/ResultAreaColumnPreviewHidden value"); +#else + Log.Error("MainViewModel", "ResultAreaColumnPreviewHidden/ResultAreaColumnPreviewShown int value not implemented", "InternalPreviewVisible"); +#endif + return false; + } + } + + private static readonly int ResultAreaColumnPreviewShown = 1; + + private static readonly int ResultAreaColumnPreviewHidden = 3; + + public int ResultAreaColumn { get; set; } = ResultAreaColumnPreviewShown; + + // This is not a reliable indicator of whether external preview is visible due to the + // ability of manually closing/exiting the external preview program which, does not inform flow that + // preview is no longer available. + public bool ExternalPreviewVisible { get; set; } = false; + + private void ShowPreview() + { + var useExternalPreview = PluginManager.UseExternalPreview(); + + switch (useExternalPreview) + { + case true + when CanExternalPreviewSelectedResult(out var path): + // Internal preview may still be on when user switches to external + if (InternalPreviewVisible) + HideInternalPreview(); + OpenExternalPreview(path); + break; + + case true + when !CanExternalPreviewSelectedResult(out var _): + if (ExternalPreviewVisible) + CloseExternalPreview(); + ShowInternalPreview(); + break; + + case false: + ShowInternalPreview(); + break; + } + } + + private void HidePreview() + { + if (PluginManager.UseExternalPreview()) + CloseExternalPreview(); + + if (InternalPreviewVisible) + HideInternalPreview(); + } + + [RelayCommand] + private void TogglePreview() + { + if (InternalPreviewVisible || ExternalPreviewVisible) + { + HidePreview(); + } + else + { + ShowPreview(); + } + } + + private void ToggleInternalPreview() + { + if (!InternalPreviewVisible) + { + ShowInternalPreview(); + } + else + { + HideInternalPreview(); + } + } + + private void OpenExternalPreview(string path, bool sendFailToast = true) + { + _ = PluginManager.OpenExternalPreviewAsync(path, sendFailToast).ConfigureAwait(false); + ExternalPreviewVisible = true; + } + + private void CloseExternalPreview() + { + _ = PluginManager.CloseExternalPreviewAsync().ConfigureAwait(false); + ExternalPreviewVisible = false; + } + + private void SwitchExternalPreview(string path, bool sendFailToast = true) + { + _ = PluginManager.SwitchExternalPreviewAsync(path,sendFailToast).ConfigureAwait(false); + } + + private void ShowInternalPreview() + { + ResultAreaColumn = ResultAreaColumnPreviewShown; + Results.SelectedItem?.LoadPreviewImage(); + } + + private void HideInternalPreview() + { + ResultAreaColumn = ResultAreaColumnPreviewHidden; + } + + public void ResetPreview() + { + switch (Settings.AlwaysPreview) + { + case true + when PluginManager.AllowAlwaysPreview() && CanExternalPreviewSelectedResult(out var path): + OpenExternalPreview(path); + break; + + case true: + ShowInternalPreview(); + break; + + case false: + HidePreview(); + break; + } + } + + private void UpdatePreview() + { + switch (PluginManager.UseExternalPreview()) + { + case true + when CanExternalPreviewSelectedResult(out var path): + if (ExternalPreviewVisible) + { + SwitchExternalPreview(path, false); + } + else if (InternalPreviewVisible) + { + HideInternalPreview(); + OpenExternalPreview(path); + } + break; + + case true + when !CanExternalPreviewSelectedResult(out var _): + if (ExternalPreviewVisible) + { + CloseExternalPreview(); + ShowInternalPreview(); + } + break; + + case false + when InternalPreviewVisible: + Results.SelectedItem?.LoadPreviewImage(); + break; + } + } + + private bool CanExternalPreviewSelectedResult(out string path) + { + path = Results.SelectedItem?.Result?.Preview.FilePath; + return !string.IsNullOrEmpty(path); + } + + #endregion + + #region Query + + public void Query(bool isReQuery = false) { if (SelectedIsFromQueryResults()) { - QueryResults(); + QueryResults(isReQuery); } else if (ContextMenuSelected()) { @@ -532,13 +1004,15 @@ namespace Flow.Launcher.ViewModel if (selected != null) // SelectedItem returns null if selection is empty. { - var results = PluginManager.GetContextMenusForPlugin(selected); + List results; + + results = PluginManager.GetContextMenusForPlugin(selected); results.Add(ContextMenuTopMost(selected)); results.Add(ContextMenuPluginInfo(selected.PluginID)); if (!string.IsNullOrEmpty(query)) { - var filtered = results.Where + var filtered = results.Select(x => x.Clone()).Where ( r => { @@ -552,7 +1026,6 @@ namespace Flow.Launcher.ViewModel r.Score = match.Score; return true; - }).ToList(); ContextMenu.AddResults(filtered, id); } @@ -579,10 +1052,7 @@ namespace Flow.Launcher.ViewModel Title = string.Format(title, h.Query), SubTitle = string.Format(time, h.ExecutedDateTime), IcoPath = "Images\\history.png", - OriginQuery = new Query - { - RawQuery = h.Query - }, + OriginQuery = new Query { RawQuery = h.Query }, Action = _ => { SelectedResults = Results; @@ -610,7 +1080,7 @@ namespace Flow.Launcher.ViewModel private readonly IReadOnlyList _emptyResult = new List(); - private async void QueryResults() + private async void QueryResults(bool isReQuery = false, bool reSelect = true) { _updateSource?.Cancel(); @@ -619,8 +1089,9 @@ namespace Flow.Launcher.ViewModel if (query == null) // shortcut expanded { Results.Clear(); - Results.Visbility = Visibility.Collapsed; + Results.Visibility = Visibility.Collapsed; PluginIconPath = null; + PluginIconSource = null; SearchIconVisibility = Visibility.Visible; return; } @@ -641,6 +1112,8 @@ namespace Flow.Launcher.ViewModel if (currentCancellationToken.IsCancellationRequested) return; + // Update the query's IsReQuery property to true if this is a re-query + query.IsReQuery = isReQuery; // handle the exclusiveness of plugin using action keyword RemoveOldQueryResults(query); @@ -652,11 +1125,13 @@ namespace Flow.Launcher.ViewModel if (plugins.Count == 1) { PluginIconPath = plugins.Single().Metadata.IcoPath; + PluginIconSource = await ImageLoader.LoadAsync(PluginIconPath); SearchIconVisibility = Visibility.Hidden; } else { PluginIconPath = null; + PluginIconSource = null; SearchIconVisibility = Visibility.Visible; } @@ -683,7 +1158,7 @@ namespace Flow.Launcher.ViewModel var tasks = plugins.Select(plugin => plugin.Metadata.Disabled switch { - false => QueryTask(plugin), + false => QueryTask(plugin, reSelect), true => Task.CompletedTask }).ToArray(); @@ -711,26 +1186,38 @@ namespace Flow.Launcher.ViewModel } // Local function - async Task QueryTask(PluginPair plugin) + async Task QueryTask(PluginPair plugin, bool reSelect = true) { // Since it is wrapped within a ThreadPool Thread, the synchronous context is null // Task.Yield will force it to run in ThreadPool await Task.Yield(); - IReadOnlyList results = await PluginManager.QueryForPluginAsync(plugin, query, currentCancellationToken); + IReadOnlyList results = + await PluginManager.QueryForPluginAsync(plugin, query, currentCancellationToken); currentCancellationToken.ThrowIfCancellationRequested(); - results ??= _emptyResult; + IReadOnlyList resultsCopy; + if (results == null) + { + resultsCopy = _emptyResult; + } + else + { + // make a copy of results to avoid possible issue that FL changes some properties of the records, like score, etc. + resultsCopy = DeepCloneResults(results); + } - if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(results, plugin.Metadata, query, currentCancellationToken))) + if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, plugin.Metadata, query, + currentCancellationToken, reSelect))) { Log.Error("MainViewModel", "Unable to add item to Result Update Queue"); } } } - private Query ConstructQuery(string queryText, IEnumerable customShortcuts, IEnumerable builtInShortcuts) + private Query ConstructQuery(string queryText, IEnumerable customShortcuts, + IEnumerable builtInShortcuts) { if (string.IsNullOrWhiteSpace(queryText)) { @@ -740,7 +1227,8 @@ namespace Flow.Launcher.ViewModel StringBuilder queryBuilder = new(queryText); StringBuilder queryBuilderTmp = new(queryText); - foreach (var shortcut in customShortcuts) + // Sorting order is important here, the reason is for matching longest shortcut by default + foreach (var shortcut in customShortcuts.OrderByDescending(x => x.Key.Length)) { if (queryBuilder.Equals(shortcut.Key)) { @@ -750,11 +1238,29 @@ namespace Flow.Launcher.ViewModel queryBuilder.Replace('@' + shortcut.Key, shortcut.Expand()); } - foreach (var shortcut in builtInShortcuts) + string customExpanded = queryBuilder.ToString(); + + Application.Current.Dispatcher.Invoke(() => { - queryBuilder.Replace(shortcut.Key, shortcut.Expand()); - queryBuilderTmp.Replace(shortcut.Key, shortcut.Expand()); - } + foreach (var shortcut in builtInShortcuts) + { + try + { + if (customExpanded.Contains(shortcut.Key)) + { + var expansion = shortcut.Expand(); + queryBuilder.Replace(shortcut.Key, expansion); + queryBuilderTmp.Replace(shortcut.Key, expansion); + } + } + catch (Exception e) + { + Log.Exception( + $"{nameof(MainViewModel)}.{nameof(ConstructQuery)}|Error when expanding shortcut {shortcut.Key}", + e); + } + } + }); // show expanded builtin shortcuts // use private field to avoid infinite recursion @@ -786,6 +1292,7 @@ namespace Flow.Launcher.ViewModel { _topMostRecord.Remove(result); App.API.ShowMsg(InternationalizationManager.Instance.GetTranslation("success")); + App.API.ReQuery(); return false; } }; @@ -801,7 +1308,8 @@ namespace Flow.Launcher.ViewModel Action = _ => { _topMostRecord.AddOrUpdate(result); - App.API.ShowMsg("Success"); + App.API.ShowMsg(InternationalizationManager.Instance.GetTranslation("success")); + App.API.ReQuery(); return false; } }; @@ -856,12 +1364,9 @@ namespace Flow.Launcher.ViewModel return selected; } - #region Hotkey + #endregion - private void SetOpenResultModifiers() - { - OpenResultCommandModifiers = Settings.OpenResultModifiers ?? DefaultOpenResultModifiers; - } + #region Hotkey public void ToggleFlowLauncher() { @@ -877,18 +1382,31 @@ namespace Flow.Launcher.ViewModel public void Show() { - MainWindowVisibility = Visibility.Visible; + Application.Current.Dispatcher.Invoke(() => + { + MainWindowVisibility = Visibility.Visible; - MainWindowVisibilityStatus = true; + MainWindowOpacity = 1; - MainWindowOpacity = 1; + MainWindowVisibilityStatus = true; + VisibilityChanged?.Invoke(this, new VisibilityChangedEventArgs { IsVisible = true }); + }); } public async void Hide() { + lastHistoryIndex = 1; // Trick for no delay MainWindowOpacity = 0; + if (ExternalPreviewVisible) + CloseExternalPreview(); + + if (!SelectedIsFromQueryResults()) + { + SelectedResults = Results; + } + switch (Settings.LastQueryMode) { case LastQueryMode.Empty: @@ -905,26 +1423,34 @@ namespace Flow.Launcher.ViewModel await Task.Delay(100); LastQuerySelected = false; break; + case LastQueryMode.ActionKeywordPreserved or LastQueryMode.ActionKeywordSelected: + var newQuery = _lastQuery.ActionKeyword; + if (!string.IsNullOrEmpty(newQuery)) + newQuery += " "; + ChangeQueryText(newQuery); + if (Settings.UseAnimation) + await Task.Delay(100); + if (Settings.LastQueryMode == LastQueryMode.ActionKeywordSelected) + LastQuerySelected = false; + break; default: throw new ArgumentException($"wrong LastQueryMode: <{Settings.LastQueryMode}>"); } MainWindowVisibilityStatus = false; MainWindowVisibility = Visibility.Collapsed; + VisibilityChanged?.Invoke(this, new VisibilityChangedEventArgs { IsVisible = false }); } - #endregion - - /// /// Checks if Flow Launcher should ignore any hotkeys /// public bool ShouldIgnoreHotkeys() { - return Settings.IgnoreHotkeysOnFullscreen && WindowsInteropHelper.IsWindowFullscreen(); + return Settings.IgnoreHotkeysOnFullscreen && WindowsInteropHelper.IsWindowFullscreen() || GameModeStatus; } - + #endregion #region Public Methods @@ -938,7 +1464,7 @@ namespace Flow.Launcher.ViewModel /// /// To avoid deadlock, this method should not called from main thread /// - public void UpdateResultView(IEnumerable resultsForUpdates) + public void UpdateResultView(ICollection resultsForUpdates) { if (!resultsForUpdates.Any()) return; @@ -967,61 +1493,41 @@ namespace Flow.Launcher.ViewModel { if (_topMostRecord.IsTopMost(result)) { - result.Score = int.MaxValue; + result.Score = Result.MaxScore; } else { var priorityScore = metaResults.Metadata.Priority * 150; - result.Score += _userSelectedRecord.GetSelectedCount(result) + priorityScore; - } - } - } - - Results.AddResults(resultsForUpdates, token); - } - - /// - /// This is the global copy method for an individual result. If no text is passed, - /// the method will work out what is to be copied based on the result, so plugin can offer the text - /// to be copied via the result model. If the text is a directory/file path, - /// then actual file/folder will be copied instead. - /// The result's subtitle text is the default text to be copied - /// - public void ResultCopy(string stringToCopy) - { - if (string.IsNullOrEmpty(stringToCopy)) - { - var result = Results.SelectedItem?.Result; - if (result != null) - { - string copyText = result.CopyText; - var isFile = File.Exists(copyText); - var isFolder = Directory.Exists(copyText); - if (isFile || isFolder) - { - var paths = new StringCollection + if (result.AddSelectedCount) { - copyText - }; - - Clipboard.SetFileDropList(paths); - App.API.ShowMsg( - $"{App.API.GetTranslation("copy")} {(isFile ? App.API.GetTranslation("fileTitle") : App.API.GetTranslation("folderTitle"))}", - App.API.GetTranslation("completedSuccessfully")); - } - else - { - Clipboard.SetDataObject(copyText); - App.API.ShowMsg( - $"{App.API.GetTranslation("copy")} {App.API.GetTranslation("textTitle")}", - App.API.GetTranslation("completedSuccessfully")); + if ((long)result.Score + _userSelectedRecord.GetSelectedCount(result) + priorityScore > Result.MaxScore) + { + result.Score = Result.MaxScore; + } + else + { + result.Score += _userSelectedRecord.GetSelectedCount(result) + priorityScore; + } + } + else + { + if ((long)result.Score + priorityScore > Result.MaxScore) + { + result.Score = Result.MaxScore; + } + else + { + result.Score += priorityScore; + } + } } } - - return; } - Clipboard.SetDataObject(stringToCopy); + // it should be the same for all results + bool reSelect = resultsForUpdates.First().ReSelectFirstResult; + + Results.AddResults(resultsForUpdates, token, reSelect); } #endregion diff --git a/Flow.Launcher/ViewModel/PluginStoreItemViewModel.cs b/Flow.Launcher/ViewModel/PluginStoreItemViewModel.cs index 622e41b1b..38b5bec65 100644 --- a/Flow.Launcher/ViewModel/PluginStoreItemViewModel.cs +++ b/Flow.Launcher/ViewModel/PluginStoreItemViewModel.cs @@ -1,12 +1,17 @@ using System; +using System.Linq; +using CommunityToolkit.Mvvm.Input; using Flow.Launcher.Core.ExternalPlugins; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Plugin; +using SemanticVersioning; +using Version = SemanticVersioning.Version; namespace Flow.Launcher.ViewModel { - public class PluginStoreItemViewModel : BaseModel + public partial class PluginStoreItemViewModel : BaseModel { + private PluginPair PluginManagerData => PluginManager.GetPluginForId("9f8f9b14-2518-4907-b211-35ab6290dee7"); public PluginStoreItemViewModel(UserPlugin plugin) { _plugin = plugin; @@ -26,7 +31,7 @@ namespace Flow.Launcher.ViewModel public string IcoPath => _plugin.IcoPath; public bool LabelInstalled => PluginManager.GetPluginForId(_plugin.ID) != null; - public bool LabelUpdate => LabelInstalled && _plugin.Version != PluginManager.GetPluginForId(_plugin.ID).Metadata.Version; + public bool LabelUpdate => LabelInstalled && new Version(_plugin.Version) > new Version(PluginManager.GetPluginForId(_plugin.ID).Metadata.Version); internal const string None = "None"; internal const string RecentlyUpdated = "RecentlyUpdated"; @@ -54,5 +59,13 @@ namespace Flow.Launcher.ViewModel return category; } } + + [RelayCommand] + private void ShowCommandQuery(string action) + { + var actionKeyword = PluginManagerData.Metadata.ActionKeywords.Any() ? PluginManagerData.Metadata.ActionKeywords[0] + " " : String.Empty; + App.API.ChangeQuery($"{actionKeyword}{action} {_plugin.Name}"); + App.API.ShowMainWindow(); + } } } diff --git a/Flow.Launcher/ViewModel/PluginViewModel.cs b/Flow.Launcher/ViewModel/PluginViewModel.cs index 2294681b4..209a81395 100644 --- a/Flow.Launcher/ViewModel/PluginViewModel.cs +++ b/Flow.Launcher/ViewModel/PluginViewModel.cs @@ -1,13 +1,17 @@ -using System.Windows; +using System.Linq; +using System.Windows; using System.Windows.Media; using Flow.Launcher.Plugin; using Flow.Launcher.Infrastructure.Image; using Flow.Launcher.Core.Plugin; using System.Windows.Controls; +using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Core.Resource; +using Flow.Launcher.Resources.Controls; namespace Flow.Launcher.ViewModel { - public class PluginViewModel : BaseModel + public partial class PluginViewModel : BaseModel { private readonly PluginPair _pluginPair; public PluginPair PluginPair @@ -24,11 +28,46 @@ namespace Flow.Launcher.ViewModel } } - public ImageSource Image => ImageLoader.Load(PluginPair.Metadata.IcoPath); + private string PluginManagerActionKeyword + { + get + { + var keyword = PluginManager + .GetPluginForId("9f8f9b14-2518-4907-b211-35ab6290dee7") + .Metadata.ActionKeywords.FirstOrDefault(); + return keyword switch + { + null or "*" => string.Empty, + _ => keyword + }; + } + } + + + private async void LoadIconAsync() + { + Image = await ImageLoader.LoadAsync(PluginPair.Metadata.IcoPath); + } + + public ImageSource Image + { + get + { + if (_image == ImageLoader.MissingImage) + LoadIconAsync(); + + return _image; + } + set => _image = value; + } public bool PluginState { get => !PluginPair.Metadata.Disabled; - set => PluginPair.Metadata.Disabled = !value; + set + { + PluginPair.Metadata.Disabled = !value; + PluginSettingsObject.Disabled = !value; + } } public bool IsExpanded { @@ -36,6 +75,7 @@ namespace Flow.Launcher.ViewModel set { _isExpanded = value; + OnPropertyChanged(); OnPropertyChanged(nameof(SettingControl)); } @@ -43,19 +83,31 @@ namespace Flow.Launcher.ViewModel private Control _settingControl; private bool _isExpanded; - public Control SettingControl + + private Control _bottomPart1; + public Control BottomPart1 => IsExpanded ? _bottomPart1 ??= new InstalledPluginDisplayKeyword() : null; + + private Control _bottomPart2; + public Control BottomPart2 => IsExpanded ? _bottomPart2 ??= new InstalledPluginDisplayBottomData() : null; + + public bool HasSettingControl => PluginPair.Plugin is ISettingProvider && (PluginPair.Plugin is not JsonRPCPluginBase jsonRPCPluginBase || jsonRPCPluginBase.NeedCreateSettingPanel()); + public Control SettingControl => IsExpanded ? _settingControl - ??= PluginPair.Plugin is not ISettingProvider settingProvider - ? new Control() - : settingProvider.CreateSettingPanel() + ??= HasSettingControl + ? ((ISettingProvider)PluginPair.Plugin).CreateSettingPanel() + : null : null; + private ImageSource _image = ImageLoader.MissingImage; public Visibility ActionKeywordsVisibility => PluginPair.Metadata.ActionKeywords.Count == 1 ? Visibility.Visible : Visibility.Collapsed; public string InitilizaTime => PluginPair.Metadata.InitTime + "ms"; public string QueryTime => PluginPair.Metadata.AvgQueryTime + "ms"; + public string Version => InternationalizationManager.Instance.GetTranslation("plugin_query_version") + " " + PluginPair.Metadata.Version; + public string InitAndQueryTime => InternationalizationManager.Instance.GetTranslation("plugin_init_time") + " " + PluginPair.Metadata.InitTime + "ms, " + InternationalizationManager.Instance.GetTranslation("plugin_query_time") + " " + PluginPair.Metadata.AvgQueryTime + "ms"; public string ActionKeywordsText => string.Join(Query.ActionKeywordSeparator, PluginPair.Metadata.ActionKeywords); public int Priority => PluginPair.Metadata.Priority; + public Infrastructure.UserSettings.Plugin PluginSettingsObject { get; set; } public void ChangeActionKeyword(string newActionKeyword, string oldActionKeyword) { @@ -66,10 +118,45 @@ namespace Flow.Launcher.ViewModel public void ChangePriority(int newPriority) { PluginPair.Metadata.Priority = newPriority; + PluginSettingsObject.Priority = newPriority; OnPropertyChanged(nameof(Priority)); } - public static bool IsActionKeywordRegistered(string newActionKeyword) => PluginManager.ActionKeywordRegistered(newActionKeyword); - } + [RelayCommand] + private void EditPluginPriority() + { + PriorityChangeWindow priorityChangeWindow = new PriorityChangeWindow(PluginPair. Metadata.ID, this); + priorityChangeWindow.ShowDialog(); + } + [RelayCommand] + private void OpenPluginDirectory() + { + var directory = PluginPair.Metadata.PluginDirectory; + if (!string.IsNullOrEmpty(directory)) + App.API.OpenDirectory(directory); + } + + [RelayCommand] + private void OpenSourceCodeLink() + { + App.API.OpenUrl(PluginPair.Metadata.Website); + } + + [RelayCommand] + private void OpenDeletePluginWindow() + { + App.API.ChangeQuery($"{PluginManagerActionKeyword} uninstall {PluginPair.Metadata.Name}".Trim(), true); + App.API.ShowMainWindow(); + } + + public static bool IsActionKeywordRegistered(string newActionKeyword) => PluginManager.ActionKeywordRegistered(newActionKeyword); + + [RelayCommand] + private void SetActionKeywords() + { + ActionKeywords changeKeywordsWindow = new ActionKeywords(this); + changeKeywordsWindow.ShowDialog(); + } + } } diff --git a/Flow.Launcher/ViewModel/ResultViewModel.cs b/Flow.Launcher/ViewModel/ResultViewModel.cs index 458aa498f..5130e7eba 100644 --- a/Flow.Launcher/ViewModel/ResultViewModel.cs +++ b/Flow.Launcher/ViewModel/ResultViewModel.cs @@ -15,58 +15,64 @@ namespace Flow.Launcher.ViewModel public class ResultViewModel : BaseModel { private static PrivateFontCollection fontCollection = new(); - private static Dictionary fonts = new(); + private static Dictionary fonts = new(); public ResultViewModel(Result result, Settings settings) { - if (result != null) + Settings = settings; + + if (result == null) { - Result = result; + return; + } + Result = result; - if (Result.Glyph is { FontFamily: not null } glyph) + if (Result.Glyph is { FontFamily: not null } glyph) + { + // Checks if it's a system installed font, which does not require path to be provided. + if (glyph.FontFamily.EndsWith(".ttf") || glyph.FontFamily.EndsWith(".otf")) { - // Checks if it's a system installed font, which does not require path to be provided. - if (glyph.FontFamily.EndsWith(".ttf") || glyph.FontFamily.EndsWith(".otf")) + string fontFamilyPath = glyph.FontFamily; + + if (!Path.IsPathRooted(fontFamilyPath)) { - string fontFamilyPath = glyph.FontFamily; + fontFamilyPath = Path.Combine(Result.PluginDirectory, fontFamilyPath); + } - if (!Path.IsPathRooted(fontFamilyPath)) + if (fonts.ContainsKey(fontFamilyPath)) + { + Glyph = glyph with { - fontFamilyPath = Path.Combine(Result.PluginDirectory, fontFamilyPath); - } - - if (fonts.ContainsKey(fontFamilyPath)) - { - Glyph = glyph with - { - FontFamily = fonts[fontFamilyPath] - }; - } - else - { - fontCollection.AddFontFile(fontFamilyPath); - fonts[fontFamilyPath] = $"{Path.GetDirectoryName(fontFamilyPath)}/#{fontCollection.Families[^1].Name}"; - Glyph = glyph with - { - FontFamily = fonts[fontFamilyPath] - }; - } + FontFamily = fonts[fontFamilyPath] + }; } else { - Glyph = glyph; + fontCollection.AddFontFile(fontFamilyPath); + fonts[fontFamilyPath] = $"{Path.GetDirectoryName(fontFamilyPath)}/#{fontCollection.Families[^1].Name}"; + Glyph = glyph with + { + FontFamily = fonts[fontFamilyPath] + }; } } + else + { + Glyph = glyph; + } } - Settings = settings; } - private Settings Settings { get; } + public Settings Settings { get; } public Visibility ShowOpenResultHotkey => Settings.ShowOpenResultHotkey ? Visibility.Visible : Visibility.Collapsed; + public Visibility ShowDefaultPreview => Result.PreviewPanel == null ? Visibility.Visible : Visibility.Collapsed; + + public Visibility ShowCustomizedPreview => Result.PreviewPanel == null ? Visibility.Collapsed : Visibility.Visible; + public Visibility ShowIcon { get @@ -84,6 +90,22 @@ namespace Flow.Launcher.ViewModel } } + public Visibility ShowPreviewImage + { + get + { + if (PreviewImageAvailable) + { + return Visibility.Visible; + } + else + { + // Fall back to icon + return ShowIcon; + } + } + } + public double IconRadius { get @@ -106,7 +128,7 @@ namespace Flow.Launcher.ViewModel if (!Settings.UseGlyphIcons && !ImgIconAvailable && GlyphAvailable) return Visibility.Visible; - return Settings.UseGlyphIcons && GlyphAvailable ? Visibility.Visible : Visibility.Hidden; + return Settings.UseGlyphIcons && GlyphAvailable ? Visibility.Visible : Visibility.Collapsed; } } @@ -114,6 +136,8 @@ namespace Flow.Launcher.ViewModel private bool ImgIconAvailable => !string.IsNullOrEmpty(Result.IcoPath) || Result.Icon is not null; + private bool PreviewImageAvailable => !string.IsNullOrEmpty(Result.Preview.PreviewImagePath) || Result.Preview.PreviewDelegate != null; + public string OpenResultModifiers => Settings.OpenResultModifiers; public string ShowTitleToolTip => string.IsNullOrEmpty(Result.TitleToolTip) @@ -125,8 +149,10 @@ namespace Flow.Launcher.ViewModel : Result.SubTitleToolTip; private volatile bool ImageLoaded; + private volatile bool PreviewImageLoaded; - private ImageSource image = ImageLoader.DefaultImage; + private ImageSource image = ImageLoader.LoadingImage; + private ImageSource previewImage = ImageLoader.LoadingImage; public ImageSource Image { @@ -143,37 +169,79 @@ namespace Flow.Launcher.ViewModel private set => image = value; } + public ImageSource PreviewImage + { + get => previewImage; + private set => previewImage = value; + } + + /// + /// Determines if to use the full width of the preview panel + /// + public bool UseBigThumbnail => Result.Preview.IsMedia; + public GlyphInfo Glyph { get; set; } - private async ValueTask LoadImageAsync() + private async Task LoadImageInternalAsync(string imagePath, Result.IconDelegate icon, bool loadFullImage) { - var imagePath = Result.IcoPath; - if (string.IsNullOrEmpty(imagePath) && Result.Icon != null) + if (string.IsNullOrEmpty(imagePath) && icon != null) { try { - image = Result.Icon(); - return; + var image = icon(); + return image; } catch (Exception e) { Log.Exception( - $"|ResultViewModel.Image|IcoPath is empty and exception when calling Icon() for result <{Result.Title}> of plugin <{Result.PluginDirectory}>", + $"|ResultViewModel.LoadImageInternalAsync|IcoPath is empty and exception when calling IconDelegate for result <{Result.Title}> of plugin <{Result.PluginDirectory}>", e); } } - var loadFullImage = (Path.GetExtension(imagePath) ?? "").Equals(".url", StringComparison.OrdinalIgnoreCase); + return await ImageLoader.LoadAsync(imagePath, loadFullImage).ConfigureAwait(false); + } - if (ImageLoader.CacheContainImage(imagePath)) + private async Task LoadImageAsync() + { + var imagePath = Result.IcoPath; + var iconDelegate = Result.Icon; + if (ImageLoader.TryGetValue(imagePath, false, out ImageSource img)) { - // will get here either when icoPath has value\icon delegate is null\when had exception in delegate - image = ImageLoader.Load(imagePath, loadFullImage); - return; + image = img; } + else + { + // We need to modify the property not field here to trigger the OnPropertyChanged event + Image = await LoadImageInternalAsync(imagePath, iconDelegate, false).ConfigureAwait(false); + } + } - // We need to modify the property not field here to trigger the OnPropertyChanged event - Image = await Task.Run(() => ImageLoader.Load(imagePath, loadFullImage)).ConfigureAwait(false); + private async Task LoadPreviewImageAsync() + { + var imagePath = Result.Preview.PreviewImagePath ?? Result.IcoPath; + var iconDelegate = Result.Preview.PreviewDelegate ?? Result.Icon; + if (ImageLoader.TryGetValue(imagePath, true, out ImageSource img)) + { + previewImage = img; + } + else + { + // We need to modify the property not field here to trigger the OnPropertyChanged event + PreviewImage = await LoadImageInternalAsync(imagePath, iconDelegate, true).ConfigureAwait(false); + } + } + + public void LoadPreviewImage() + { + if (ShowDefaultPreview == Visibility.Visible) + { + if (!PreviewImageLoaded && ShowPreviewImage == Visibility.Visible) + { + PreviewImageLoaded = true; + _ = LoadPreviewImageAsync(); + } + } } public Result Result { get; } diff --git a/Flow.Launcher/ViewModel/ResultsForUpdate.cs b/Flow.Launcher/ViewModel/ResultsForUpdate.cs index 94c6a923a..bc0be0de8 100644 --- a/Flow.Launcher/ViewModel/ResultsForUpdate.cs +++ b/Flow.Launcher/ViewModel/ResultsForUpdate.cs @@ -1,28 +1,16 @@ using Flow.Launcher.Plugin; -using System; using System.Collections.Generic; -using System.Text; using System.Threading; namespace Flow.Launcher.ViewModel { - public struct ResultsForUpdate + public record struct ResultsForUpdate( + IReadOnlyList Results, + PluginMetadata Metadata, + Query Query, + CancellationToken Token, + bool ReSelectFirstResult = true) { - public IReadOnlyList Results { get; } - - public PluginMetadata Metadata { get; } - public string ID { get; } - - public Query Query { get; } - public CancellationToken Token { get; } - - public ResultsForUpdate(IReadOnlyList results, PluginMetadata metadata, Query query, CancellationToken token) - { - Results = results; - Metadata = metadata; - Query = query; - Token = token; - ID = metadata.ID; - } + public string ID { get; } = Metadata.ID; } } diff --git a/Flow.Launcher/ViewModel/ResultsViewModel.cs b/Flow.Launcher/ViewModel/ResultsViewModel.cs index 1820ea56b..7d2b5bc93 100644 --- a/Flow.Launcher/ViewModel/ResultsViewModel.cs +++ b/Flow.Launcher/ViewModel/ResultsViewModel.cs @@ -1,4 +1,5 @@ -using Flow.Launcher.Infrastructure.UserSettings; +using System; +using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using System.Collections.Generic; using System.Collections.Specialized; @@ -9,7 +10,6 @@ using System.Windows.Controls; using System.Windows.Data; using System.Windows.Documents; using System.Windows.Input; -using JetBrains.Annotations; namespace Flow.Launcher.ViewModel { @@ -33,9 +33,15 @@ namespace Flow.Launcher.ViewModel _settings = settings; _settings.PropertyChanged += (s, e) => { - if (e.PropertyName == nameof(_settings.MaxResultsToShow)) + switch (e.PropertyName) { - OnPropertyChanged(nameof(MaxHeight)); + case nameof(_settings.MaxResultsToShow): + OnPropertyChanged(nameof(MaxHeight)); + break; + case nameof(_settings.ItemHeightSize): + OnPropertyChanged(nameof(ItemHeightSize)); + OnPropertyChanged(nameof(MaxHeight)); + break; } }; } @@ -44,14 +50,37 @@ namespace Flow.Launcher.ViewModel #region Properties - public int MaxHeight => MaxResults * 52; + public bool IsPreviewOn { get; set; } + + public double MaxHeight + { + get + { + var newResultsCount = MaxResults; + if (IsPreviewOn) + { + newResultsCount = (int)Math.Ceiling(380 / _settings.ItemHeightSize); + if (newResultsCount < MaxResults) + { + newResultsCount = MaxResults; + } + } + return newResultsCount * _settings.ItemHeightSize; + } + } + + public double ItemHeightSize + { + get => _settings.ItemHeightSize; + set => _settings.ItemHeightSize = value; + } public int SelectedIndex { get; set; } public ResultViewModel SelectedItem { get; set; } public Thickness Margin { get; set; } - public Visibility Visbility { get; set; } = Visibility.Collapsed; - + public Visibility Visibility { get; set; } = Visibility.Collapsed; + public ICommand RightClickResultCommand { get; init; } public ICommand LeftClickResultCommand { get; init; } @@ -118,6 +147,11 @@ namespace Flow.Launcher.ViewModel SelectedIndex = NewIndex(0); } + public void SelectLastResult() + { + SelectedIndex = NewIndex(Results.Count - 1); + } + public void Clear() { lock (_collectionLock) @@ -148,36 +182,34 @@ namespace Flow.Launcher.ViewModel /// /// To avoid deadlock, this method should not called from main thread /// - public void AddResults(IEnumerable resultsForUpdates, CancellationToken token) + public void AddResults(ICollection resultsForUpdates, CancellationToken token, bool reselect = true) { var newResults = NewResults(resultsForUpdates); if (token.IsCancellationRequested) return; - UpdateResults(newResults, token); + UpdateResults(newResults, token, reselect); } - private void UpdateResults(List newResults, CancellationToken token = default) + private void UpdateResults(List newResults, CancellationToken token = default, bool reselect = true) { lock (_collectionLock) { // update UI in one run, so it can avoid UI flickering Results.Update(newResults, token); - if (Results.Any()) + if (reselect && Results.Any()) SelectedItem = Results[0]; } - switch (Visbility) + switch (Visibility) { case Visibility.Collapsed when Results.Count > 0: - Margin = new Thickness { Top = 0 }; SelectedIndex = 0; - Visbility = Visibility.Visible; + Visibility = Visibility.Visible; break; case Visibility.Visible when Results.Count == 0: - Margin = new Thickness { Top = 0 }; - Visbility = Visibility.Collapsed; + Visibility = Visibility.Collapsed; break; } } @@ -196,12 +228,12 @@ namespace Flow.Launcher.ViewModel .ToList(); } - private List NewResults(IEnumerable resultsForUpdates) + private List NewResults(ICollection resultsForUpdates) { if (!resultsForUpdates.Any()) return Results; - return Results.Where(r => r != null && !resultsForUpdates.Any(u => u.ID == r.Result.PluginID)) + return Results.Where(r => r?.Result != null && resultsForUpdates.All(u => u.ID != r.Result.PluginID)) .Concat(resultsForUpdates.SelectMany(u => u.Results, (u, r) => new ResultViewModel(r, _settings))) .OrderByDescending(rv => rv.Result.Score) .ToList(); @@ -262,7 +294,7 @@ namespace Flow.Launcher.ViewModel return; // manually update event - // wpf use directx / double buffered already, so just reset all won't cause ui flickering + // wpf use DirectX / double buffered already, so just reset all won't cause ui flickering OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } private void AddAll(List Items) diff --git a/Flow.Launcher/ViewModel/SettingWindowViewModel.cs b/Flow.Launcher/ViewModel/SettingWindowViewModel.cs index 60dc95e2e..51afe533d 100644 --- a/Flow.Launcher/ViewModel/SettingWindowViewModel.cs +++ b/Flow.Launcher/ViewModel/SettingWindowViewModel.cs @@ -1,866 +1,46 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using Flow.Launcher.Core; -using Flow.Launcher.Core.Configuration; -using Flow.Launcher.Core.ExternalPlugins; -using Flow.Launcher.Core.Plugin; -using Flow.Launcher.Core.Resource; -using Flow.Launcher.Helper; -using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Storage; -using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; -using Flow.Launcher.Plugin.SharedModels; -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.Input; -using System.Globalization; -namespace Flow.Launcher.ViewModel +namespace Flow.Launcher.ViewModel; + +public partial class SettingWindowViewModel : BaseModel { - public partial class SettingWindowViewModel : BaseModel + private readonly Settings _settings; + + public SettingWindowViewModel(Settings settings) { - private readonly Updater _updater; - private readonly IPortable _portable; - private readonly FlowLauncherJsonStorage _storage; - - public SettingWindowViewModel(Updater updater, IPortable portable) - { - _updater = updater; - _portable = portable; - _storage = new FlowLauncherJsonStorage(); - Settings = _storage.Load(); - Settings.PropertyChanged += (s, e) => - { - switch (e.PropertyName) - { - case nameof(Settings.ActivateTimes): - OnPropertyChanged(nameof(ActivatedTimes)); - break; - case nameof(Settings.WindowSize): - OnPropertyChanged(nameof(WindowWidthSize)); - break; - case nameof(Settings.UseDate): - case nameof(Settings.DateFormat): - OnPropertyChanged(nameof(DateText)); - break; - case nameof(Settings.UseClock): - case nameof(Settings.TimeFormat): - OnPropertyChanged(nameof(ClockText)); - break; - case nameof(Settings.Language): - OnPropertyChanged(nameof(ClockText)); - OnPropertyChanged(nameof(DateText)); - break; - } - }; - } - - public Settings Settings { get; set; } - - public async void UpdateApp() - { - await _updater.UpdateAppAsync(App.API, false); - } - - public bool AutoUpdates - { - get => Settings.AutoUpdates; - set - { - Settings.AutoUpdates = value; - - if (value) - { - UpdateApp(); - } - } - } - - public CultureInfo cultureInfo => new CultureInfo(Settings.Language); - - public bool StartFlowLauncherOnSystemStartup - { - get => Settings.StartFlowLauncherOnSystemStartup; - set - { - Settings.StartFlowLauncherOnSystemStartup = value; - - try - { - if (value) - AutoStartup.Enable(); - else - AutoStartup.Disable(); - } - catch (Exception e) - { - Notification.Show(InternationalizationManager.Instance.GetTranslation("setAutoStartFailed"), e.Message); - } - } - } - - // This is only required to set at startup. When portable mode enabled/disabled a restart is always required - private bool _portableMode = DataLocation.PortableDataLocationInUse(); - public bool PortableMode - { - get => _portableMode; - set - { - if (!_portable.CanUpdatePortability()) - return; - - if (DataLocation.PortableDataLocationInUse()) - { - _portable.DisablePortableMode(); - } - else - { - _portable.EnablePortableMode(); - } - } - } - - public void Save() - { - foreach (var vm in PluginViewModels) - { - var id = vm.PluginPair.Metadata.ID; - - Settings.PluginSettings.Plugins[id].Disabled = vm.PluginPair.Metadata.Disabled; - Settings.PluginSettings.Plugins[id].Priority = vm.Priority; - } - - PluginManager.Save(); - _storage.Save(); - } - - #region general - - // todo a better name? - public class LastQueryMode : BaseModel - { - public string Display { get; set; } - public Infrastructure.UserSettings.LastQueryMode Value { get; set; } - } - - private List _lastQueryModes = new List(); - public List LastQueryModes - { - get - { - if (_lastQueryModes.Count == 0) - { - _lastQueryModes = InitLastQueryModes(); - } - return _lastQueryModes; - } - } - - private List InitLastQueryModes() - { - var modes = new List(); - var enums = (Infrastructure.UserSettings.LastQueryMode[])Enum.GetValues(typeof(Infrastructure.UserSettings.LastQueryMode)); - foreach (var e in enums) - { - var key = $"LastQuery{e}"; - var display = _translater.GetTranslation(key); - var m = new LastQueryMode - { - Display = display, Value = e, - }; - modes.Add(m); - } - return modes; - } - - private void UpdateLastQueryModeDisplay() - { - foreach (var item in LastQueryModes) - { - item.Display = _translater.GetTranslation($"LastQuery{item.Value}"); - } - } - - public string Language - { - get - { - return Settings.Language; - } - set - { - InternationalizationManager.Instance.ChangeLanguage(value); - - if (InternationalizationManager.Instance.PromptShouldUsePinyin(value)) - ShouldUsePinyin = true; - - UpdateLastQueryModeDisplay(); - } - } - - public bool ShouldUsePinyin - { - get - { - return Settings.ShouldUsePinyin; - } - set - { - Settings.ShouldUsePinyin = value; - } - } - - public List QuerySearchPrecisionStrings - { - get - { - var precisionStrings = new List(); - - var enumList = Enum.GetValues(typeof(SearchPrecisionScore)).Cast().ToList(); - - enumList.ForEach(x => precisionStrings.Add(x.ToString())); - - return precisionStrings; - } - } - - public List OpenResultModifiersList => new List - { - KeyConstant.Alt, - KeyConstant.Ctrl, - $"{KeyConstant.Ctrl}+{KeyConstant.Alt}" - }; - private Internationalization _translater => InternationalizationManager.Instance; - public List Languages => _translater.LoadAvailableLanguages(); - public IEnumerable MaxResultsRange => Enumerable.Range(2, 16); - - public ObservableCollection CustomShortcuts => Settings.CustomShortcuts; - public ObservableCollection BuiltinShortcuts => Settings.BuiltinShortcuts; - - public string TestProxy() - { - var proxyServer = Settings.Proxy.Server; - var proxyUserName = Settings.Proxy.UserName; - if (string.IsNullOrEmpty(proxyServer)) - { - return InternationalizationManager.Instance.GetTranslation("serverCantBeEmpty"); - } - if (Settings.Proxy.Port <= 0) - { - return InternationalizationManager.Instance.GetTranslation("portCantBeEmpty"); - } - - HttpWebRequest request = (HttpWebRequest)WebRequest.Create(_updater.GitHubRepository); - - if (string.IsNullOrEmpty(proxyUserName) || string.IsNullOrEmpty(Settings.Proxy.Password)) - { - request.Proxy = new WebProxy(proxyServer, Settings.Proxy.Port); - } - else - { - request.Proxy = new WebProxy(proxyServer, Settings.Proxy.Port) - { - Credentials = new NetworkCredential(proxyUserName, Settings.Proxy.Password) - }; - } - try - { - var response = (HttpWebResponse)request.GetResponse(); - if (response.StatusCode == HttpStatusCode.OK) - { - return InternationalizationManager.Instance.GetTranslation("proxyIsCorrect"); - } - else - { - return InternationalizationManager.Instance.GetTranslation("proxyConnectFailed"); - } - } - catch - { - return InternationalizationManager.Instance.GetTranslation("proxyConnectFailed"); - } - } - - #endregion - - #region plugin - - public static string Plugin => @"https://github.com/Flow-Launcher/Flow.Launcher.PluginsManifest"; - public PluginViewModel SelectedPlugin { get; set; } - - public IList PluginViewModels - { - get - { - var metadatas = PluginManager.AllPlugins - .OrderBy(x => x.Metadata.Disabled) - .ThenBy(y => y.Metadata.Name) - .Select(p => new PluginViewModel - { - PluginPair = p - }) - .ToList(); - return metadatas; - } - } - - public IList ExternalPlugins - { - get - { - return LabelMaker(PluginsManifest.UserPlugins); - } - } - - private IList LabelMaker(IList list) - { - return list.Select(p => new PluginStoreItemViewModel(p)) - .OrderByDescending(p => p.Category == PluginStoreItemViewModel.NewRelease) - .ThenByDescending(p => p.Category == PluginStoreItemViewModel.RecentlyUpdated) - .ThenByDescending(p => p.Category == PluginStoreItemViewModel.None) - .ThenByDescending(p => p.Category == PluginStoreItemViewModel.Installed) - .ToList(); - } - - public Control SettingProvider - { - get - { - var settingProvider = SelectedPlugin.PluginPair.Plugin as ISettingProvider; - if (settingProvider != null) - { - var control = settingProvider.CreateSettingPanel(); - control.HorizontalAlignment = HorizontalAlignment.Stretch; - control.VerticalAlignment = VerticalAlignment.Stretch; - return control; - } - else - { - return new Control(); - } - } - } - - [RelayCommand] - private async Task RefreshExternalPluginsAsync() - { - await PluginsManifest.UpdateManifestAsync(); - OnPropertyChanged(nameof(ExternalPlugins)); - } - - internal void DisplayPluginQuery(string queryToDisplay, PluginPair plugin, int actionKeywordPosition = 0) - { - var actionKeyword = plugin.Metadata.ActionKeywords.Count == 0 - ? string.Empty - : plugin.Metadata.ActionKeywords[actionKeywordPosition]; - - App.API.ChangeQuery($"{actionKeyword} {queryToDisplay}"); - App.API.ShowMainWindow(); - } - - #endregion - - #region theme - - public static string Theme => @"https://flowlauncher.com/docs/#/how-to-create-a-theme"; - - public string SelectedTheme - { - get { return Settings.Theme; } - set - { - Settings.Theme = value; - ThemeManager.Instance.ChangeTheme(value); - - if (ThemeManager.Instance.BlurEnabled && Settings.UseDropShadowEffect) - DropShadowEffect = false; - } - } - - public List Themes - => ThemeManager.Instance.LoadAvailableThemes().Select(Path.GetFileNameWithoutExtension).ToList(); - - public bool DropShadowEffect - { - get { return Settings.UseDropShadowEffect; } - set - { - if (ThemeManager.Instance.BlurEnabled && value) - { - MessageBox.Show(InternationalizationManager.Instance.GetTranslation("shadowEffectNotAllowed")); - return; - } - - if (value) - { - ThemeManager.Instance.AddDropShadowEffectToCurrentTheme(); - } - else - { - ThemeManager.Instance.RemoveDropShadowEffectFromCurrentTheme(); - } - - Settings.UseDropShadowEffect = value; - } - } - - public class ColorScheme - { - public string Display { get; set; } - public ColorSchemes Value { get; set; } - } - - public List ColorSchemes - { - get - { - List modes = new List(); - var enums = (ColorSchemes[])Enum.GetValues(typeof(ColorSchemes)); - foreach (var e in enums) - { - var key = $"ColorScheme{e}"; - var display = _translater.GetTranslation(key); - var m = new ColorScheme - { - Display = display, Value = e, - }; - modes.Add(m); - } - return modes; - } - } - - public class SearchWindowPosition - { - public string Display { get; set; } - public SearchWindowPositions Value { get; set; } - } - - public List SearchWindowPositions - { - get - { - List modes = new List(); - var enums = (SearchWindowPositions[])Enum.GetValues(typeof(SearchWindowPositions)); - foreach (var e in enums) - { - var key = $"SearchWindowPosition{e}"; - var display = _translater.GetTranslation(key); - var m = new SearchWindowPosition - { - Display = display, Value = e, - }; - modes.Add(m); - } - return modes; - } - } - - public List TimeFormatList { get; } = new() - { - "h:mm", - "hh:mm", - "H:mm", - "HH:mm", - "tt h:mm", - "tt hh:mm", - "h:mm tt", - "hh:mm tt" - }; - - public List DateFormatList { get; } = new() - { - "MM'/'dd dddd", - "MM'/'dd ddd", - "MM'/'dd", - "MM'-'dd", - "MMMM', 'dd", - "dd'/'MM", - "dd'-'MM", - "ddd MM'/'dd", - "dddd MM'/'dd", - "dddd", - "ddd dd'/'MM", - "dddd dd'/'MM", - "dddd dd', 'MMMM", - "dd', 'MMMM" - }; - - public string TimeFormat - { - get { return Settings.TimeFormat; } - set { Settings.TimeFormat = value; } - } - - public string DateFormat - { - get { return Settings.DateFormat; } - set { Settings.DateFormat = value; } - } - - public string ClockText => DateTime.Now.ToString(TimeFormat, cultureInfo); - - public string DateText => DateTime.Now.ToString(DateFormat, cultureInfo); - - - public double WindowWidthSize - { - get => Settings.WindowSize; - set => Settings.WindowSize = value; - } - - public bool UseGlyphIcons - { - get => Settings.UseGlyphIcons; - set => Settings.UseGlyphIcons = value; - } - - public bool UseAnimation - { - get => Settings.UseAnimation; - set => Settings.UseAnimation = value; - } - - public bool UseSound - { - get => Settings.UseSound; - set => Settings.UseSound = value; - } - - public bool UseClock - { - get => Settings.UseClock; - set => Settings.UseClock = value; - } - - public bool UseDate - { - get => Settings.UseDate; - set => Settings.UseDate = value; - } - - public double SettingWindowWidth - { - get => Settings.SettingWindowWidth; - set => Settings.SettingWindowWidth = value; - } - - public double SettingWindowHeight - { - get => Settings.SettingWindowHeight; - set => Settings.SettingWindowHeight = value; - } - - public double SettingWindowTop - { - get => Settings.SettingWindowTop; - set => Settings.SettingWindowTop = value; - } - - public double SettingWindowLeft - { - get => Settings.SettingWindowLeft; - set => Settings.SettingWindowLeft = value; - } - - public Brush PreviewBackground - { - get - { - var wallpaper = WallpaperPathRetrieval.GetWallpaperPath(); - if (wallpaper != null && File.Exists(wallpaper)) - { - var memStream = new MemoryStream(File.ReadAllBytes(wallpaper)); - var bitmap = new BitmapImage(); - bitmap.BeginInit(); - bitmap.StreamSource = memStream; - bitmap.DecodePixelWidth = 800; - bitmap.DecodePixelHeight = 600; - bitmap.EndInit(); - var brush = new ImageBrush(bitmap) - { - Stretch = Stretch.UniformToFill - }; - return brush; - } - else - { - var wallpaperColor = WallpaperPathRetrieval.GetWallpaperColor(); - var brush = new SolidColorBrush(wallpaperColor); - return brush; - } - } - } - - public ResultsViewModel PreviewResults - { - get - { - var results = new List - { - new Result - { - Title = "Explorer", - SubTitle = "Search for files, folders and file contents", - IcoPath = Path.Combine(Constant.ProgramDirectory, @"Plugins\Flow.Launcher.Plugin.Explorer\Images\explorer.png") - }, - new Result - { - Title = "WebSearch", - SubTitle = "Search the web with different search engine support", - IcoPath = Path.Combine(Constant.ProgramDirectory, @"Plugins\Flow.Launcher.Plugin.WebSearch\Images\web_search.png") - }, - new Result - { - Title = "Program", - SubTitle = "Launch programs as admin or a different user", - IcoPath = Path.Combine(Constant.ProgramDirectory, @"Plugins\Flow.Launcher.Plugin.Program\Images\program.png") - }, - new Result - { - Title = "ProcessKiller", - SubTitle = "Terminate unwanted processes", - IcoPath = Path.Combine(Constant.ProgramDirectory, @"Plugins\Flow.Launcher.Plugin.ProcessKiller\Images\app.png") - } - }; - var vm = new ResultsViewModel(Settings); - vm.AddResults(results, "PREVIEW"); - return vm; - } - } - - public FontFamily SelectedQueryBoxFont - { - get - { - if (Fonts.SystemFontFamilies.Count(o => - o.FamilyNames.Values != null && - o.FamilyNames.Values.Contains(Settings.QueryBoxFont)) > 0) - { - var font = new FontFamily(Settings.QueryBoxFont); - return font; - } - else - { - var font = new FontFamily("Segoe UI"); - return font; - } - } - set - { - Settings.QueryBoxFont = value.ToString(); - ThemeManager.Instance.ChangeTheme(Settings.Theme); - } - } - - public FamilyTypeface SelectedQueryBoxFontFaces - { - get - { - var typeface = SyntaxSugars.CallOrRescueDefault( - () => SelectedQueryBoxFont.ConvertFromInvariantStringsOrNormal( - Settings.QueryBoxFontStyle, - Settings.QueryBoxFontWeight, - Settings.QueryBoxFontStretch - )); - return typeface; - } - set - { - Settings.QueryBoxFontStretch = value.Stretch.ToString(); - Settings.QueryBoxFontWeight = value.Weight.ToString(); - Settings.QueryBoxFontStyle = value.Style.ToString(); - ThemeManager.Instance.ChangeTheme(Settings.Theme); - } - } - - public FontFamily SelectedResultFont - { - get - { - if (Fonts.SystemFontFamilies.Count(o => - o.FamilyNames.Values != null && - o.FamilyNames.Values.Contains(Settings.ResultFont)) > 0) - { - var font = new FontFamily(Settings.ResultFont); - return font; - } - else - { - var font = new FontFamily("Segoe UI"); - return font; - } - } - set - { - Settings.ResultFont = value.ToString(); - ThemeManager.Instance.ChangeTheme(Settings.Theme); - } - } - - public FamilyTypeface SelectedResultFontFaces - { - get - { - var typeface = SyntaxSugars.CallOrRescueDefault( - () => SelectedResultFont.ConvertFromInvariantStringsOrNormal( - Settings.ResultFontStyle, - Settings.ResultFontWeight, - Settings.ResultFontStretch - )); - return typeface; - } - set - { - Settings.ResultFontStretch = value.Stretch.ToString(); - Settings.ResultFontWeight = value.Weight.ToString(); - Settings.ResultFontStyle = value.Style.ToString(); - ThemeManager.Instance.ChangeTheme(Settings.Theme); - } - } - - public string ThemeImage => Constant.QueryTextBoxIconImagePath; - - #endregion - - #region hotkey - - public CustomPluginHotkey SelectedCustomPluginHotkey { get; set; } - - #endregion - - #region shortcut - - public CustomShortcutModel? SelectedCustomShortcut { get; set; } - - public void DeleteSelectedCustomShortcut() - { - var item = SelectedCustomShortcut; - if (item == null) - { - MessageBox.Show(InternationalizationManager.Instance.GetTranslation("pleaseSelectAnItem")); - return; - } - - string deleteWarning = string.Format( - InternationalizationManager.Instance.GetTranslation("deleteCustomShortcutWarning"), - item.Key, item.Value); - if (MessageBox.Show(deleteWarning, InternationalizationManager.Instance.GetTranslation("delete"), - MessageBoxButton.YesNo) == MessageBoxResult.Yes) - { - Settings.CustomShortcuts.Remove(item); - } - } - - public bool EditSelectedCustomShortcut() - { - var item = SelectedCustomShortcut; - if (item == null) - { - MessageBox.Show(InternationalizationManager.Instance.GetTranslation("pleaseSelectAnItem")); - return false; - } - - var shortcutSettingWindow = new CustomShortcutSetting(item.Key, item.Value, this); - if (shortcutSettingWindow.ShowDialog() == true) - { - item.Key = shortcutSettingWindow.Key; - item.Value = shortcutSettingWindow.Value; - return true; - } - return false; - } - - public void AddCustomShortcut() - { - var shortcutSettingWindow = new CustomShortcutSetting(this); - if (shortcutSettingWindow.ShowDialog() == true) - { - var shortcut = new CustomShortcutModel(shortcutSettingWindow.Key, shortcutSettingWindow.Value); - Settings.CustomShortcuts.Add(shortcut); - } - } - - public bool ShortcutExists(string key) - { - return Settings.CustomShortcuts.Any(x => x.Key == key) || Settings.BuiltinShortcuts.Any(x => x.Key == key); - } - - #endregion - - #region about - - public string Website => Constant.Website; - public string ReleaseNotes => _updater.GitHubRepository + @"/releases/latest"; - public string Documentation => Constant.Documentation; - public string Docs => Constant.Docs; - public string Github => Constant.GitHub; - public string Version - { - get - { - if (Constant.Version == "1.0.0") - { - return Constant.Dev; - } - else - { - return Constant.Version; - } - } - } - public string ActivatedTimes => string.Format(_translater.GetTranslation("about_activate_times"), Settings.ActivateTimes); - - public string CheckLogFolder - { - get - { - var dirInfo = new DirectoryInfo(Path.Combine(DataLocation.DataDirectory(), Constant.Logs, Constant.Version)); - long size = dirInfo.EnumerateFiles("*", SearchOption.AllDirectories).Sum(file => file.Length); - - return _translater.GetTranslation("clearlogfolder") + " (" + FormatBytes(size) + ")"; - } - } - - internal void ClearLogFolder() - { - var directory = new DirectoryInfo( - Path.Combine( - DataLocation.DataDirectory(), - Constant.Logs, - Constant.Version)); - - directory.EnumerateFiles() - .ToList() - .ForEach(x => x.Delete()); - } - internal string FormatBytes(long bytes) - { - const int scale = 1024; - string[] orders = new string[] - { - "GB", "MB", "KB", "Bytes" - }; - long max = (long)Math.Pow(scale, orders.Length - 1); - - foreach (string order in orders) - { - if (bytes > max) - return string.Format("{0:##.##} {1}", decimal.Divide(bytes, max), order); - - max /= scale; - } - return "0 Bytes"; - } - - #endregion + _settings = settings; + } + + /// + /// Save Flow settings. Plugins settings are not included. + /// + public void Save() + { + _settings.Save(); + } + + public double SettingWindowWidth + { + get => _settings.SettingWindowWidth; + set => _settings.SettingWindowWidth = value; + } + + public double SettingWindowHeight + { + get => _settings.SettingWindowHeight; + set => _settings.SettingWindowHeight = value; + } + + public double? SettingWindowTop + { + get => _settings.SettingWindowTop; + set => _settings.SettingWindowTop = value; + } + + public double? SettingWindowLeft + { + get => _settings.SettingWindowLeft; + set => _settings.SettingWindowLeft = value; } } diff --git a/Flow.Launcher/ViewModel/WelcomeViewModel.cs b/Flow.Launcher/ViewModel/WelcomeViewModel.cs new file mode 100644 index 000000000..5eecabfde --- /dev/null +++ b/Flow.Launcher/ViewModel/WelcomeViewModel.cs @@ -0,0 +1,68 @@ +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.ViewModel +{ + public partial class WelcomeViewModel : BaseModel + { + public const int MaxPageNum = 5; + + public string PageDisplay => $"{PageNum}/5"; + + private int _pageNum = 1; + public int PageNum + { + get => _pageNum; + set + { + if (_pageNum != value) + { + _pageNum = value; + OnPropertyChanged(); + UpdateView(); + } + } + } + + private bool _backEnabled = false; + public bool BackEnabled + { + get => _backEnabled; + set + { + _backEnabled = value; + OnPropertyChanged(); + } + } + + private bool _nextEnabled = true; + public bool NextEnabled + { + get => _nextEnabled; + set + { + _nextEnabled = value; + OnPropertyChanged(); + } + } + + private void UpdateView() + { + OnPropertyChanged(nameof(PageDisplay)); + if (PageNum == 1) + { + BackEnabled = false; + NextEnabled = true; + } + else if (PageNum == MaxPageNum) + { + BackEnabled = true; + NextEnabled = false; + } + else + { + BackEnabled = true; + NextEnabled = true; + } + } + } +} diff --git a/Flow.Launcher/WelcomeWindow.xaml b/Flow.Launcher/WelcomeWindow.xaml index d797a623b..e7c483f89 100644 --- a/Flow.Launcher/WelcomeWindow.xaml +++ b/Flow.Launcher/WelcomeWindow.xaml @@ -6,14 +6,21 @@ 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" Name="FlowWelcomeWindow" - Title="Welcome to Flow Launcher" - Activated="OnActivated" + Title="{DynamicResource Welcome_Page1_Title}" Width="550" Height="650" - MouseDown="window_MouseDown" + MinWidth="550" + MinHeight="650" + MaxWidth="550" + MaxHeight="650" + d:DataContext="{d:DesignInstance Type=vm:WelcomeViewModel}" + Activated="OnActivated" Background="{DynamicResource Color00B}" + Closed="Window_Closed" Foreground="{DynamicResource PopupTextColor}" + MouseDown="window_MouseDown" WindowStartupLocation="CenterScreen" mc:Ignorable="d"> @@ -32,26 +39,23 @@ - - - + Text="{DynamicResource Welcome_Page1_Title}" /> diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/CustomBrowserSetting.xaml.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/CustomBrowserSetting.xaml.cs index b243878f7..bff2967d6 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/CustomBrowserSetting.xaml.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/CustomBrowserSetting.xaml.cs @@ -1,57 +1,56 @@ using Flow.Launcher.Plugin.BrowserBookmark.Models; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Documents; using System.Windows.Input; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Shapes; +using System.Windows.Forms; -namespace Flow.Launcher.Plugin.BrowserBookmark.Views +namespace Flow.Launcher.Plugin.BrowserBookmark.Views; + +/// +/// Interaction logic for CustomBrowserSetting.xaml +/// +public partial class CustomBrowserSettingWindow : Window { - /// - /// Interaction logic for CustomBrowserSetting.xaml - /// - public partial class CustomBrowserSettingWindow : Window + private CustomBrowser _currentCustomBrowser; + public CustomBrowserSettingWindow(CustomBrowser browser) { - private CustomBrowser currentCustomBrowser; - public CustomBrowserSettingWindow(CustomBrowser browser) + InitializeComponent(); + _currentCustomBrowser = browser; + DataContext = new CustomBrowser { - InitializeComponent(); - currentCustomBrowser = browser; - DataContext = new CustomBrowser - { - Name = browser.Name, DataDirectoryPath = browser.DataDirectoryPath - }; - } + Name = browser.Name, + DataDirectoryPath = browser.DataDirectoryPath, + BrowserType = browser.BrowserType, + }; + } - private void ConfirmCancelEditCustomBrowser(object sender, RoutedEventArgs e) + private void ConfirmEditCustomBrowser(object sender, RoutedEventArgs e) + { + CustomBrowser editBrowser = (CustomBrowser)DataContext; + _currentCustomBrowser.Name = editBrowser.Name; + _currentCustomBrowser.DataDirectoryPath = editBrowser.DataDirectoryPath; + _currentCustomBrowser.BrowserType = editBrowser.BrowserType; + DialogResult = true; + Close(); + } + + private void CancelEditCustomBrowser(object sender, RoutedEventArgs e) + { + Close(); + } + + private void WindowKeyDown(object sender, System.Windows.Input.KeyEventArgs e) + { + if (e.Key == Key.Enter) { - if (DataContext is CustomBrowser editBrowser && e.Source is Button button) - { - if (button.Name == "btnConfirm") - { - currentCustomBrowser.Name = editBrowser.Name; - currentCustomBrowser.DataDirectoryPath = editBrowser.DataDirectoryPath; - Close(); - } - } - - Close(); - } - - private void WindowKeyDown(object sender, KeyEventArgs e) - { - if (e.Key == Key.Enter) - { - ConfirmCancelEditCustomBrowser(sender, e); - } + ConfirmEditCustomBrowser(sender, e); } } -} \ No newline at end of file + + private void OnSelectPathClick(object sender, RoutedEventArgs e) + { + var dialog = new FolderBrowserDialog(); + dialog.ShowDialog(); + CustomBrowser editBrowser = (CustomBrowser)DataContext; + editBrowser.DataDirectoryPath = dialog.SelectedPath; + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml index 09ad2101b..014deff03 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml @@ -8,40 +8,77 @@ d:DesignWidth="500" DataContext="{Binding RelativeSource={RelativeSource Self}}" mc:Ignorable="d"> - + - - - - - - - - - - - + + + + + + +