Merge branch 'dev' into ProgressBarDispatcher

This commit is contained in:
Jack Ye 2025-03-14 17:55:46 +08:00 committed by GitHub
commit 9c1ff85b53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
814 changed files with 59900 additions and 22887 deletions

80
.cm/gitstream.cm Normal file
View file

@ -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/(<img.*src.*(jpg|svg|png|gif|psd).*>)|!\[image\]\(.*github\.com.*\)/) }}

View file

@ -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

View file

@ -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\<version>\<date>.txt`
- For portable mode: `%LocalAppData%\FlowLauncher\<App-Version>\UserData\Logs\<version>\<date>.txt`
From flow type 'open log location' and find log file with the corresponding date containing the error info.
value: >
<details>

17
.github/actions/spelling/README.md vendored Normal file
View file

@ -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.

25
.github/actions/spelling/advice.md vendored Normal file
View file

@ -0,0 +1,25 @@
<!-- See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-advice --> <!-- markdownlint-disable MD033 MD041 -->
<details><summary>If the flagged items are :exploding_head: false positives</summary>
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.
</details>

8
.github/actions/spelling/allow.txt vendored Normal file
View file

@ -0,0 +1,8 @@
github
https
ssh
ubuntu
runcount
Firefox
Português
Português (Brasil)

View file

@ -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
(['"]|&quot;)[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
(?<!')\b(?:B|BR|Br|F|FR|Fr|R|RB|RF|Rb|Rf|U|UR|Ur|b|bR|br|f|fR|fr|r|rB|rF|rb|rf|u|uR|ur)'(?:[A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,})
# Regular expressions for (P|p)assword
\([A-Z]\|[a-z]\)[a-z]+
# JavaScript regular expressions
# javascript test regex
/.*/[gim]*\.test\(
# javascript match regex
\.match\(/[^/\s"]*/[gim]*\s*
# javascript match regex
\.match\(/\\[b].*?/[gim]*\s*\)(?:;|$)
# javascript regex
^\s*/\\[b].*/[gim]*\s*(?:\)(?:;|$)|,$)
# javascript replace regex
\.replace\(/[^/\s"]*/[gim]*\s*,
# Go regular expressions
regexp?\.MustCompile\(`[^`]*`\)
# sed regular expressions
sed 's/(?:[^/]*?[a-zA-Z]{3,}[^/]*?/){2}
# go install
go install(?:\s+[a-z]+\.[-@\w/.]+)+
# kubernetes pod status lists
# https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-phase
\w+(?:-\w+)+\s+\d+/\d+\s+(?:Running|Pending|Succeeded|Failed|Unknown)\s+
# kubectl - pods in CrashLoopBackOff
\w+-[0-9a-f]+-\w+\s+\d+/\d+\s+CrashLoopBackOff\s+
# kubernetes object suffix
-[0-9a-f]{10}-\w{5}\s
# posthog secrets
posthog\.init\((['"])phc_[^"',]+\g{-1},
# xcode
# xcodeproject scenes
(?:Controller|ID|id)="\w{3}-\w{2}-\w{3}"
# xcode api botches
customObjectInstantitationMethod
# font awesome classes
\.fa-[-a-z0-9]+
# Update Lorem based on your content (requires `ge` and `w` from https://github.com/jsoref/spelling; and `review` from https://github.com/check-spelling/check-spelling/wiki/Looking-for-items-locally )
# grep '^[^#].*lorem' .github/actions/spelling/patterns.txt|perl -pne 's/.*i..\?://;s/\).*//' |tr '|' "\n"|sort -f |xargs -n1 ge|perl -pne 's/^[^:]*://'|sort -u|w|sed -e 's/ .*//'|w|review -
# Warning, while `(?i)` is very neat and fancy, if you have some binary files that aren't proper unicode, you might run into:
## Operation "substitution (s///)" returns its argument for non-Unicode code point 0x1C19AE (the code point will vary).
## You could manually change `(?i)X...` to use `[Xx]...`
## or you could add the files to your `excludes` file (a version after 0.0.19 should identify the file path)
# Lorem
(?:\w|\s|[,.])*\b(?i)(?:amet|consectetur|cursus|dolor|eros|ipsum|lacus|libero|ligula|lorem|magna|neque|nulla|suscipit|tempus)\b(?:\w|\s|[,.])*
# Non-English
[a-zA-Z]*[ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź][a-zA-Z]{3}[a-zA-ZÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź]*
# French
# This corpus only had capital letters, but you probably want lowercase ones as well.
\b[LN]'+[a-z]{2,}\b
# latex
\\(?:n(?:ew|ormal|osub)|r(?:enew)|t(?:able(?:of|)|he|itle))(?=[a-z]+)
# the negative lookahead here is to allow catching 'templatesz' as a misspelling
# but to otherwise recognize a Windows path with \templates\foo.template or similar:
\\(?:necessary|r(?:eport|esolve[dr]?|esult)|t(?:arget|emplates?))(?![a-z])
# ignore long runs of a single character:
\b([A-Za-z])\g{-1}{3,}\b
# Note that the next example is no longer necessary if you are using
# to match a string starting with a `#`, use a character-class:
[#]backwards
# version suffix <word>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-)/

73
.github/actions/spelling/excludes.txt vendored Normal file
View file

@ -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$

112
.github/actions/spelling/expect.txt vendored Normal file
View file

@ -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

View file

@ -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

123
.github/actions/spelling/patterns.txt vendored Normal file
View file

@ -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+[^>]*>
</\w+[^>]*>
#http/https
(?:\b(?:https?|ftp|file)://)[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]
# UWP
[Uu][Ww][Pp]
# version suffix <word>v#
(?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\d+(?:\b|(?=[a-zA-Z_]))

10
.github/actions/spelling/reject.txt vendored Normal file
View file

@ -0,0 +1,10 @@
^attache$
benefitting
occurences?
^dependan.*
^oer$
Sorce
^[Ss]pae.*
^untill$
^untilling$
^wether.*

View file

@ -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"

31
.github/pr-labeler.yml vendored Normal file
View file

@ -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

372
.github/workflows/default_plugins.yml vendored Normal file
View file

@ -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 }}

49
.github/workflows/gitstream.yml vendored Normal file
View file

@ -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 }}

17
.github/workflows/pr_assignee.yml vendored Normal file
View file

@ -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

22
.github/workflows/pr_milestone.yml vendored Normal file
View file

@ -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

160
.github/workflows/spelling.yml vendored Normal file
View file

@ -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 }}"

View file

@ -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'

View file

@ -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

13
.github/workflows/winget.yml vendored Normal file
View file

@ -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 }}

View file

@ -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

5
Directory.Build.props Normal file
View file

@ -0,0 +1,5 @@
<Project>
<PropertyGroup>
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
</PropertyGroup>
</Project>

View file

@ -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<IPublicAPI>();
/// <summary>
/// As at Squirrel.Windows version 1.5.2, UpdateManager needs to be disposed after finish
/// </summary>
@ -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;

View file

@ -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<UserPlugin> plugins = new();
private static JsonSerializerOptions PluginStoreItemSerializationOption = new JsonSerializerOptions()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault
};
/// <summary>
/// Fetch and deserialize the contents of a plugins.json file found at <see cref="ManifestFileUrl"/>.
/// We use conditional http requests to keep repeat requests fast.
/// </summary>
/// <remarks>
/// 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
/// </remarks>
public async Task<List<UserPlugin>> 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<List<UserPlugin>>(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}");
}
}
}
}

View file

@ -0,0 +1,54 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Flow.Launcher.Core.ExternalPlugins
{
/// <summary>
/// Describes a store of community-made plugins.
/// The provided URLs should point to a json file, whose content
/// is deserializable as a <see cref="UserPlugin"/> array.
/// </summary>
/// <param name="primaryUrl">Primary URL to the manifest json file.</param>
/// <param name="secondaryUrls">Secondary URLs to access the <paramref name="primaryUrl"/>, for example CDN links</param>
public record CommunityPluginStore(string primaryUrl, params string[] secondaryUrls)
{
private readonly List<CommunityPluginSource> pluginSources =
secondaryUrls
.Append(primaryUrl)
.Select(url => new CommunityPluginSource(url))
.ToList();
public async Task<List<UserPlugin>> 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<UserPlugin>();
// 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;
}
}
}

View file

@ -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<IPublicAPI>();
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<PluginMetadata> PluginMetadataList;
internal PluginsSettings PluginSettings;
internal AbstractPluginEnvironment(List<PluginMetadata> pluginMetadataList, PluginsSettings pluginSettings)
{
PluginMetadataList = pluginMetadataList;
PluginSettings = pluginSettings;
}
internal IEnumerable<PluginPair> Setup()
{
if (!PluginMetadataList.Any(o => o.Language.Equals(Language, StringComparison.OrdinalIgnoreCase)))
return new List<PluginPair>();
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<PluginPair>();
}
}
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<PluginPair> SetPathForPluginPairs(string filePath, string languageToSet)
{
var pluginPairs = new List<PluginPair>();
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="settings"></param>
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}";
}
}
}

View file

@ -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<PluginMetadata> pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { }
}
}

View file

@ -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<PluginMetadata> pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { }
}
}

View file

@ -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<PluginMetadata> 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
};
}
}
}

View file

@ -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<PluginMetadata> pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { }
}
}

View file

@ -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<PluginMetadata> 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
};
}
}
}

View file

@ -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<PluginMetadata> 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
};
}
}
}

View file

@ -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<UserPlugin> UserPlugins { get; private set; } = new List<UserPlugin>();
public static List<UserPlugin> UserPlugins { get; private set; }
public static async Task UpdateManifestAsync(CancellationToken token = default)
public static async Task<bool> 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<List<UserPlugin>>(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;
}
}
}
}

View file

@ -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);
}
}

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<TargetFramework>net7.0-windows</TargetFramework>
<UseWpf>true</UseWpf>
<UseWindowsForms>true</UseWindowsForms>
<OutputType>Library</OutputType>
@ -53,10 +53,12 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Droplex" Version="1.4.1" />
<PackageReference Include="FSharp.Core" Version="6.0.6" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.1.3" />
<PackageReference Include="Droplex" Version="1.7.0" />
<PackageReference Include="FSharp.Core" Version="9.0.201" />
<PackageReference Include="Meziantou.Framework.Win32.Jobs" Version="3.4.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="squirrel.windows" Version="1.5.2" NoWarn="NU1701" />
<PackageReference Include="StreamJsonRpc" Version="2.21.10" />
</ItemGroup>
<ItemGroup>

View file

@ -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<Stream> 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);
}
}

View file

@ -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;
}
}

View file

@ -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<JsonRPCResult> Result,
IReadOnlyDictionary<string, object> 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<string, object> 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<JsonRPCResult> Result { get; set; }
public Dictionary<string, object> SettingsChange { get; set; }
public string DebugMessage { get; set; }
}
public class JsonRPCRequestModel
{
public string Method { get; set; }
public object[] Parameters { get; set; }
public Dictionary<string, object> Settings { get; set; }
private static readonly JsonSerializerOptions options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public override string ToString()
{
return JsonSerializer.Serialize(this, options);
}
}
/// <summary>
/// Json RPC Request that Flow Launcher sent to client
/// </summary>
public class JsonRPCServerRequestModel : JsonRPCRequestModel
{
}
/// <summary>
/// Json RPC Request(in query response) that client sent to Flow Launcher
/// </summary>
public class JsonRPCClientRequestModel : JsonRPCRequestModel
{
public bool DontHideAfterAction { get; set; }
}
public record JsonRPCClientRequestModel(
int Id,
string Method,
object[] Parameters,
IReadOnlyDictionary<string, object> Settings,
bool DontHideAfterAction = false,
JsonRPCErrorModel Error = default) : JsonRPCRequestModel(Id, Method, Parameters, Settings, Error);
/// <summary>
/// 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

View file

@ -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<string> Options { get; set; }
public string DefaultValue { get; set; }
@ -40,4 +43,4 @@ namespace Flow.Launcher.Core.Plugin
DefaultValue = this.DefaultValue;
}
}
}
}

View file

@ -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
/// </summary>
internal abstract class JsonRPCPlugin : IAsyncPlugin, IContextMenu, ISettingProvider, ISavable
internal abstract class JsonRPCPlugin : JsonRPCPluginBase
{
protected PluginInitContext context;
public const string JsonRPC = "JsonRPC";
/// <summary>
/// The language this JsonRPCPlugin support
/// </summary>
public abstract string SupportedLanguage { get; set; }
protected abstract Task<Stream> 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<Result> 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<Result> 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<string, object> Settings { get; set; }
private readonly Dictionary<string, FrameworkElement> _settingControls = new();
@ -106,85 +98,42 @@ namespace Flow.Launcher.Core.Plugin
return ParseResults(queryResponseModel);
}
private List<Result> ParseResults(JsonRPCQueryResponseModel queryResponseModel)
protected override async Task<bool> 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<JsonRPCRequestModel>(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<JsonRPCRequestModel>(actionResponse, options);
if (jsonRpcRequestModel?.Method?.StartsWith("Flow.Launcher.") ?? false)
{
ExecuteFlowLauncherAPI(jsonRpcRequestModel.Method["Flow.Launcher.".Length..],
jsonRpcRequestModel.Parameters);
}
}
var results = new List<Result>();
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
}
}
/// <summary>
/// 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<List<Result>> QueryAsync(Query query, CancellationToken token)
public override async Task<List<Result>> 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<Dictionary<string, object>>(fileStream, options);
}
var deserializer = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance).Build();
_settingsTemplate = deserializer.Deserialize<JsonRpcConfigurationModel>(await File.ReadAllTextAsync(SettingConfigurationPath));
Settings ??= new Dictionary<string, object>();
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<string, object> 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;
}
}
}
}
}
}

View file

@ -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
{
/// <summary>
/// Represent the plugin that using JsonPRC
/// every JsonRPC plugin should has its own plugin instance
/// </summary>
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<Result> 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<bool> ExecuteResultAsync(JsonRPCResult result);
protected JsonRPCPluginSettings Settings { get; set; }
protected List<Result> 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<Result>();
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<List<Result>> 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<JsonRpcConfigurationModel>(
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);
}
}
}
}

View file

@ -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<string, FrameworkElement> SettingControls { get; } = new();
public IReadOnlyDictionary<string, object> Inner => Settings;
protected ConcurrentDictionary<string, object> Settings { get; set; }
public required IPublicAPI API { get; init; }
private JsonStorage<ConcurrentDictionary<string, object>> _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<ConcurrentDictionary<string, object>>(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<string, object> 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;
}
}
}

View file

@ -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<bool> ExecuteResultAsync(JsonRPCResult result)
{
var res = await RPC.InvokeAsync<JsonRPCExecuteResponse>(result.JsonRPCAction.Method,
argument: result.JsonRPCAction.Parameters);
return res.Hide;
}
private JoinableTaskFactory JTF { get; } = new JoinableTaskFactory(new JoinableTaskContext());
public override List<Result> LoadContextMenus(Result selectedResult)
{
var res = JTF.Run(() => RPC.InvokeWithCancellationAsync<JsonRPCQueryResponseModel>("context_menu",
new object[] { selectedResult.ContextData }));
var results = ParseResults(res);
return results;
}
public override async Task<List<Result>> QueryAsync(Query query, CancellationToken token)
{
var res = await RPC.InvokeWithCancellationAsync<JsonRPCQueryResponseModel>("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<string, JsonRPCQueryResponseModel>((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();
}
}
}
}

View file

@ -0,0 +1,4 @@
namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models
{
public record JsonRPCExecuteResponse(bool Hide = true);
}

View file

@ -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<PluginPair> GetAllPlugins()
{
return _api.GetAllPlugins();
}
public MatchResult FuzzySearch(string query, string stringToCompare)
{
return _api.FuzzySearch(query, stringToCompare);
}
public Task<string> HttpGetStringAsync(string url, CancellationToken token = default)
{
return _api.HttpGetStringAsync(url, token);
}
public Task<Stream> HttpGetStreamAsync(string url, CancellationToken token = default)
{
return _api.HttpGetStreamAsync(url, token);
}
public Task HttpDownloadAsync([NotNull] string url, [NotNull] string filePath, Action<double> 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();
}
}
}

View file

@ -0,0 +1,9 @@
using System.Collections.Generic;
using Flow.Launcher.Plugin;
namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models
{
public record JsonRPCQueryRequest(
List<JsonRPCResult> Results
);
}

View file

@ -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
{
/// <summary>
/// Execution of JavaScript & TypeScript plugins
/// </summary>
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<Stream> 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);
}
}
}

View file

@ -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
{
/// <summary>
/// Execution of JavaScript & TypeScript plugins
/// </summary>
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;
}
}

View file

@ -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));
}
}
}
}

View file

@ -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<PluginPair> GlobalPlugins = new();
public static readonly Dictionary<string, PluginPair> 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<IPublicAPI>();
// todo happlebao, this should not be public, the indicator function should be embeded
public static PluginsSettings Settings;
private static PluginsSettings Settings;
private static List<PluginMetadata> _metadatas;
private static List<string> _modifiedPlugins = new List<string>();
/// <summary>
/// Directories that will hold Flow Launcher plugin directory
@ -48,6 +54,9 @@ namespace Flow.Launcher.Core.Plugin
}
}
/// <summary>
/// Save json and ISavable
/// </summary>
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<IAsyncExternalPreview>().Any(x => !x.Metadata.Disabled);
}
public static bool AllowAlwaysPreview()
{
var plugin = GetPluginsForInterface<IAsyncExternalPreview>().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
/// </summary>
/// <returns>return the list of failed to init plugins or null for none</returns>
public static async Task InitializePluginsAsync(IPublicAPI api)
public static async Task InitializePluginsAsync()
{
API = api;
var failedPlugins = new ConcurrentQueue<PluginPair>();
var InitTasks = AllPlugins.Select(pair => Task.Run(async delegate
@ -155,12 +205,21 @@ namespace Flow.Launcher.Core.Plugin
}
}
InternationalizationManager.Instance.AddPluginLanguageDirectories(GetPluginsForInterface<IPluginI18n>());
InternationalizationManager.Instance.ChangeLanguage(Ioc.Default.GetRequiredService<Settings>().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<PluginPair>();
if (!NonGlobalPlugins.ContainsKey(query.ActionKeyword))
return GlobalPlugins;
var plugin = NonGlobalPlugins[query.ActionKeyword];
return new List<PluginPair>
{
@ -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<Result> results, PluginMetadata metadata, Query query)
public static void UpdatePluginMetadata(IReadOnlyList<Result> 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<PluginPair> GetPluginsForInterface<T>() 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<PluginPair>();
}
public static List<Result> 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<PluginMetadata>(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);
}
/// <summary>
/// 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
/// </summary>
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);
}
/// <summary>
/// Install a plugin. By default will remove the zip file if installation is from url, unless it's a local path installation
/// </summary>
public static void InstallPlugin(UserPlugin plugin, string zipFilePath)
{
InstallPlugin(plugin, zipFilePath, checkModified: true);
}
/// <summary>
/// Uninstall a plugin.
/// </summary>
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<string>
{
"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
}
}

View file

@ -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<PluginPair> Plugins(List<PluginMetadata> 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<IPublicAPI>().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<PluginPair> PythonPlugins(List<PluginMetadata> source, PluginsSettings settings)
{
if (!source.Any(o => o.Language.ToUpper() == AllowedLanguage.Python))
return new List<PluginPair>();
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<PluginPair>();
}
return SetPythonPathForPluginPairs(source, pythonPath);
}
private static IEnumerable<PluginPair> SetPythonPathForPluginPairs(List<PluginMetadata> 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<PluginPair> ExecutablePlugins(IEnumerable<PluginMetadata> source)
public static IEnumerable<PluginPair> ExecutablePlugins(IEnumerable<PluginMetadata> 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<PluginPair> ExecutableV2Plugins(IEnumerable<PluginMetadata> source)
{
return source
.Where(o => o.Language.Equals(AllowedLanguage.ExecutableV2, StringComparison.OrdinalIgnoreCase))
.Select(metadata => new PluginPair
{
Plugin = new ExecutablePluginV2(metadata.ExecuteFilePath), Metadata = metadata
});
}
}
}

View file

@ -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();
}
}
}

View file

@ -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<Stream> 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 <code>`
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;
}

View file

@ -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 <code>`
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;
}
}

View file

@ -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
};
}
}
}

View file

@ -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<Language> 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",
};
}
}
}

View file

@ -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<string> _languageDirectories = new List<string>();
private readonly List<ResourceDictionary> _oldResources = new List<ResourceDictionary>();
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<IPluginI18n>())
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<PluginPair> 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<IPublicAPI>().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<Language> 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)

View file

@ -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<Internationalization>() 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<Internationalization>();
}
}
}

View file

@ -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
{

View file

@ -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
{

View file

@ -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<string> _themeDirectories = new List<string>();
private readonly IPublicAPI _api;
private readonly Settings _settings;
private readonly List<string> _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<string> LoadAvailableThemes()
private ResourceDictionary GetCurrentResourceDictionary( )
{
List<string> themes = new List<string>();
return GetResourceDictionary(_settings.Theme);
}
public List<ThemeData> LoadAvailableThemes()
{
List<ThemeData> themes = new List<ThemeData>();
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);
/// <summary>
/// Sets the blur for a window via SetWindowCompositionAttribute
/// </summary>
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);
}
}

View file

@ -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<Theme>() 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<Theme>();
}
}

View file

@ -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)
{

View file

@ -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<UpdateManager> 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;
}
}
}

View file

@ -1,115 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="12.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{2FEB2298-7653-4009-B1EA-FFFB1A768BCC}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>Flow.Launcher.CrashReporter</RootNamespace>
<AssemblyName>Flow.Launcher.CrashReporter</AssemblyName>
<TargetFrameworkVersion>v4.5.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir>
<TargetFrameworkProfile />
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>..\Output\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<Prefer32Bit>false</Prefer32Bit>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>..\Output\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<Prefer32Bit>false</Prefer32Bit>
</PropertyGroup>
<ItemGroup>
<Reference Include="Exceptionless, Version=1.5.2121.0, Culture=neutral, PublicKeyToken=fc181f0a46f65747, processorArchitecture=MSIL">
<HintPath>..\packages\Exceptionless.1.5.2121\lib\net45\Exceptionless.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="Exceptionless.Models, Version=1.5.2121.0, Culture=neutral, PublicKeyToken=fc181f0a46f65747, processorArchitecture=MSIL">
<HintPath>..\packages\Exceptionless.1.5.2121\lib\net45\Exceptionless.Models.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="JetBrains.Annotations, Version=10.1.4.0, Culture=neutral, PublicKeyToken=1010a0d8d6380325, processorArchitecture=MSIL">
<HintPath>..\packages\JetBrains.Annotations.10.1.4\lib\net20\JetBrains.Annotations.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xaml" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="System.Data" />
<Reference Include="System.Xml" />
<Reference Include="WindowsBase" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\SolutionAssemblyInfo.cs">
<Link>Properties\SolutionAssemblyInfo.cs</Link>
</Compile>
<Compile Include="CrashReporter.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="ReportWindow.xaml.cs">
<DependentUpon>ReportWindow.xaml</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<Page Include="ReportWindow.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Flow.Launcher.Core\Flow.Launcher.Core.csproj">
<Project>{B749F0DB-8E75-47DB-9E5E-265D16D0C0D2}</Project>
<Name>Flow.Launcher.Core</Name>
</ProjectReference>
<ProjectReference Include="..\Flow.Launcher.Infrastructure\Flow.Launcher.Infrastructure.csproj">
<Project>{4fd29318-a8ab-4d8f-aa47-60bc241b8da3}</Project>
<Name>Flow.Launcher.Infrastructure</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<Resource Include="Images\crash_warning.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
<Resource Include="Images\crash_go.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
</ItemGroup>
<ItemGroup>
<Resource Include="Images\crash_stop.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
</ItemGroup>
<ItemGroup>
<Resource Include="Images\app_error.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
Other similar extension points exist, see Microsoft.Common.targets.
<Target Name="BeforeBuild">
</Target>
<Target Name="AfterBuild">
</Target>
-->
</Project>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 738 B

View file

@ -1,5 +0,0 @@
using System.Reflection;
using System.Runtime.InteropServices;
[assembly: AssemblyTitle("Flow.Launcher.CrashReporter")]
[assembly: Guid("0ea3743c-2c0d-4b13-b9ce-e5e1f85aea23")]

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Exceptionless" version="1.5.2121" targetFramework="net452" />
<package id="JetBrains.Annotations" version="10.1.4" targetFramework="net452" />
<package id="System.Runtime" version="4.0.0" targetFramework="net452" />
</packages>

View file

@ -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";
}
}

View file

@ -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";
}
}
}
}

View file

@ -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
{
/// <summary>
/// Gets the path of the file explorer that is currently in the foreground
/// </summary>
public static string GetActiveExplorerPath()
{
var explorerWindow = GetActiveExplorer();
string locationUrl = explorerWindow?.LocationURL;
return !string.IsNullOrEmpty(locationUrl) ? GetDirectoryPath(new Uri(locationUrl).LocalPath) : null;
}
/// <summary>
/// Get directory path from a file path
/// </summary>
private static string GetDirectoryPath(string path)
{
if (!path.EndsWith("\\"))
{
return path + "\\";
}
return path;
}
/// <summary>
/// Gets the file explorer that is currently in the foreground
/// </summary>
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<dynamic>();
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);
/// <summary>
/// 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.
/// </summary>
private static IEnumerable<int> GetZOrder(List<dynamic> 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;
}
}
}

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<TargetFramework>net7.0-windows</TargetFramework>
<ProjectGuid>{4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}</ProjectGuid>
<OutputType>Library</OutputType>
<UseWpf>true</UseWpf>
@ -35,6 +35,10 @@
<Prefer32Bit>false</Prefer32Bit>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="NativeMethods.txt" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\SolutionAssemblyInfo.cs" Link="Properties\SolutionAssemblyInfo.cs" />
<None Include="FodyWeavers.xml" />
@ -49,16 +53,21 @@
<ItemGroup>
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
<PackageReference Include="BitFaster.Caching" Version="2.5.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Fody" Version="6.5.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading" Version="17.3.44" />
<PackageReference Include="MemoryPack" Version="1.21.4" />
<PackageReference Include="Microsoft.VisualStudio.Threading" Version="17.12.19" />
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.106">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NLog" Version="4.7.10" />
<PackageReference Include="NLog.Schema" Version="4.7.10" />
<PackageReference Include="NLog.Web.AspNetCore" Version="4.13.0" />
<PackageReference Include="PropertyChanged.Fody" Version="3.4.0" />
<PackageReference Include="System.Drawing.Common" Version="5.0.3" />
<PackageReference Include="System.Drawing.Common" Version="7.0.0" />
<!--ToolGood.Words.Pinyin v3.0.2.6 results in high memory usage when search with pinyin is enabled-->
<!--Bumping to it or higher needs to test and ensure this is no longer a problem-->
<PackageReference Include="ToolGood.Words.Pinyin" Version="3.0.1.4" />

View file

@ -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
/// <summary>
/// http://www.yinwang.org/blog-cn/2015/11/21/programming-philosophy
/// </summary>
public static T NonNull<T>(this T obj)
public static T NonNull<T>(this T? obj)
{
if (obj == null)
{

View file

@ -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
/// </summary>
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<KeyEvent, int, SpecialKeyState, bool> 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();
}
}
}
}

View file

@ -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<Key, string> specialSymbolDictionary = new Dictionary<Key, string>
private static readonly Dictionary<Key, string> specialSymbolDictionary = new Dictionary<Key, string>
{
{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<string> 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<Key, string>? specialSymbolPair = specialSymbolDictionary.FirstOrDefault(pair => pair.Value == charKey);
KeyValuePair<Key, string>? 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<string> 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;
/// <summary>
/// Validate hotkey
/// </summary>
/// <param name="validateKeyGestrue">Try to validate hotkey as a KeyGesture.</param>
/// <returns></returns>
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);
}
}
}

View file

@ -0,0 +1,17 @@
using System.Collections.Generic;
namespace Flow.Launcher.Infrastructure.Hotkey;
/// <summary>
/// Interface that you should implement in your settings class to be able to pass it to
/// <c>Flow.Launcher.HotkeyControlDialog</c>. It allows the dialog to display the hotkeys that have already been
/// registered, and optionally provide a way to unregister them.
/// </summary>
public interface IHotkeySettings
{
/// <summary>
/// A list of hotkeys that have already been registered. The dialog will display these hotkeys and provide a way to
/// unregister them.
/// </summary>
public List<RegisteredHotkeyData> RegisteredHotkeys { get; }
}

View file

@ -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<int, UIntPtr, IntPtr, IntPtr> 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<int, UIntPtr, IntPtr, IntPtr> 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);
}
}

View file

@ -1,3 +1,5 @@
using Windows.Win32;
namespace Flow.Launcher.Infrastructure.Hotkey
{
public enum KeyEvent
@ -5,21 +7,21 @@ namespace Flow.Launcher.Infrastructure.Hotkey
/// <summary>
/// Key down
/// </summary>
WM_KEYDOWN = 256,
WM_KEYDOWN = (int)PInvoke.WM_KEYDOWN,
/// <summary>
/// Key up
/// </summary>
WM_KEYUP = 257,
WM_KEYUP = (int)PInvoke.WM_KEYUP,
/// <summary>
/// System key up
/// </summary>
WM_SYSKEYUP = 261,
WM_SYSKEYUP = (int)PInvoke.WM_SYSKEYUP,
/// <summary>
/// System key down
/// </summary>
WM_SYSKEYDOWN = 260
WM_SYSKEYDOWN = (int)PInvoke.WM_SYSKEYDOWN
}
}
}

View file

@ -0,0 +1,119 @@
using System;
namespace Flow.Launcher.Infrastructure.Hotkey;
#nullable enable
/// <summary>
/// Represents a hotkey that has been registered. Used in <c>Flow.Launcher.HotkeyControlDialog</c> via
/// <see cref="UserSettings"/> and <see cref="IHotkeySettings"/> to display errors if user tries to register a hotkey
/// that has already been registered, and optionally provides a way to unregister the hotkey.
/// </summary>
public record RegisteredHotkeyData
{
/// <summary>
/// <see cref="HotkeyModel"/> representation of this hotkey.
/// </summary>
public HotkeyModel Hotkey { get; }
/// <summary>
/// String key in the localization dictionary that represents this hotkey. For example, <c>ReloadPluginHotkey</c>,
/// which represents the string "Reload Plugins Data" in <c>en.xaml</c>
/// </summary>
public string DescriptionResourceKey { get; }
/// <summary>
/// Array of values that will replace <c>{0}</c>, <c>{1}</c>, <c>{2}</c>, etc. in the localized string found via
/// <see cref="DescriptionResourceKey"/>.
/// </summary>
public object?[] DescriptionFormatVariables { get; } = Array.Empty<object?>();
/// <summary>
/// An action that, when called, will unregister this hotkey. If it's <c>null</c>, it's assumed that
/// this hotkey can't be unregistered, and the "Overwrite" option will not appear in the hotkey dialog.
/// </summary>
public Action? RemoveHotkey { get; }
/// <summary>
/// Creates an instance of <c>RegisteredHotkeyData</c>. Assumes that the key specified in
/// <c>descriptionResourceKey</c> doesn't need any arguments for <c>string.Format</c>. If it does,
/// use one of the other constructors.
/// </summary>
/// <param name="hotkey">
/// The hotkey this class will represent.
/// Example values: <c>F1</c>, <c>Ctrl+Shift+Enter</c>
/// </param>
/// <param name="descriptionResourceKey">
/// The key in the localization dictionary that represents this hotkey. For example, <c>ReloadPluginHotkey</c>,
/// which represents the string "Reload Plugins Data" in <c>en.xaml</c>
/// </param>
/// <param name="removeHotkey">
/// An action that, when called, will unregister this hotkey. If it's <c>null</c>, it's assumed that this hotkey
/// can't be unregistered, and the "Overwrite" option will not appear in the hotkey dialog.
/// </param>
public RegisteredHotkeyData(string hotkey, string descriptionResourceKey, Action? removeHotkey = null)
{
Hotkey = new HotkeyModel(hotkey);
DescriptionResourceKey = descriptionResourceKey;
RemoveHotkey = removeHotkey;
}
/// <summary>
/// Creates an instance of <c>RegisteredHotkeyData</c>. Assumes that the key specified in
/// <c>descriptionResourceKey</c> needs exactly one argument for <c>string.Format</c>.
/// </summary>
/// <param name="hotkey">
/// The hotkey this class will represent.
/// Example values: <c>F1</c>, <c>Ctrl+Shift+Enter</c>
/// </param>
/// <param name="descriptionResourceKey">
/// The key in the localization dictionary that represents this hotkey. For example, <c>ReloadPluginHotkey</c>,
/// which represents the string "Reload Plugins Data" in <c>en.xaml</c>
/// </param>
/// <param name="descriptionFormatVariable">
/// The value that will replace <c>{0}</c> in the localized string found via <c>description</c>.
/// </param>
/// <param name="removeHotkey">
/// An action that, when called, will unregister this hotkey. If it's <c>null</c>, it's assumed that this hotkey
/// can't be unregistered, and the "Overwrite" option will not appear in the hotkey dialog.
/// </param>
public RegisteredHotkeyData(
string hotkey, string descriptionResourceKey, object? descriptionFormatVariable, Action? removeHotkey = null
)
{
Hotkey = new HotkeyModel(hotkey);
DescriptionResourceKey = descriptionResourceKey;
DescriptionFormatVariables = new[] { descriptionFormatVariable };
RemoveHotkey = removeHotkey;
}
/// <summary>
/// Creates an instance of <c>RegisteredHotkeyData</c>. Assumes that the key specified in
/// <paramref name="descriptionResourceKey"/> needs multiple arguments for <c>string.Format</c>.
/// </summary>
/// <param name="hotkey">
/// The hotkey this class will represent.
/// Example values: <c>F1</c>, <c>Ctrl+Shift+Enter</c>
/// </param>
/// <param name="descriptionResourceKey">
/// The key in the localization dictionary that represents this hotkey. For example, <c>ReloadPluginHotkey</c>,
/// which represents the string "Reload Plugins Data" in <c>en.xaml</c>
/// </param>
/// <param name="descriptionFormatVariables">
/// Array of values that will replace <c>{0}</c>, <c>{1}</c>, <c>{2}</c>, etc.
/// in the localized string found via <c>description</c>.
/// </param>
/// <param name="removeHotkey">
/// An action that, when called, will unregister this hotkey. If it's <c>null</c>, it's assumed that this hotkey
/// can't be unregistered, and the "Overwrite" option will not appear in the hotkey dialog.
/// </param>
public RegisteredHotkeyData(
string hotkey, string descriptionResourceKey, object?[] descriptionFormatVariables, Action? removeHotkey = null
)
{
Hotkey = new HotkeyModel(hotkey);
DescriptionResourceKey = descriptionResourceKey;
DescriptionFormatVariables = descriptionFormatVariables;
RemoveHotkey = removeHotkey;
}
}

View file

@ -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<IPublicAPI>().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<double> 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<string> 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);
}
/// <summary>
@ -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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="url">The Uri the request is sent to.</param>
/// <param name="completionOption">An HTTP completion option value that indicates when the operation should be considered completed.</param>
/// <param name="token">A cancellation token that can be used by other objects or threads to receive notice of cancellation</param>
/// <returns></returns>
public static Task<Stream> GetStreamAsync([NotNull] string url,
CancellationToken token = default) => GetStreamAsync(new Uri(url), token);
/// <summary>
/// Send a GET request to the specified Uri with an HTTP completion option and a cancellation token as an asynchronous operation.
/// </summary>
/// <param name="url"></param>
/// <param name="token"></param>
/// <returns></returns>
public static async Task<Stream> GetStreamAsync([NotNull] string url, CancellationToken token = default)
public static async Task<Stream> 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<HttpResponseMessage> GetResponseAsync(string url, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead,
CancellationToken token = default)
=> await GetResponseAsync(new Uri(url), completionOption, token);
public static async Task<HttpResponseMessage> GetResponseAsync([NotNull] Uri url, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead,
CancellationToken token = default)
{
Log.Debug($"|Http.Get|Url <{url}>");
return await client.GetAsync(url, completionOption, token);
}
/// <summary>
/// Asynchrously send an HTTP request.
/// </summary>
public static async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token = default)
public static async Task<HttpResponseMessage> 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);
}
}
}

View file

@ -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<string, ImageUsage> Data { get; private set; } = new ConcurrentDictionary<string, ImageUsage>();
private const int permissibleFactor = 2;
private SemaphoreSlim semaphore = new(1, 1);
private const int MaxCached = 150;
public void Initialization(Dictionary<string, int> 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<ImageSource> GetOrAddAsync(string key,
Func<(string, bool), Task<ImageSource>> 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;
}
/// <summary>
@ -97,7 +70,14 @@ namespace Flow.Launcher.Infrastructure.Image
/// </summary>
public int UniqueImagesInCache()
{
return Data.Values.Select(x => x.imageSource).Distinct().Count();
return CacheManager.Select(x => x.Value)
.Distinct()
.Count();
}
public IEnumerable<KeyValuePair<(string, bool), ImageSource>> EnumerateEntries()
{
return CacheManager;
}
}
}
}

View file

@ -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<Dictionary<string, int>> _storage;
private static SemaphoreSlim storageLock { get; } = new SemaphoreSlim(1, 1);
private static BinaryStorage<List<(string, bool)>> _storage;
private static readonly ConcurrentDictionary<string, string> 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<Dictionary<string, int>>("Image");
_storage = new BinaryStorage<List<(string, bool)>>("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<string, int> LoadStorageToConcurrentDictionary()
private static async Task<List<(string, bool)>> LoadStorageToConcurrentDictionaryAsync()
{
lock (_storage)
await storageLock.WaitAsync();
try
{
var loaded = _storage.TryLoad(new Dictionary<string, int>());
return new ConcurrentDictionary<string, int>(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<ImageResult> 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<BitmapImage> 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<ImageSource> 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;
}
}

View file

@ -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
{
/// <summary>
/// Subclass of <see cref="Windows.Win32.UI.Shell.SIIGBF"/>
/// </summary>
[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;
}
}
}
}

View file

@ -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
}
}
/// <param name="message">example: "|prefix|unprefixed" </param>
/// Example: "|ClassName.MethodName|Message"
/// <param name="message">Example: "|ClassName.MethodName|Message" </param>
/// <param name="e">Exception</param>
public static void Exception(string message, System.Exception e)
{
@ -158,7 +181,7 @@ namespace Flow.Launcher.Infrastructure.Logger
#endif
}
/// <param name="message">example: "|prefix|unprefixed" </param>
/// 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);
}
/// <param name="message">example: "|prefix|unprefixed" </param>
/// 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);
}
/// <param name="message">example: "|prefix|unprefixed" </param>
/// 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);
}
/// <param name="message">example: "|prefix|unprefixed" </param>
/// Example: "|ClassName.MethodName|Message"
public static void Warn(string message)
{
LogInternal(message, LogLevel.Warn);
}
}
public enum LOGLEVEL
{
DEBUG,
INFO
}
}

View file

@ -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

View file

@ -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<Settings>());
}
private void Initialize([NotNull] Settings settings)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
}

View file

@ -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.
/// <summary>
/// Stroage object using binary data
/// Normally, it has better performance, but not readable
/// </summary>
/// <remarks>
/// It utilize MemoryPack, which means the object must be MemoryPackSerializable
/// https://github.com/Cysharp/MemoryPack
/// </remarks>
public class BinaryStorage<T>
{
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<T> 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<T> 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<T>(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
}

View file

@ -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

View file

@ -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 { }
}

View file

@ -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
/// </summary>
public class JsonStorage<T> 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<T> 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<T>(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<T> LoadBackupOrDefaultAsync()
{
var backup = await TryLoadBackupAsync();
return backup ?? LoadDefault();
}
private async ValueTask<T?> TryLoadBackupAsync()
{
if (!File.Exists(BackupFilePath))
return default;
try
{
_data = JsonSerializer.Deserialize<T>(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<T>(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<T>(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<T>(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<T> : JsonStorage<T> where T : new() { }
}

View file

@ -3,14 +3,17 @@ using Flow.Launcher.Infrastructure.UserSettings;
namespace Flow.Launcher.Infrastructure.Storage
{
public class PluginJsonStorage<T> :JsonStorage<T> where T : new()
public class PluginJsonStorage<T> : JsonStorage<T> 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);
}
}
}
}

View file

@ -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<StringMatcher>().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<int> spaceIndices, int firstMatchIndex)
private static int CalculateClosestSpaceIndex(List<int> spaceIndices, int firstMatchIndex)
{
var closestSpaceIndex = -1;

View file

@ -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
{

View file

@ -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);
}
}

View file

@ -1,6 +1,4 @@
using System.ComponentModel;
namespace Flow.Launcher.Infrastructure.UserSettings
namespace Flow.Launcher.Infrastructure.UserSettings
{
public enum ProxyProperty
{

Some files were not shown because too many files have changed in this diff Show more