Compare commits
56 Commits
v2.5
...
31274d17a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31274d17a5 | ||
|
|
450f64605e | ||
|
|
391e2dd2c8 | ||
|
|
8fc01db275 | ||
|
|
76c2b9a475 | ||
|
|
14d5a6b52b | ||
|
|
6069330e92 | ||
|
|
552ebb7ad5 | ||
|
|
74e6ee8e8e | ||
|
|
167aef9ba1 | ||
|
|
ed726f26f4 | ||
|
|
760f611007 | ||
|
|
49c704037b | ||
|
|
7a5f8a5e41 | ||
|
|
1bae41a350 | ||
|
|
f1bdbbc0af | ||
|
|
f01c26b2c2 | ||
|
|
cbe1f971a5 | ||
|
|
1d654ac4de | ||
|
|
55b9b4a38b | ||
|
|
e916fdbe6c | ||
|
|
0e3df33d1f | ||
|
|
506fe1cae6 | ||
|
|
1d97314825 | ||
|
|
e1ecb6760b | ||
|
|
953f560a11 | ||
|
|
3d69911aa8 | ||
|
|
1052735535 | ||
|
|
d6504ac2e9 | ||
|
|
2a25f934c5 | ||
|
|
16a7f3409c | ||
|
|
0e11cec99a | ||
|
|
c158912da4 | ||
|
|
08ad04401d | ||
|
|
a851d8ac9d | ||
|
|
5a3547e32e | ||
|
|
b24152c19a | ||
|
|
9f93298cf9 | ||
|
|
ac9b635ed8 | ||
|
|
72a1930b9e | ||
|
|
e339354cc9 | ||
|
|
4b3a278679 | ||
|
|
aa06e65c59 | ||
|
|
dd57abefdd | ||
|
|
be8ba62bb1 | ||
|
|
b7895f6743 | ||
|
|
ebe7b130b8 | ||
|
|
7fe688e97c | ||
|
|
6b02a09f75 | ||
|
|
f0d2ab6493 | ||
|
|
42ee0372fe | ||
|
|
9762e09cb3 | ||
|
|
dd8b7ab27d | ||
|
|
c348593ef4 | ||
|
|
a51da7b8ec | ||
|
|
33503f7896 |
9
.github/workflows/build-docker.yml
vendored
@@ -3,6 +3,8 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: nkanaev/yarr
|
||||
@@ -17,6 +19,12 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -38,3 +46,4 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
1
.gitignore
vendored
@@ -6,3 +6,4 @@
|
||||
*.db-wal
|
||||
*.syso
|
||||
versioninfo.rc
|
||||
.DS_Store
|
||||
|
||||
@@ -32,7 +32,7 @@ func opt(envVar, defaultValue string) string {
|
||||
|
||||
func parseAuthfile(authfile io.Reader) (username, password string, err error) {
|
||||
scanner := bufio.NewScanner(authfile)
|
||||
for scanner.Scan() {
|
||||
if scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
@@ -40,7 +40,6 @@ func parseAuthfile(authfile io.Reader) (username, password string, err error) {
|
||||
}
|
||||
username = parts[0]
|
||||
password = parts[1]
|
||||
break
|
||||
}
|
||||
return username, password, nil
|
||||
}
|
||||
@@ -90,6 +89,10 @@ func main() {
|
||||
log.SetOutput(os.Stdout)
|
||||
}
|
||||
|
||||
if open && strings.HasPrefix(addr, "unix:") {
|
||||
log.Fatal("Cannot open ", addr, " in browser")
|
||||
}
|
||||
|
||||
if db == "" {
|
||||
configPath, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
# upcoming
|
||||
# upcoming
|
||||
|
||||
- (fix) articles not resetting immediately after feed/filter selection (thank to @scratchmex for the report)
|
||||
- (fix) crash on empty article list with article is selected (thanks to @rksvc)
|
||||
- (fix) invalid article title in RSS feeds with media containing titles (thanks to @bwwu-git for the report)
|
||||
- (fix) missing image enclosures in certain RSS feeds (thanks to @palinek for the report)
|
||||
|
||||
# v2.6 (2025-11-24)
|
||||
|
||||
- (new) serve on unix socket (thanks to @rvighne)
|
||||
- (new) more auto-refresh options: 12h & 24h (thanks to @aswerkljh for suggestion)
|
||||
- (fix) smooth scrolling on iOS (thanks to gatheraled)
|
||||
- (fix) displaying youtube shorts in "Read Here" (thanks to @Dean-Corso for the report)
|
||||
- (etc) theme-color support (thanks to @asimpson)
|
||||
- (etc) cookie security measures (thanks to Tom Fitzhenry)
|
||||
- (etc) restrict access to internal IPs for page crawler (thanks to Omar Kurt)
|
||||
|
||||
# v2.5 (2025-03-26)
|
||||
|
||||
- (new) Fever API support (thanks to @icefed)
|
||||
- (new) editable feed link (thanks to @adaszko)
|
||||
- (new) switch to feed by clicking the title in the article page (thanks to @tarasglek for suggestion)
|
||||
- (new) support multiple media links
|
||||
- (new) next/prev article navigation buttons (thanks to @tillcash)
|
||||
- (fix) duplicate articles caused by the same feed addition (thanks to @adaszko)
|
||||
- (fix) relative article links (thanks to @adazsko for the report)
|
||||
- (fix) atom article links stored in id element (thanks to @adazsko for the report)
|
||||
@@ -17,6 +35,7 @@
|
||||
- (etc) load external images with no-referrer policy (thanks to @tillcash for the report)
|
||||
- (etc) open external links with no-referrer policy (thanks to @donovanglover)
|
||||
- (etc) show article content in the list if title is missing (thanks to @asimpson for suggestion)
|
||||
- (etc) accessibility improvements (thanks to @tseykovets)
|
||||
|
||||
# v2.4 (2023-08-15)
|
||||
|
||||
254
doc/fever-api.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# API Public Beta
|
||||
|
||||
Fever 1.14 introduces the new Fever API. This API is in public beta and currently supports basic syncing and consuming of content. A subsequent update will allow for adding, editing and deleting feeds and groups. The API’s primary focus is maintaining a local cache of the data in a remote Fever installation.
|
||||
|
||||
I am [soliciting feedback](https://web.archive.org/web/20221221112459/https://feedafever.com/contact) from interested developers and as such the beta API may expand to reflect that feedback. The current API is incomplete but stable. Existing features may be expanded on but will not be removed or modified. New features may be added.
|
||||
|
||||
I’ve created a [simple HTML widget](https://web.archive.org/web/20221221112459/https://feedafever.com/gateway/public/api-widget.html.zip) that allows you to query the Fever API and view the response.
|
||||
|
||||
## Authentication
|
||||
|
||||
Without further ado, the Fever API endpoint URL looks like:
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api
|
||||
```
|
||||
|
||||
All requests must be authenticated with a `POST`ed `api_key`. The value of `api_key` should be the md5 checksum of the Fever accounts email address and password concatenated with a colon. An example of a valid value for `api_key` using PHP’s native `md5()` function:
|
||||
|
||||
```
|
||||
$email = 'you@yourdomain.com';
|
||||
$pass = 'b3stp4s4wd3v4';
|
||||
$api_key = md5($email.':'.$pass);
|
||||
```
|
||||
|
||||
A user may specify that `https` be used to connect to their Fever installation for additional security but you should not assume that all Fever installations support `https`.
|
||||
|
||||
The default response is a JSON object containing two members:
|
||||
|
||||
- `api_version` contains the version of the API responding (positive integer)
|
||||
- `auth` whether the request was successfully authenticated (boolean integer)
|
||||
|
||||
The API can also return XML by passing `xml` as the optional value of the `api` argument like so:
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api=xml
|
||||
```
|
||||
|
||||
The top level XML element is named `response`.
|
||||
|
||||
The response to each successfully authenticated request will have `auth` set to `1` and include at least one additional member:
|
||||
|
||||
- `last_refreshed_on_time` contains the time of the most recently refreshed (not _updated_) feed (Unix timestamp/integer)
|
||||
|
||||
## Read
|
||||
|
||||
When reading from the Fever API you add arguments to the query string of the API endpoint URL. If you attempt to `POST` these arguments (and their optional values) Fever will not recognize the request.
|
||||
|
||||
### Groups
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&groups
|
||||
```
|
||||
|
||||
A request with the `groups` argument will return two additional members:
|
||||
|
||||
- `groups` contains an array of `group` objects
|
||||
- `feeds_groups` contains an array of `feeds_group` objects
|
||||
|
||||
A `group` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `title` (utf-8 string)
|
||||
|
||||
The `feeds_group` object is documented under “Feeds/Groups Relationships.”
|
||||
|
||||
The “Kindling” super group is not included in this response and is composed of all feeds with an `is_spark` equal to `0`. The “Sparks” super group is not included in this response and is composed of all feeds with an `is_spark` equal to `1`.
|
||||
|
||||
### Feeds
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&feeds
|
||||
```
|
||||
|
||||
A request with the `feeds` argument will return two additional members:
|
||||
|
||||
- `feeds` contains an array of `group` objects
|
||||
- `feeds_groups` contains an array of `feeds_group` objects
|
||||
|
||||
A `feed` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `favicon_id` (positive integer)
|
||||
- `title` (utf-8 string)
|
||||
- `url` (utf-8 string)
|
||||
- `site_url` (utf-8 string)
|
||||
- `is_spark` (boolean integer)
|
||||
- `last_updated_on_time` (Unix timestamp/integer)
|
||||
|
||||
The `feeds_group` object is documented under “Feeds/Groups Relationships.”
|
||||
|
||||
The “All Items” super feed is not included in this response and is composed of all items from all feeds that belong to a given group. For the “Kindling” super group and all user created groups the items should be limited to feeds with an `is_spark` equal to `0`. For the “Sparks” super group the items should be limited to feeds with an `is_spark` equal to `1`.
|
||||
|
||||
### Feeds/Groups Relationships
|
||||
|
||||
A request with either the `groups` or `feeds` arguments will return an additional member:
|
||||
|
||||
A `feeds_group` object has the following members:
|
||||
|
||||
- `group_id` (positive integer)
|
||||
- `feed_ids` (string/comma-separated list of positive integers)
|
||||
|
||||
### Favicons
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&favicons
|
||||
```
|
||||
|
||||
A request with the `favicons` argument will return one additional member:
|
||||
|
||||
- `favicons` contains an array of `favicon` objects
|
||||
|
||||
A `favicon` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `data` (base64 encoded image data; prefixed by image type)
|
||||
|
||||
An example `data` value:
|
||||
|
||||
```
|
||||
image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==
|
||||
```
|
||||
|
||||
The `data` member of a `favicon` object can be used with the `data:` protocol to embed an image in CSS or HTML. A PHP/HTML example:
|
||||
|
||||
```
|
||||
echo '<img src="data:'.$favicon['data'].'">';
|
||||
```
|
||||
|
||||
### Items
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&items
|
||||
```
|
||||
|
||||
A request with the `items` argument will return two additional members:
|
||||
|
||||
- `items` contains an array of `item` objects
|
||||
- `total_items` contains the total number of items stored in the database (added in API version 2)
|
||||
|
||||
An `item` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `feed_id` (positive integer)
|
||||
- `title` (utf-8 string)
|
||||
- `author` (utf-8 string)
|
||||
- `html` (utf-8 string)
|
||||
- `url` (utf-8 string)
|
||||
- `is_saved` (boolean integer)
|
||||
- `is_read` (boolean integer)
|
||||
- `created_on_time` (Unix timestamp/integer)
|
||||
|
||||
Most servers won’t have enough memory allocated to PHP to dump all items at once. Three optional arguments control determine the items included in the response.
|
||||
|
||||
- Use the `since_id` argument with the highest id of locally cached items to request 50 additional items. Repeat until the `items` array in the response is empty.
|
||||
|
||||
- Use the `max_id` argument with the lowest id of locally cached items (or `0` initially) to request 50 previous items. Repeat until the `items` array in the response is empty. (added in API version 2)
|
||||
|
||||
- Use the `with_ids` argument with a comma-separated list of item ids to request (a maximum of 50) specific items. (added in API version 2)
|
||||
|
||||
|
||||
### Hot Links
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&links
|
||||
```
|
||||
|
||||
A request with the `links` argument will return one additional member:
|
||||
|
||||
- `links` contains an array of `link` objects
|
||||
|
||||
A `link` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `feed_id` (positive integer) only use when `is_item` equals `1`
|
||||
- `item_id` (positive integer) only use when `is_item` equals `1`
|
||||
- `temperature` (positive float)
|
||||
- `is_item` (boolean integer)
|
||||
- `is_local` (boolean integer) used to determine if the source feed and favicon should be displayed
|
||||
- `is_saved` (boolean integer) only use when `is_item` equals `1`
|
||||
- `title` (utf-8 string)
|
||||
- `url` (utf-8 string)
|
||||
- `item_ids` (string/comma-separated list of positive integers)
|
||||
|
||||
When requesting hot links you can control the range and offset by specifying a length of days for each as well as a page to fetch additional hot links. A request with just the `links` argument is equivalent to:
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&links&offset=0&range=7&page=1
|
||||
```
|
||||
|
||||
Or the first page (`page=1`) of Hot links for the past week (`range=7`) starting now (`offset=0`).
|
||||
|
||||
### Link Caveats
|
||||
|
||||
Fever calculates Hot link temperatures in real-time. The API assumes you have an up-to-date local cache of items, feeds and favicons with which to construct a meaningful Hot view. Because they are ephemeral Hot links should not be cached in the same relational manner as items, feeds, groups and favicons.
|
||||
|
||||
Because Fever saves items and not individual links you can only "save" a Hot link when `is_item` equals `1`.
|
||||
|
||||
## Sync
|
||||
|
||||
The `unread_item_ids` and `saved_item_ids` arguments can be used to keep your local cache synced with the remote Fever installation.
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&unread_item_ids
|
||||
```
|
||||
|
||||
A request with the `unread_item_ids` argument will return one additional member:
|
||||
|
||||
- `unread_item_ids` (string/comma-separated list of positive integers)
|
||||
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&saved_item_ids
|
||||
```
|
||||
|
||||
A request with the `saved_item_ids` argument will return one additional member:
|
||||
|
||||
- `saved_item_ids` (string/comma-separated list of positive integers)
|
||||
|
||||
One of these members will be returned as appropriate when marking an item as read, unread, saved, or unsaved and when marking a feed or group as read.
|
||||
|
||||
Because groups and feeds will be limited in number compared to items, they should be synced by comparing an array of locally cached feed or group ids to an array of feed or group ids returned by their respective API request.
|
||||
|
||||
## Write
|
||||
|
||||
The public beta of the API does not provide a way to add, edit or delete feeds or groups but you can mark items, feeds and groups as read and save or unsave items. You can also unread recently read items. When writing to the Fever API you add arguments to the `POST` data you submit to the API endpoint URL.
|
||||
|
||||
Adding `unread_recently_read=1` to your `POST` data will mark recently read items as unread.
|
||||
|
||||
You can update an individual item by adding the following three arguments to your `POST` data:
|
||||
|
||||
- `mark=item`
|
||||
- `as=?` where `?` is replaced with `read`, `saved` or `unsaved`
|
||||
- `id=?` where `?` is replaced with the `id` of the item to modify
|
||||
|
||||
Marking a feed or group as read is similar but requires one additional argument to prevent marking new, unreceived items as read:
|
||||
|
||||
- `mark=?` where `?` is replaced with `feed` or `group`
|
||||
- `as=read`
|
||||
- `id=?` where `?` is replaced with the `id` of the feed or group to modify
|
||||
- `before=?` where `?` is replaced with the Unix timestamp of the the local client’s most recent `items` API request
|
||||
|
||||
You can mark the “Kindling” super group (and the “Sparks” super group) as read by adding the following four arguments to your `POST` data:
|
||||
|
||||
- `mark=group`
|
||||
- `as=read`
|
||||
- `id=0`
|
||||
- `before=?` where `?` is replaced with the Unix timestamp of the the local client’s last `items` API request
|
||||
|
||||
Similarly you can mark just the “Sparks” super group as read by adding the following four arguments to your `POST` data:
|
||||
|
||||
- `mark=group`
|
||||
- `as=read`
|
||||
- `id=-1`
|
||||
- `before=?` where `?` is replaced with the Unix timestamp of the the local client’s last `items` API request
|
||||
1755
doc/fever-api.mhtml
Normal file
@@ -16,11 +16,6 @@ The licenses are included, and the authorship comments are left intact.
|
||||
- allowed uri schemes
|
||||
- added svg whitelist
|
||||
|
||||
- systray
|
||||
https://github.com/getlantern/systray (commit:2c0986d) Apache 2.0
|
||||
|
||||
removed golog dependency
|
||||
|
||||
- fixconsole
|
||||
https://github.com/apenwarr/fixconsole (commit:5a9f648) Apache 2.0
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Name=yarr
|
||||
Exec=$HOME/.local/bin/yarr -open
|
||||
Icon=yarr
|
||||
Type=Application
|
||||
Categories=Internet;
|
||||
Categories=Internet;Network;News;Feed;
|
||||
END
|
||||
|
||||
if [[ ! -d "$HOME/.local/share/icons" ]]; then
|
||||
|
||||
BIN
etc/promo.png
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 335 KiB |
8
go.mod
@@ -5,9 +5,13 @@ go 1.23.0
|
||||
toolchain go1.23.5
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
golang.org/x/net v0.37.0
|
||||
golang.org/x/net v0.38.0
|
||||
golang.org/x/sys v0.31.0
|
||||
)
|
||||
|
||||
require golang.org/x/text v0.23.0 // indirect
|
||||
require (
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
)
|
||||
|
||||
14
go.sum
@@ -1,14 +1,12 @@
|
||||
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
|
||||
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
|
||||
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
|
||||
5
makefile
@@ -1,10 +1,11 @@
|
||||
VERSION=2.5
|
||||
VERSION=2.6
|
||||
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
||||
|
||||
GO_TAGS = sqlite_foreign_keys sqlite_json
|
||||
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||
|
||||
GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
|
||||
GO_FLAGS_DEBUG = -tags "$(GO_TAGS) debug"
|
||||
GO_FLAGS_GUI = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS)"
|
||||
GO_FLAGS_GUI_WIN = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS) -H windowsgui"
|
||||
|
||||
@@ -75,7 +76,7 @@ windows_arm64_gui: src/platform/versioninfo.rc
|
||||
GOOS=windows GOARCH=arm64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
|
||||
|
||||
serve:
|
||||
go run $(GO_FLAGS) ./cmd/yarr -db local.db
|
||||
go run $(GO_FLAGS_DEBUG) ./cmd/yarr -db local.db
|
||||
|
||||
test:
|
||||
go test $(GO_FLAGS) ./...
|
||||
|
||||
20
readme.md
@@ -9,21 +9,21 @@ The app is a single binary with an embedded database (SQLite).
|
||||
|
||||
## usage
|
||||
|
||||
The latest prebuilt binaries for Linux/MacOS/Windows AMD64 are available
|
||||
[here](https://github.com/nkanaev/yarr/releases/latest). Installation instructions:
|
||||
The latest prebuilt binaries for Linux/MacOS/Windows are available
|
||||
[here](https://github.com/nkanaev/yarr/releases/latest).
|
||||
The archives follow the naming convention `yarr_{OS}_{ARCH}[_gui].zip`, where:
|
||||
|
||||
* MacOS
|
||||
* `OS` is the target operating system
|
||||
* `ARCH` is the CPU architecture (`arm64` for AArch64, `amd64` for X86-64)
|
||||
* `-gui` indicates that the binary ships with the GUI (tray icon), and is a command line application if omitted
|
||||
|
||||
Download `yarr-*-macos64.zip`, unzip it, place `yarr.app` in `/Applications` folder, [open the app][macos-open], click the anchor menu bar icon, select "Open".
|
||||
Usage instructions:
|
||||
|
||||
* Windows
|
||||
* MacOS: place `yarr.app` in `/Applications` folder, [open the app][macos-open], click the anchor menu bar icon, select "Open".
|
||||
|
||||
Download `yarr-*-windows64.zip`, unzip it, open `yarr.exe`, click the anchor system tray icon, select "Open".
|
||||
* Windows: open `yarr.exe`, click the anchor system tray icon, select "Open".
|
||||
|
||||
* Linux
|
||||
|
||||
Download `yarr-*-linux64.zip`, unzip it, place `yarr` in `$HOME/.local/bin`
|
||||
and run [the script](etc/install-linux.sh).
|
||||
* Linux: place `yarr` in `$HOME/.local/bin` and run [the script](etc/install-linux.sh).
|
||||
|
||||
[macos-open]: https://support.apple.com/en-gb/guide/mac-help/mh40616/mac
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
@@ -37,7 +36,7 @@ func Template(path string) *template.Template {
|
||||
}
|
||||
defer svgfile.Close()
|
||||
|
||||
content, err := ioutil.ReadAll(svgfile)
|
||||
content, err := io.ReadAll(svgfile)
|
||||
// should never happen
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -52,7 +51,7 @@ func Template(path string) *template.Template {
|
||||
return tmpl
|
||||
}
|
||||
|
||||
func Render(path string, writer io.Writer, data interface{}) {
|
||||
func Render(path string, writer io.Writer, data any) {
|
||||
tmpl := Template(path)
|
||||
tmpl.Execute(writer, data)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !debug
|
||||
|
||||
package assets
|
||||
|
||||
import "embed"
|
||||
|
||||
1
src/assets/graphicarts/chevron-down.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
||||
|
After Width: | Height: | Size: 269 B |
1
src/assets/graphicarts/chevron-up.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-up"><polyline points="18 15 12 9 6 15"></polyline></svg>
|
||||
|
After Width: | Height: | Size: 268 B |
@@ -8,6 +8,7 @@
|
||||
<link rel="icon" href="./static/graphicarts/favicon.svg" type="image/svg+xml">
|
||||
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
|
||||
<link rel="manifest" href="./manifest.json" />
|
||||
<meta name="theme-color" content="" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<script>
|
||||
window.app = window.app || {}
|
||||
@@ -23,46 +24,46 @@
|
||||
<div class="p-2 toolbar d-flex align-items-center">
|
||||
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
|
||||
<div class="flex-grow-1"></div>
|
||||
<button class="toolbar-item"
|
||||
<button class="toolbar-item ml-1"
|
||||
:class="{active: filterSelected == 'unread'}"
|
||||
:aria-pressed="filterSelected == 'unread'"
|
||||
title="Unread"
|
||||
:title="$t('unread')"
|
||||
@click="filterSelected = 'unread'">
|
||||
<span class="icon">{% inline "circle-full.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item"
|
||||
<button class="toolbar-item mx-1"
|
||||
:class="{active: filterSelected == 'starred'}"
|
||||
:aria-pressed="filterSelected == 'starred'"
|
||||
title="Starred"
|
||||
:title="$t('starred')"
|
||||
@click="filterSelected = 'starred'">
|
||||
<span class="icon">{% inline "star-full.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item"
|
||||
<button class="toolbar-item mr-1"
|
||||
:class="{active: filterSelected == ''}"
|
||||
:aria-pressed="filterSelected == ''"
|
||||
title="All"
|
||||
:title="$t('all')"
|
||||
@click="filterSelected = ''">
|
||||
<span class="icon">{% inline "assorted.svg" %}</span>
|
||||
</button>
|
||||
<div class="flex-grow-1"></div>
|
||||
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" title="Settings">
|
||||
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" :title="$t('settings')">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</template>
|
||||
|
||||
<button class="dropdown-item" @click="showSettings('create')">
|
||||
<span class="icon mr-1">{% inline "plus.svg" %}</span>
|
||||
New Feed
|
||||
{{ $t('new_feed') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" @click="fetchAllFeeds()">
|
||||
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
|
||||
Refresh Feeds
|
||||
{{ $t('refresh_feeds') }}
|
||||
</button>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header" role="heading" aria-level="2">Theme</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('theme') }}</header>
|
||||
<div class="row text-center m-0">
|
||||
<button class="btn btn-link col-4 px-0 rounded-0"
|
||||
:class="'theme-'+t"
|
||||
@@ -76,25 +77,33 @@
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header" role="heading" aria-level="2">Auto Refresh</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('auto_refresh') }}</header>
|
||||
<div class="row text-center m-0">
|
||||
<button class="dropdown-item col-4 px-0" :aria-pressed="!refreshRate" :class="{active: !refreshRate}" @click.stop="refreshRate = 0">0</button>
|
||||
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 10" :class="{active: refreshRate == 10}" @click.stop="refreshRate = 10">10m</button>
|
||||
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 30" :class="{active: refreshRate == 30}" @click.stop="refreshRate = 30">30m</button>
|
||||
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 60" :class="{active: refreshRate == 60}" @click.stop="refreshRate = 60">1h</button>
|
||||
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 120" :class="{active: refreshRate == 120}" @click.stop="refreshRate = 120">2h</button>
|
||||
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 240" :class="{active: refreshRate == 240}" @click.stop="refreshRate = 240">4h</button>
|
||||
<button class="dropdown-item col-4 px-0"
|
||||
@click.stop="changeRefreshRate(-1)"
|
||||
:disabled="!refreshRate">
|
||||
<span class="icon">
|
||||
{% inline "chevron-down.svg" %}
|
||||
</span>
|
||||
</button>
|
||||
<div class="col-4 d-flex align-items-center justify-content-center">{{ refreshRateTitle }}</div>
|
||||
<button class="dropdown-item col-4 px-0"
|
||||
@click.stop="changeRefreshRate(1)" :disabled="refreshRate === refreshRateOptions.at(-1).value">
|
||||
<span class="icon">
|
||||
{% inline "chevron-up.svg" %}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header" role="heading" aria-level="2">Show first</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('show_first') }}</header>
|
||||
<div class="d-flex text-center">
|
||||
<button class="dropdown-item px-0" :aria-pressed="itemSortNewestFirst" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">New</button>
|
||||
<button class="dropdown-item px-0" :aria-pressed="!itemSortNewestFirst" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">Old</button>
|
||||
<button class="dropdown-item px-0" :aria-pressed="itemSortNewestFirst" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">{{ $t('new') }}</button>
|
||||
<button class="dropdown-item px-0" :aria-pressed="!itemSortNewestFirst" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">{{ $t('old') }}</button>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">Subscriptions</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('subscriptions') }}</header>
|
||||
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
|
||||
<input type="file"
|
||||
id="opml-import"
|
||||
@@ -103,42 +112,49 @@
|
||||
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
|
||||
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
|
||||
<span class="icon mr-1">{% inline "download.svg" %}</span>
|
||||
Import
|
||||
{{ $t('import') }}
|
||||
</label>
|
||||
</form>
|
||||
<a class="dropdown-item" href="./opml/export">
|
||||
<span class="icon mr-1">{% inline "upload.svg" %}</span>
|
||||
Export
|
||||
{{ $t('export') }}
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" @click="showSettings('shortcuts')">
|
||||
<span class="icon mr-1">{% inline "help-circle.svg" %}</span>
|
||||
Shortcuts
|
||||
{{ $t('shortcuts') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">ᚨ / 𐎠 / 𑖀</header>
|
||||
<button
|
||||
v-for="lang in languages"
|
||||
class="dropdown-item"
|
||||
:class="{active: language==lang.code}"
|
||||
@click.stop="changeLanguage(lang.code)">
|
||||
{{ lang.name }}
|
||||
</button>
|
||||
<div class="dropdown-divider" v-if="authenticated"></div>
|
||||
<button class="dropdown-item" v-if="authenticated" @click="logout()">
|
||||
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
|
||||
Log out
|
||||
{{ $t('log_out') }}
|
||||
</button>
|
||||
</dropdown>
|
||||
</div>
|
||||
<div id="feed-list-scroll" class="p-2 overflow-auto border-top flex-grow-1">
|
||||
<div id="feed-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1">
|
||||
<label class="selectgroup">
|
||||
<input type="radio" name="feed" value="" v-model="feedSelected">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||
<span class="icon mr-2">{% inline "layers.svg" %}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">All Unread</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">All Starred</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">All Feeds</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">{{ $t('all_unread') }}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">{{ $t('all_starred') }}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">{{ $t('all_feeds') }}</span>
|
||||
<span class="counter text-right">{{ filteredTotalStats }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div v-for="folder in foldersWithFeeds">
|
||||
<label class="selectgroup mt-1"
|
||||
:class="{'d-none': filterSelected
|
||||
&& !(current.folder.id == folder.id || current.feed.folder_id == folder.id)
|
||||
&& !filteredFolderStats[folder.id]
|
||||
&& (!itemSelectedDetails || (feedsById[itemSelectedDetails.feed_id] || {}).folder_id != folder.id)}">
|
||||
:class="{'d-none': mustHideFolder(folder)}"
|
||||
v-if="folder.id">
|
||||
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
|
||||
<span class="icon mr-2"
|
||||
@@ -152,10 +168,7 @@
|
||||
</label>
|
||||
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
|
||||
<label class="selectgroup"
|
||||
:class="{'d-none': filterSelected
|
||||
&& !(current.feed.id == feed.id)
|
||||
&& !filteredFeedStats[feed.id]
|
||||
&& (!itemSelectedDetails || itemSelectedDetails.feed_id != feed.id)}"
|
||||
:class="{'d-none': mustHideFeed(feed)}"
|
||||
v-for="feed in folder.feeds">
|
||||
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||
@@ -175,7 +188,7 @@
|
||||
</div>
|
||||
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
|
||||
<span class="icon loading mx-2"></span>
|
||||
<span class="text-truncate cursor-default noselect">Refreshing ({{ loading.feeds }} left)</span>
|
||||
<span class="text-truncate cursor-default noselect">{{ $t('refreshing') }} ({{ loading.feeds }} {{ $t('left') }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- item list -->
|
||||
@@ -184,7 +197,7 @@
|
||||
<div class="px-2 toolbar d-flex align-items-center">
|
||||
<button class="toolbar-item mr-2 d-block d-md-none"
|
||||
@click="feedSelected = null"
|
||||
title="Show Feeds">
|
||||
:title="$t('show_feeds')">
|
||||
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||
</button>
|
||||
<div class="input-icon flex-grow-1">
|
||||
@@ -195,7 +208,7 @@
|
||||
<button class="toolbar-item ml-2"
|
||||
@click="markItemsRead()"
|
||||
v-if="filterSelected == 'unread'"
|
||||
title="Mark All Read">
|
||||
:title="$t('mark_all_read')">
|
||||
<span class="icon">{% inline "check.svg" %}</span>
|
||||
</button>
|
||||
|
||||
@@ -206,7 +219,7 @@
|
||||
<dropdown class="settings-dropdown"
|
||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||
drop="right"
|
||||
title="Feed Settings"
|
||||
:title="$t('feed_settings')"
|
||||
v-if="current.type == 'feed'">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
@@ -214,23 +227,23 @@
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.feed.title }}</header>
|
||||
<a class="dropdown-item" :href="current.feed.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.link">
|
||||
<span class="icon mr-1">{% inline "globe.svg" %}</span>
|
||||
Website
|
||||
{{ $t('website') }}
|
||||
</a>
|
||||
<a class="dropdown-item" :href="current.feed.feed_link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.feed_link">
|
||||
<span class="icon mr-1">{% inline "rss.svg" %}</span>
|
||||
Feed Link
|
||||
{{ $t('feed_link') }}
|
||||
</a>
|
||||
<div class="dropdown-divider" v-if="current.feed.link || current.feed.feed_link"></div>
|
||||
<button class="dropdown-item" @click="renameFeed(current.feed)">
|
||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||
Rename
|
||||
{{ $t('rename') }}
|
||||
</button>
|
||||
<button class="dropdown-item" @click="updateFeedLink(current.feed)" v-if="current.feed.feed_link">
|
||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||
Change Link
|
||||
{{ $t('change_link') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">Move to...</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('move_to') }}</header>
|
||||
<button class="dropdown-item"
|
||||
v-if="folder.id != current.feed.folder_id"
|
||||
v-for="folder in folders"
|
||||
@@ -244,17 +257,17 @@
|
||||
</button>
|
||||
<button class="dropdown-item text-muted" @click="moveFeedToNewFolder(current.feed)">
|
||||
<span class="icon mr-1">{% inline "folder-plus.svg" %}</span>
|
||||
new folder
|
||||
{{ $t('new_folder') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @click.prevent="deleteFeed(current.feed)">
|
||||
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
||||
Delete
|
||||
{{ $t('delete') }}
|
||||
</button>
|
||||
</dropdown>
|
||||
<dropdown class="settings-dropdown"
|
||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||
title="Folder Settings"
|
||||
:title="$t('folder_settings')"
|
||||
drop="right"
|
||||
v-if="current.type == 'folder'">
|
||||
<template v-slot:button>
|
||||
@@ -263,21 +276,21 @@
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.folder.title }}</header>
|
||||
<button class="dropdown-item" @click="renameFolder(current.folder)">
|
||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||
Rename
|
||||
{{ $t('rename') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @click="deleteFolder(current.folder)">
|
||||
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
||||
Delete
|
||||
{{ $t('delete') }}
|
||||
</button>
|
||||
</dropdown>
|
||||
</div>
|
||||
<div id="item-list-scroll" class="p-2 overflow-auto border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
|
||||
<div id="item-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
|
||||
<label v-for="item in items" :key="item.id"
|
||||
class="selectgroup">
|
||||
<input type="radio" name="item" :value="item.id" v-model="itemSelected">
|
||||
<div class="selectgroup-label d-flex flex-column">
|
||||
<div style="line-height: 1; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
|
||||
<div style="line-height: 100%; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
|
||||
<transition name="indicator">
|
||||
<span class="icon icon-small mr-1" v-if="item.status=='unread'">{% inline "circle-full.svg" %}</span>
|
||||
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
|
||||
@@ -287,7 +300,7 @@
|
||||
</small>
|
||||
<small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
|
||||
</div>
|
||||
<div>{{ item.title || 'untitled' }}</div>
|
||||
<div>{{ item.title || $t('untitled') }}</div>
|
||||
</div>
|
||||
</label>
|
||||
<button class="btn btn-link btn-block loading my-3" v-if="itemsHasMore"></button>
|
||||
@@ -301,24 +314,24 @@
|
||||
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
|
||||
<button class="toolbar-item"
|
||||
@click="toggleItemStarred(itemSelectedDetails)"
|
||||
title="Mark Starred">
|
||||
:title="$t('mark_starred')">
|
||||
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
|
||||
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item"
|
||||
title="Mark Unread"
|
||||
:title="$t('mark_unread')"
|
||||
@click="toggleItemRead(itemSelectedDetails)">
|
||||
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
|
||||
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
|
||||
</button>
|
||||
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" title="Appearance">
|
||||
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" :title="$t('appearance')">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "sliders.svg" %}</span>
|
||||
</template>
|
||||
|
||||
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">sans-serif</button>
|
||||
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">serif</button>
|
||||
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">monospace</button>
|
||||
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">{{ $t('sans_serif') }}</button>
|
||||
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">{{ $t('serif') }}</button>
|
||||
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">{{ $t('monospace') }}</button>
|
||||
|
||||
<div class="d-flex text-center">
|
||||
<button class="dropdown-item" style="font-size: 0.8rem" @click.stop="incrFont(-1)">A</button>
|
||||
@@ -328,30 +341,30 @@
|
||||
<button class="toolbar-item"
|
||||
:class="{active: itemSelectedReadability}"
|
||||
@click="toggleReadability()"
|
||||
title="Read Here">
|
||||
:title="$t('read_here')">
|
||||
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
|
||||
</button>
|
||||
<a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" title="Open Link">
|
||||
<a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" :title="$t('open_link')">
|
||||
<span class="icon">{% inline "external-link.svg" %}</span>
|
||||
</a>
|
||||
<div class="flex-grow-1"></div>
|
||||
<button class="toolbar-item" @click="navigateToItem(-1)" title="Previous Article" :disabled="itemSelected == items[0].id">
|
||||
<button class="toolbar-item" @click="navigateToItem(-1)" :title="$t('previous_article')" :disabled="!items.length || itemSelected == items[0].id">
|
||||
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item" @click="navigateToItem(+1)" title="Next Article" :disabled="itemSelected == items[items.length - 1].id">
|
||||
<button class="toolbar-item" @click="navigateToItem(+1)" :title="$t('next_article')" :disabled="!items.length || itemSelected == items[items.length - 1].id">
|
||||
<span class="icon">{% inline "chevron-right.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item" @click="itemSelected=null" title="Close Article">
|
||||
<button class="toolbar-item" @click="itemSelected=null" :title="$t('close_article')">
|
||||
<span class="icon">{% inline "x.svg" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="itemSelectedDetails"
|
||||
ref="content"
|
||||
class="content px-4 pt-3 pb-5 border-top overflow-auto"
|
||||
class="content px-4 pt-3 pb-5 border-top overflow-auto scroll-touch"
|
||||
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
|
||||
:style="{'font-size': theme.size + 'rem'}">
|
||||
<div class="content-wrapper">
|
||||
<h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1>
|
||||
<h1><b>{{ itemSelectedDetails.title || $t('untitled') }}</b></h1>
|
||||
<div class="text-muted">
|
||||
<div>
|
||||
<span class="cursor-pointer" @click="feedSelected = 'feed:'+(feedsById[itemSelectedDetails.feed_id] || {}).id">
|
||||
@@ -380,13 +393,13 @@
|
||||
<span class="icon">{% inline "x.svg" %}</span>
|
||||
</button>
|
||||
<div v-if="settings=='create'">
|
||||
<p class="cursor-default"><b>New Feed</b></p>
|
||||
<p class="cursor-default"><b>{{ $t('new_feed') }}</b></p>
|
||||
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
|
||||
<label for="feed-url">URL</label>
|
||||
<label for="feed-url">{{ $t('url') }}</label>
|
||||
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0" placeholder="https://example.com/feed" v-focus>
|
||||
<label for="feed-folder" class="mt-3 d-block">
|
||||
Folder
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
|
||||
{{ $t('folder') }}
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">{{ $t('new_folder') }}</a>
|
||||
</label>
|
||||
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
|
||||
<option value="">---</option>
|
||||
@@ -394,8 +407,8 @@
|
||||
</select>
|
||||
<div class="mt-4" v-if="feedNewChoice.length">
|
||||
<p class="mb-2">
|
||||
Multiple feeds found. Choose one below:
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">cancel</a>
|
||||
{{ $t('multiple_feeds_found') }}
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">{{ $t('cancel') }}</a>
|
||||
</p>
|
||||
<label class="selectgroup" v-for="choice in feedNewChoice">
|
||||
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
|
||||
@@ -405,28 +418,29 @@
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">Add</button>
|
||||
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">{{ $t('add') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else-if="settings=='shortcuts'">
|
||||
<p class="cursor-default"><b>Keyboard Shortcuts</b></p>
|
||||
<p class="cursor-default"><b>{{ $t('keyboard_shortcuts') }}</b></p>
|
||||
|
||||
<table class="table table-borderless table-sm table-compact m-0">
|
||||
<tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td>
|
||||
<td>show unread / starred / all feeds</td></tr>
|
||||
<tr><td><kbd>/</kbd></td> <td>focus the search bar</td></tr>
|
||||
<td>{{ $t('kb_show_filters') }}</td></tr>
|
||||
<tr><td><kbd>/</kbd></td> <td>{{ $t('kb_focus_search') }}</td></tr>
|
||||
|
||||
<tr><td colspan=2> </td></tr>
|
||||
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>next / prev article</td></tr>
|
||||
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
|
||||
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>{{ $t('kb_next_prev_article') }}</td></tr>
|
||||
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>{{ $t('kb_next_prev_feed') }}</td></tr>
|
||||
<tr><td><kbd>q</kbd></td> <td>{{ $t('kb_close_article') }}</td></tr>
|
||||
|
||||
<tr><td colspan=2> </td></tr>
|
||||
<tr><td><kbd>R</kbd></td> <td>mark all read</td></tr>
|
||||
<tr><td><kbd>r</kbd></td> <td>mark read / unread</td></tr>
|
||||
<tr><td><kbd>s</kbd></td> <td>mark starred / unstarred</td></tr>
|
||||
<tr><td><kbd>o</kbd></td> <td>open link</td></tr>
|
||||
<tr><td><kbd>i</kbd></td> <td>read here</td> </tr>
|
||||
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>scroll content forward / backward</td>
|
||||
<tr><td><kbd>R</kbd></td> <td>{{ $t('kb_mark_all_read') }}</td></tr>
|
||||
<tr><td><kbd>r</kbd></td> <td>{{ $t('kb_mark_read') }}</td></tr>
|
||||
<tr><td><kbd>s</kbd></td> <td>{{ $t('kb_mark_starred') }}</td></tr>
|
||||
<tr><td><kbd>o</kbd></td> <td>{{ $t('kb_open_link') }}</td></tr>
|
||||
<tr><td><kbd>i</kbd></td> <td>{{ $t('kb_read_here') }}</td> </tr>
|
||||
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>{{ $t('kb_scroll_content') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -435,6 +449,7 @@
|
||||
<!-- external -->
|
||||
<script src="./static/javascripts/vue.min.js"></script>
|
||||
<!-- internal -->
|
||||
<script src="./static/javascripts/i18n.js"></script>
|
||||
<script src="./static/javascripts/api.js"></script>
|
||||
<script src="./static/javascripts/app.js"></script>
|
||||
<script src="./static/javascripts/key.js"></script>
|
||||
|
||||
@@ -202,6 +202,8 @@ Vue.component('relative-time', {
|
||||
},
|
||||
})
|
||||
|
||||
Vue.use(i18n)
|
||||
|
||||
var vm = new Vue({
|
||||
created: function() {
|
||||
this.refreshStats()
|
||||
@@ -211,6 +213,8 @@ var vm = new Vue({
|
||||
api.feeds.list_errors().then(function(errors) {
|
||||
vm.feed_errors = errors
|
||||
})
|
||||
this.updateMetaTheme(app.settings.theme_name)
|
||||
this.$setLang(app.settings.language)
|
||||
},
|
||||
data: function() {
|
||||
var s = app.settings
|
||||
@@ -249,9 +253,32 @@ var vm = new Vue({
|
||||
'font': s.theme_font,
|
||||
'size': s.theme_size,
|
||||
},
|
||||
'themeColors': {
|
||||
'night': '#0e0e0e',
|
||||
'sepia': '#f4f0e5',
|
||||
'light': '#fff',
|
||||
},
|
||||
'refreshRate': s.refresh_rate,
|
||||
'authenticated': app.authenticated,
|
||||
'feed_errors': {},
|
||||
|
||||
'refreshRateOptions': [
|
||||
{ title: "0", value: 0 },
|
||||
{ title: "10m", value: 10 },
|
||||
{ title: "30m", value: 30 },
|
||||
{ title: "1h", value: 60 },
|
||||
{ title: "2h", value: 120 },
|
||||
{ title: "4h", value: 240 },
|
||||
{ title: "12h", value: 720 },
|
||||
{ title: "24h", value: 1440 },
|
||||
],
|
||||
|
||||
'language': s.language,
|
||||
'languages': [
|
||||
{code: 'en', name: 'English' },
|
||||
{code: 'zh', name: '简体中文'},
|
||||
{code: 'ru', name: 'Русский'},
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -309,12 +336,17 @@ var vm = new Vue({
|
||||
contentVideos: function() {
|
||||
if (!this.itemSelectedDetails) return []
|
||||
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'video')
|
||||
}
|
||||
},
|
||||
refreshRateTitle: function () {
|
||||
const entry = this.refreshRateOptions.find(o => o.value === this.refreshRate)
|
||||
return entry ? entry.title : '0'
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'theme': {
|
||||
deep: true,
|
||||
handler: function(theme) {
|
||||
this.updateMetaTheme(theme.name)
|
||||
document.body.classList.value = 'theme-' + theme.name
|
||||
api.settings.update({
|
||||
theme_name: theme.name,
|
||||
@@ -339,14 +371,18 @@ var vm = new Vue({
|
||||
},
|
||||
'filterSelected': function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this, false))
|
||||
this.itemSelected = null
|
||||
this.items = []
|
||||
this.itemsHasMore = true
|
||||
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this, false))
|
||||
this.computeStats()
|
||||
},
|
||||
'feedSelected': function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this, false))
|
||||
this.itemSelected = null
|
||||
this.items = []
|
||||
this.itemsHasMore = true
|
||||
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this, false))
|
||||
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
|
||||
},
|
||||
'itemSelected': function(newVal, oldVal) {
|
||||
@@ -390,6 +426,9 @@ var vm = new Vue({
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateMetaTheme: function(theme) {
|
||||
document.querySelector("meta[name='theme-color']").content = this.themeColors[theme]
|
||||
},
|
||||
refreshStats: function(loopMode) {
|
||||
return api.status().then(function(data) {
|
||||
if (loopMode && !vm.itemSelected) vm.refreshItems()
|
||||
@@ -753,9 +792,18 @@ var vm = new Vue({
|
||||
// navigation helper, navigate relative to selected feed
|
||||
navigateToFeed: function(relativePosition) {
|
||||
let vm = this
|
||||
var navigationList = Array.from(document.querySelectorAll('#col-feed-list input[name=feed]'))
|
||||
.filter(function(r) { return r.offsetParent !== null && r.value !== 'folder:null' })
|
||||
.map(function(r) { return r.value })
|
||||
const navigationList = this.foldersWithFeeds
|
||||
.filter(folder => !folder.id || !vm.mustHideFolder(folder))
|
||||
.map((folder) => {
|
||||
if (this.mustHideFolder(folder)) return []
|
||||
const folds = folder.id ? [`folder:${folder.id}`] : []
|
||||
const feeds = (folder.is_expanded || !folder.id)
|
||||
? (folder.feeds || []).filter(f => !vm.mustHideFeed(f)).map(f => `feed:${f.id}`)
|
||||
: []
|
||||
return folds.concat(feeds)
|
||||
})
|
||||
.flat()
|
||||
navigationList.unshift('')
|
||||
|
||||
var currentFeedPosition = navigationList.indexOf(vm.feedSelected)
|
||||
|
||||
@@ -778,6 +826,29 @@ var vm = new Vue({
|
||||
if (target && scroll) scrollto(target, scroll)
|
||||
})
|
||||
},
|
||||
changeRefreshRate: function(offset) {
|
||||
const curIdx = this.refreshRateOptions.findIndex(o => o.value === this.refreshRate)
|
||||
if (curIdx <= 0 && offset < 0) return
|
||||
if (curIdx >= (this.refreshRateOptions.length - 1) && offset > 0) return
|
||||
this.refreshRate = this.refreshRateOptions[curIdx + offset].value
|
||||
},
|
||||
mustHideFolder: function (folder) {
|
||||
return this.filterSelected
|
||||
&& !(this.current.folder.id == folder.id || this.current.feed.folder_id == folder.id)
|
||||
&& !this.filteredFolderStats[folder.id]
|
||||
&& (!this.itemSelectedDetails || (this.feedsById[this.itemSelectedDetails.feed_id] || {}).folder_id != folder.id)
|
||||
},
|
||||
mustHideFeed: function (feed) {
|
||||
return this.filterSelected
|
||||
&& !(this.current.feed.id == feed.id)
|
||||
&& !this.filteredFeedStats[feed.id]
|
||||
&& (!this.itemSelectedDetails || this.itemSelectedDetails.feed_id != feed.id)
|
||||
},
|
||||
changeLanguage(lang) {
|
||||
this.$setLang(lang)
|
||||
this.language = lang
|
||||
api.settings.update({language: lang})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
372
src/assets/javascripts/i18n.js
Normal file
@@ -0,0 +1,372 @@
|
||||
(function (exports) {
|
||||
const translations = {
|
||||
"unread": {
|
||||
"en": "Unread",
|
||||
"zh": "未读",
|
||||
"ru": "Непрочитанные"
|
||||
},
|
||||
"starred": {
|
||||
"en": "Starred",
|
||||
"zh": "星标",
|
||||
"ru": "Избранные"
|
||||
},
|
||||
"all": {
|
||||
"en": "All",
|
||||
"zh": "全部",
|
||||
"ru": "Все"
|
||||
},
|
||||
"settings": {
|
||||
"en": "Settings",
|
||||
"zh": "设置",
|
||||
"ru": "Настройки"
|
||||
},
|
||||
"new_feed": {
|
||||
"en": "New Feed",
|
||||
"zh": "新建订阅",
|
||||
"ru": "Новая лента"
|
||||
},
|
||||
"refresh_feeds": {
|
||||
"en": "Refresh Feeds",
|
||||
"zh": "刷新订阅",
|
||||
"ru": "Обновить ленты"
|
||||
},
|
||||
"theme": {
|
||||
"en": "Theme",
|
||||
"zh": "主题",
|
||||
"ru": "Тема"
|
||||
},
|
||||
"auto_refresh": {
|
||||
"en": "Auto Refresh",
|
||||
"zh": "自动刷新",
|
||||
"ru": "Автообновление"
|
||||
},
|
||||
"show_first": {
|
||||
"en": "Show first",
|
||||
"zh": "优先显示",
|
||||
"ru": "Сначала"
|
||||
},
|
||||
"new": {
|
||||
"en": "New",
|
||||
"zh": "最新",
|
||||
"ru": "Новые"
|
||||
},
|
||||
"old": {
|
||||
"en": "Old",
|
||||
"zh": "最旧",
|
||||
"ru": "Старые"
|
||||
},
|
||||
"subscriptions": {
|
||||
"en": "Subscriptions",
|
||||
"zh": "订阅管理",
|
||||
"ru": "Подписки"
|
||||
},
|
||||
"import": {
|
||||
"en": "Import",
|
||||
"zh": "导入",
|
||||
"ru": "Импорт"
|
||||
},
|
||||
"export": {
|
||||
"en": "Export",
|
||||
"zh": "导出",
|
||||
"ru": "Экспорт"
|
||||
},
|
||||
"shortcuts": {
|
||||
"en": "Shortcuts",
|
||||
"zh": "快捷键",
|
||||
"ru": "Горячие клавиши"
|
||||
},
|
||||
"log_out": {
|
||||
"en": "Log out",
|
||||
"zh": "登出",
|
||||
"ru": "Выйти"
|
||||
},
|
||||
"all_unread": {
|
||||
"en": "All Unread",
|
||||
"zh": "全部未读",
|
||||
"ru": "Все непрочитанные"
|
||||
},
|
||||
"all_starred": {
|
||||
"en": "All Starred",
|
||||
"zh": "全部星标",
|
||||
"ru": "Все избранные"
|
||||
},
|
||||
"all_feeds": {
|
||||
"en": "All Feeds",
|
||||
"zh": "全部订阅",
|
||||
"ru": "Все ленты"
|
||||
},
|
||||
"refreshing": {
|
||||
"en": "Refreshing",
|
||||
"zh": "正在刷新",
|
||||
"ru": "Обновление"
|
||||
},
|
||||
"left": {
|
||||
"en": "left",
|
||||
"zh": "剩余",
|
||||
"ru": "осталось"
|
||||
},
|
||||
"show_feeds": {
|
||||
"en": "Show Feeds",
|
||||
"zh": "显示订阅",
|
||||
"ru": "Показать ленты"
|
||||
},
|
||||
"mark_all_read": {
|
||||
"en": "Mark All Read",
|
||||
"zh": "全部标记为已读",
|
||||
"ru": "Отметить все как прочитанные"
|
||||
},
|
||||
"feed_settings": {
|
||||
"en": "Feed Settings",
|
||||
"zh": "订阅设置",
|
||||
"ru": "Настройки ленты"
|
||||
},
|
||||
"folder_settings": {
|
||||
"en": "Folder Settings",
|
||||
"zh": "文件夹设置",
|
||||
"ru": "Настройки папки"
|
||||
},
|
||||
"website": {
|
||||
"en": "Website",
|
||||
"zh": "网站",
|
||||
"ru": "Сайт"
|
||||
},
|
||||
"feed_link": {
|
||||
"en": "Feed Link",
|
||||
"zh": "订阅链接",
|
||||
"ru": "Ссылка на ленту"
|
||||
},
|
||||
"rename": {
|
||||
"en": "Rename",
|
||||
"zh": "重命名",
|
||||
"ru": "Переименовать"
|
||||
},
|
||||
"change_link": {
|
||||
"en": "Change Link",
|
||||
"zh": "修改链接",
|
||||
"ru": "Изменить ссылку"
|
||||
},
|
||||
"move_to": {
|
||||
"en": "Move to...",
|
||||
"zh": "移动到...",
|
||||
"ru": "Переместить в..."
|
||||
},
|
||||
"new_folder": {
|
||||
"en": "new folder",
|
||||
"zh": "新建文件夹",
|
||||
"ru": "новая папка"
|
||||
},
|
||||
"delete": {
|
||||
"en": "Delete",
|
||||
"zh": "删除",
|
||||
"ru": "Удалить"
|
||||
},
|
||||
"mark_starred": {
|
||||
"en": "Mark Starred",
|
||||
"zh": "标记星标",
|
||||
"ru": "Пометить избранным"
|
||||
},
|
||||
"mark_unread": {
|
||||
"en": "Mark Unread",
|
||||
"zh": "标记未读",
|
||||
"ru": "Пометить непрочитанным"
|
||||
},
|
||||
"appearance": {
|
||||
"en": "Appearance",
|
||||
"zh": "外观",
|
||||
"ru": "Внешний вид"
|
||||
},
|
||||
"read_here": {
|
||||
"en": "Read Here",
|
||||
"zh": "在此阅读",
|
||||
"ru": "Читать здесь"
|
||||
},
|
||||
"open_link": {
|
||||
"en": "Open Link",
|
||||
"zh": "打开链接",
|
||||
"ru": "Открыть ссылку"
|
||||
},
|
||||
"previous_article": {
|
||||
"en": "Previous Article",
|
||||
"zh": "上一篇",
|
||||
"ru": "Предыдущая статья"
|
||||
},
|
||||
"next_article": {
|
||||
"en": "Next Article",
|
||||
"zh": "下一篇",
|
||||
"ru": "Следующая статья"
|
||||
},
|
||||
"close_article": {
|
||||
"en": "Close Article",
|
||||
"zh": "关闭文章",
|
||||
"ru": "Закрыть статью"
|
||||
},
|
||||
"untitled": {
|
||||
"en": "untitled",
|
||||
"zh": "无标题",
|
||||
"ru": "без названия"
|
||||
},
|
||||
"sans_serif": {
|
||||
"en": "sans-serif",
|
||||
"zh": "无衬线",
|
||||
"ru": "sans-serif"
|
||||
},
|
||||
"serif": {
|
||||
"en": "serif",
|
||||
"zh": "衬线",
|
||||
"ru": "serif"
|
||||
},
|
||||
"monospace": {
|
||||
"en": "monospace",
|
||||
"zh": "等宽",
|
||||
"ru": "monospace"
|
||||
},
|
||||
"url": {
|
||||
"en": "URL",
|
||||
"zh": "网址",
|
||||
"ru": "URL"
|
||||
},
|
||||
"folder": {
|
||||
"en": "Folder",
|
||||
"zh": "文件夹",
|
||||
"ru": "Папка"
|
||||
},
|
||||
"add": {
|
||||
"en": "Add",
|
||||
"zh": "添加",
|
||||
"ru": "Добавить"
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"en": "Keyboard Shortcuts",
|
||||
"zh": "键盘快捷键",
|
||||
"ru": "Горячие клавиши"
|
||||
},
|
||||
"multiple_feeds_found": {
|
||||
"en": "Multiple feeds found. Choose one below:",
|
||||
"zh": "找到多个订阅源,请选择一个:",
|
||||
"ru": "Найдено несколько лент. Выберите одну:"
|
||||
},
|
||||
"cancel": {
|
||||
"en": "cancel",
|
||||
"zh": "取消",
|
||||
"ru": "отмена"
|
||||
},
|
||||
"kb_show_filters": {
|
||||
"en": "show unread / starred / all feeds",
|
||||
"zh": "显示未读/星标/全部订阅",
|
||||
"ru": "показать непрочитанные / избранные / все ленты"
|
||||
},
|
||||
"kb_focus_search": {
|
||||
"en": "focus the search bar",
|
||||
"zh": "聚焦搜索栏",
|
||||
"ru": "фокус на строку поиска"
|
||||
},
|
||||
"kb_next_prev_article": {
|
||||
"en": "next / prev article",
|
||||
"zh": "下一篇/上一篇文章",
|
||||
"ru": "следующая / предыдущая статья"
|
||||
},
|
||||
"kb_next_prev_feed": {
|
||||
"en": "next / prev feed",
|
||||
"zh": "下一个/上一个订阅",
|
||||
"ru": "следующая / предыдущая лента"
|
||||
},
|
||||
"kb_close_article": {
|
||||
"en": "close article",
|
||||
"zh": "关闭文章",
|
||||
"ru": "закрыть статью"
|
||||
},
|
||||
"kb_mark_all_read": {
|
||||
"en": "mark all read",
|
||||
"zh": "全部标记为已读",
|
||||
"ru": "отметить все как прочитанные"
|
||||
},
|
||||
"kb_mark_read": {
|
||||
"en": "mark read / unread",
|
||||
"zh": "标记已读/未读",
|
||||
"ru": "отметить как прочитанное / непрочитанное"
|
||||
},
|
||||
"kb_mark_starred": {
|
||||
"en": "mark starred / unstarred",
|
||||
"zh": "标记星标/取消星标",
|
||||
"ru": "пометить избранным / убрать из избранного"
|
||||
},
|
||||
"kb_open_link": {
|
||||
"en": "open link",
|
||||
"zh": "打开链接",
|
||||
"ru": "открыть ссылку"
|
||||
},
|
||||
"kb_read_here": {
|
||||
"en": "read here",
|
||||
"zh": "在此阅读",
|
||||
"ru": "читать здесь"
|
||||
},
|
||||
"kb_scroll_content": {
|
||||
"en": "scroll content forward / backward",
|
||||
"zh": "向前/向后滚动内容",
|
||||
"ru": "прокрутка вперед / назад"
|
||||
},
|
||||
"prompt_folder_name": {
|
||||
"en": "Enter folder name:",
|
||||
"zh": "请输入文件夹名称:",
|
||||
"ru": "Введите имя папки:"
|
||||
},
|
||||
"prompt_new_title": {
|
||||
"en": "Enter new title",
|
||||
"zh": "请输入新标题",
|
||||
"ru": "Введите новый заголовок"
|
||||
},
|
||||
"prompt_feed_link": {
|
||||
"en": "Enter feed link",
|
||||
"zh": "请输入订阅链接",
|
||||
"ru": "Введите ссылку на ленту"
|
||||
},
|
||||
"confirm_delete_folder": {
|
||||
"en": "Are you sure you want to delete",
|
||||
"zh": "确定要删除",
|
||||
"ru": "Вы уверены, что хотите удалить"
|
||||
},
|
||||
"confirm_delete_feed": {
|
||||
"en": "Are you sure you want to delete",
|
||||
"zh": "确定要删除",
|
||||
"ru": "Вы уверены, что хотите удалить"
|
||||
},
|
||||
"alert_no_feeds": {
|
||||
"en": "No feeds found at the given url.",
|
||||
"zh": "在指定的网址未找到订阅源。",
|
||||
"ru": "Лент по данному адресу не найдено."
|
||||
},
|
||||
"login": {
|
||||
"en": "Login",
|
||||
"zh": "登录",
|
||||
"ru": "Вход"
|
||||
},
|
||||
"username": {
|
||||
"en": "Username",
|
||||
"zh": "用户名",
|
||||
"ru": "Имя пользователя"
|
||||
},
|
||||
"password": {
|
||||
"en": "Password",
|
||||
"zh": "密码",
|
||||
"ru": "Пароль"
|
||||
},
|
||||
};
|
||||
class i18n {
|
||||
constructor() {
|
||||
this.lang = 'en'
|
||||
}
|
||||
setLang(lang) {
|
||||
this.lang = lang
|
||||
}
|
||||
$t(code) {
|
||||
return translations[code][this.lang]
|
||||
}
|
||||
}
|
||||
exports.i18n = {
|
||||
install(Vue, opts) {
|
||||
const x = new i18n();
|
||||
Vue.prototype.$t = x.$t
|
||||
Vue.prototype.$setLang = x.setLang
|
||||
}
|
||||
}
|
||||
})(window)
|
||||
@@ -60,6 +60,9 @@ var shortcutFunctions = {
|
||||
scrollBackward: function() {
|
||||
helperFunctions.scrollContent(-1)
|
||||
},
|
||||
closeItem: function () {
|
||||
vm.itemSelected = null
|
||||
},
|
||||
showAll() {
|
||||
vm.filterSelected = ''
|
||||
},
|
||||
@@ -85,6 +88,7 @@ var keybindings = {
|
||||
"h": shortcutFunctions.previousFeed,
|
||||
"f": shortcutFunctions.scrollForward,
|
||||
"b": shortcutFunctions.scrollBackward,
|
||||
"q": shortcutFunctions.closeItem,
|
||||
"1": shortcutFunctions.showUnread,
|
||||
"2": shortcutFunctions.showStarred,
|
||||
"3": shortcutFunctions.showAll,
|
||||
@@ -103,6 +107,7 @@ var codebindings = {
|
||||
"KeyH": shortcutFunctions.previousFeed,
|
||||
"KeyF": shortcutFunctions.scrollForward,
|
||||
"KeyB": shortcutFunctions.scrollBackward,
|
||||
"KeyQ": shortcutFunctions.closeItem,
|
||||
"Digit1": shortcutFunctions.showUnread,
|
||||
"Digit2": shortcutFunctions.showStarred,
|
||||
"Digit3": shortcutFunctions.showAll,
|
||||
|
||||
@@ -100,6 +100,10 @@ select.form-control:not([multiple]):not([size]) {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.scroll-touch {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* custom elements */
|
||||
|
||||
.font-serif {
|
||||
|
||||
@@ -27,10 +27,16 @@ var (
|
||||
|
||||
blacklistCandidatesRegexp = regexp.MustCompile(`(?i)popupbody|-ad|g-plus`)
|
||||
okMaybeItsACandidateRegexp = regexp.MustCompile(`(?i)and|article|body|column|main|shadow`)
|
||||
unlikelyCandidatesRegexp = regexp.MustCompile(`(?i)banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`)
|
||||
unlikelyCandidatesRegexp = regexp.MustCompile(
|
||||
`(?i)banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`,
|
||||
)
|
||||
|
||||
negativeRegexp = regexp.MustCompile(`(?i)hidden|^hid$|hid$|hid|^hid |banner|combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby|p-author`)
|
||||
positiveRegexp = regexp.MustCompile(`(?i)article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`)
|
||||
negativeRegexp = regexp.MustCompile(
|
||||
`(?i)hidden|^hid$|hid$|hid|^hid |banner|combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby|p-author`,
|
||||
)
|
||||
positiveRegexp = regexp.MustCompile(
|
||||
`(?i)article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`,
|
||||
)
|
||||
)
|
||||
|
||||
type nodeScores map[*html.Node]float32
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -146,7 +147,10 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
||||
}
|
||||
|
||||
attrNames = append(attrNames, attribute.Key)
|
||||
htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)))
|
||||
htmlAttrs = append(
|
||||
htmlAttrs,
|
||||
fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)),
|
||||
)
|
||||
}
|
||||
|
||||
extraAttrNames, extraHTMLAttributes := getExtraAttributes(tagName)
|
||||
@@ -161,11 +165,25 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
||||
func getExtraAttributes(tagName string) ([]string, []string) {
|
||||
switch tagName {
|
||||
case "a":
|
||||
return []string{"rel", "target", "referrerpolicy"}, []string{`rel="noopener noreferrer"`, `target="_blank"`, `referrerpolicy="no-referrer"`}
|
||||
return []string{
|
||||
"rel",
|
||||
"target",
|
||||
"referrerpolicy",
|
||||
}, []string{
|
||||
`rel="noopener noreferrer"`,
|
||||
`target="_blank"`,
|
||||
`referrerpolicy="no-referrer"`,
|
||||
}
|
||||
case "video", "audio":
|
||||
return []string{"controls"}, []string{"controls"}
|
||||
case "iframe":
|
||||
return []string{"sandbox", "loading"}, []string{`sandbox="allow-scripts allow-same-origin allow-popups"`, `loading="lazy"`}
|
||||
return []string{
|
||||
"sandbox",
|
||||
"loading",
|
||||
}, []string{
|
||||
`sandbox="allow-scripts allow-same-origin allow-popups"`,
|
||||
`loading="lazy"`,
|
||||
}
|
||||
case "img":
|
||||
return []string{"loading"}, []string{`loading="lazy"`, `referrerpolicy="no-referrer"`}
|
||||
default:
|
||||
@@ -208,10 +226,8 @@ func hasRequiredAttributes(tagName string, attributes []string) bool {
|
||||
for element, attrs := range elements {
|
||||
if tagName == element {
|
||||
for _, attribute := range attributes {
|
||||
for _, attr := range attrs {
|
||||
if attr == attribute {
|
||||
return true
|
||||
}
|
||||
if slices.Contains(attrs, attribute) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,13 +284,7 @@ func isValidIframeSource(baseURL, src string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, safeDomain := range whitelist {
|
||||
if safeDomain == domain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return slices.Contains(whitelist, domain)
|
||||
}
|
||||
|
||||
func getTagAllowList() map[string][]string {
|
||||
@@ -338,13 +348,7 @@ func getTagAllowList() map[string][]string {
|
||||
}
|
||||
|
||||
func inList(needle string, haystack []string) bool {
|
||||
for _, element := range haystack {
|
||||
if element == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return slices.Contains(haystack, needle)
|
||||
}
|
||||
|
||||
func isBlockedTag(tagName string) bool {
|
||||
@@ -354,13 +358,7 @@ func isBlockedTag(tagName string) bool {
|
||||
"style",
|
||||
}
|
||||
|
||||
for _, element := range blacklist {
|
||||
if element == tagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return slices.Contains(blacklist, tagName)
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -2,6 +2,7 @@ package scraper
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||
@@ -22,10 +23,8 @@ func FindFeeds(body string, base string) map[string]string {
|
||||
isFeedLink := func(n *html.Node) bool {
|
||||
if n.Type == html.ElementNode && n.Data == "link" {
|
||||
t := htmlutil.Attr(n, "type")
|
||||
for _, tt := range linkTypes {
|
||||
if tt == t {
|
||||
return true
|
||||
}
|
||||
if slices.Contains(linkTypes, t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -22,6 +22,8 @@ func VideoIFrame(link string) string {
|
||||
youtubeID := ""
|
||||
if l.Host == "www.youtube.com" && l.Path == "/watch" {
|
||||
youtubeID = l.Query().Get("v")
|
||||
} else if l.Host == "www.youtube.com" && strings.HasPrefix(l.Path, "/shorts/") {
|
||||
youtubeID = strings.TrimPrefix(l.Path, "/shorts/")
|
||||
} else if l.Host == "youtu.be" {
|
||||
youtubeID = strings.TrimLeft(l.Path, "/")
|
||||
}
|
||||
|
||||
@@ -91,13 +91,22 @@ func ParseAtom(r io.Reader) (*Feed, error) {
|
||||
|
||||
mediaLinks := srcitem.mediaLinks()
|
||||
|
||||
link := firstNonEmpty(srcitem.OrigLink, srcitem.Links.First("alternate"), srcitem.Links.First(""), linkFromID)
|
||||
link := firstNonEmpty(
|
||||
srcitem.OrigLink,
|
||||
srcitem.Links.First("alternate"),
|
||||
srcitem.Links.First(""),
|
||||
linkFromID,
|
||||
)
|
||||
dstfeed.Items = append(dstfeed.Items, Item{
|
||||
GUID: firstNonEmpty(guidFromID, srcitem.ID, link),
|
||||
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
|
||||
URL: link,
|
||||
Title: srcitem.Title.Text(),
|
||||
Content: firstNonEmpty(srcitem.Content.String(), srcitem.Summary.String(), srcitem.firstMediaDescription()),
|
||||
GUID: firstNonEmpty(guidFromID, srcitem.ID, link),
|
||||
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
|
||||
URL: link,
|
||||
Title: srcitem.Title.Text(),
|
||||
Content: firstNonEmpty(
|
||||
srcitem.Content.String(),
|
||||
srcitem.Summary.String(),
|
||||
srcitem.firstMediaDescription(),
|
||||
),
|
||||
MediaLinks: mediaLinks,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
var UnknownFormat = errors.New("unknown feed format")
|
||||
var ErrUnknownFormat = errors.New("unknown feed format")
|
||||
|
||||
type feedProbe struct {
|
||||
feedType string
|
||||
@@ -89,7 +89,7 @@ func ParseWithEncoding(r io.Reader, fallbackEncoding string) (*Feed, error) {
|
||||
|
||||
out := sniff(string(lookup))
|
||||
if out.feedType == "" {
|
||||
return nil, UnknownFormat
|
||||
return nil, ErrUnknownFormat
|
||||
}
|
||||
|
||||
if out.encoding == "" && fallbackEncoding != "" {
|
||||
|
||||
@@ -40,7 +40,12 @@ func TestSniff(t *testing.T) {
|
||||
want := testcase.want
|
||||
have := sniff(testcase.input)
|
||||
if want.encoding != have.encoding || want.feedType != have.feedType {
|
||||
t.Errorf("Invalid output\n---\n%s\n---\n\nwant=%#v\nhave=%#v", testcase.input, want, have)
|
||||
t.Errorf(
|
||||
"Invalid output\n---\n%s\n---\n\nwant=%#v\nhave=%#v",
|
||||
testcase.input,
|
||||
want,
|
||||
have,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,23 +34,6 @@ type mediaDescription struct {
|
||||
Text string `xml:",chardata"`
|
||||
}
|
||||
|
||||
func (m *media) firstMediaThumbnail() string {
|
||||
for _, c := range m.MediaContents {
|
||||
for _, t := range c.MediaThumbnails {
|
||||
return t.URL
|
||||
}
|
||||
}
|
||||
for _, t := range m.MediaThumbnails {
|
||||
return t.URL
|
||||
}
|
||||
for _, g := range m.MediaGroups {
|
||||
for _, t := range g.MediaThumbnails {
|
||||
return t.URL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *media) firstMediaDescription() string {
|
||||
for _, d := range m.MediaDescriptions {
|
||||
return plain2html(d.Text)
|
||||
@@ -87,7 +70,10 @@ func (m *media) mediaLinks() []MediaLink {
|
||||
} else if strings.HasPrefix(content.MediaType, "video/") {
|
||||
links = append(links, MediaLink{URL: url, Type: "video", Description: description})
|
||||
} else if content.MediaMedium == "image" || content.MediaMedium == "audio" || content.MediaMedium == "video" {
|
||||
links = append(links, MediaLink{URL: url, Type: content.MediaMedium, Description: description})
|
||||
links = append(
|
||||
links,
|
||||
MediaLink{URL: url, Type: content.MediaMedium, Description: description},
|
||||
)
|
||||
} else {
|
||||
if len(content.MediaThumbnails) > 0 {
|
||||
links = append(links, MediaLink{
|
||||
|
||||
@@ -42,8 +42,16 @@ func TestRDFFeed(t *testing.T) {
|
||||
Title: "Mozilla Dot Org",
|
||||
SiteURL: "http://www.mozilla.org",
|
||||
Items: []Item{
|
||||
{GUID: "http://www.mozilla.org/status/", URL: "http://www.mozilla.org/status/", Title: "New Status Updates"},
|
||||
{GUID: "http://www.mozilla.org/bugs/", URL: "http://www.mozilla.org/bugs/", Title: "Bugzilla Reorganized"},
|
||||
{
|
||||
GUID: "http://www.mozilla.org/status/",
|
||||
URL: "http://www.mozilla.org/status/",
|
||||
Title: "New Status Updates",
|
||||
},
|
||||
{
|
||||
GUID: "http://www.mozilla.org/bugs/",
|
||||
URL: "http://www.mozilla.org/bugs/",
|
||||
Title: "Bugzilla Reorganized",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,12 @@ type rssFeed struct {
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
GUID rssGuid `xml:"guid"`
|
||||
Title string `xml:"title"`
|
||||
GUID rssGuid `xml:"rss guid"`
|
||||
Title string `xml:"rss title"`
|
||||
Link string `xml:"rss link"`
|
||||
Description string `xml:"rss description"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Enclosures []rssEnclosure `xml:"enclosure"`
|
||||
PubDate string `xml:"rss pubDate"`
|
||||
Enclosures []rssEnclosure `xml:"rss enclosure"`
|
||||
|
||||
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||
ContentEncoded string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
|
||||
@@ -78,13 +78,19 @@ func ParseRSS(r io.Reader) (*Feed, error) {
|
||||
for _, e := range srcitem.Enclosures {
|
||||
if strings.HasPrefix(e.Type, "audio/") {
|
||||
podcastURL := e.URL
|
||||
if srcitem.OrigEnclosureLink != "" && strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
|
||||
if srcitem.OrigEnclosureLink != "" &&
|
||||
strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
|
||||
podcastURL = srcitem.OrigEnclosureLink
|
||||
}
|
||||
mediaLinks = append(mediaLinks, MediaLink{URL: podcastURL, Type: "audio"})
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, e := range srcitem.Enclosures {
|
||||
if strings.HasPrefix(e.Type, "image/") {
|
||||
mediaLinks = append(mediaLinks, MediaLink{URL: e.URL, Type: "image"})
|
||||
}
|
||||
}
|
||||
|
||||
permalink := ""
|
||||
if srcitem.GUID.IsPermaLink == "true" {
|
||||
@@ -92,11 +98,15 @@ func ParseRSS(r io.Reader) (*Feed, error) {
|
||||
}
|
||||
|
||||
dstfeed.Items = append(dstfeed.Items, Item{
|
||||
GUID: firstNonEmpty(srcitem.GUID.GUID, srcitem.Link),
|
||||
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
|
||||
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
|
||||
Title: srcitem.Title,
|
||||
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description, srcitem.firstMediaDescription()),
|
||||
GUID: firstNonEmpty(srcitem.GUID.GUID, srcitem.Link),
|
||||
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
|
||||
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
|
||||
Title: srcitem.Title,
|
||||
Content: firstNonEmpty(
|
||||
srcitem.ContentEncoded,
|
||||
srcitem.Description,
|
||||
srcitem.firstMediaDescription(),
|
||||
),
|
||||
MediaLinks: mediaLinks,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ func TestRSSTitleHTMLTags(t *testing.T) {
|
||||
`))
|
||||
have := []string{feed.Items[0].Title, feed.Items[1].Title}
|
||||
want := []string{"title in p", "very strong title"}
|
||||
for i := 0; i < len(want); i++ {
|
||||
for i := range want {
|
||||
if want[i] != have[i] {
|
||||
t.Errorf("title doesn't match\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
||||
}
|
||||
@@ -241,13 +241,42 @@ func TestRSSIsPermalink(t *testing.T) {
|
||||
URL: "http://example.com/posts/1",
|
||||
},
|
||||
}
|
||||
for i := 0; i < len(want); i++ {
|
||||
for i := range want {
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/nkanaev/yarr/issues/284
|
||||
func TestRSSEnclosureImage(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Post with image</title>
|
||||
<link>http://example.com/post/1</link>
|
||||
<enclosure url="http://example.com/photo.jpg" type="image/jpeg" length="123456"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatalf("Expected 1 media link, got %d: %#v", len(feed.Items[0].MediaLinks), feed.Items[0].MediaLinks)
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: "http://example.com/photo.jpg",
|
||||
Type: "image",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSSMultipleMedia(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -274,9 +303,21 @@ func TestRSSMultipleMedia(t *testing.T) {
|
||||
GUID: "http://example.com/posts/1",
|
||||
URL: "http://example.com/posts/1",
|
||||
MediaLinks: []MediaLink{
|
||||
{URL: "https://example.com/path/to/image1.png", Type: "image", Description: "description 1"},
|
||||
{URL: "https://example.com/path/to/image2.png", Type: "image", Description: "description 2"},
|
||||
{URL: "https://example.com/path/to/video1.mp4", Type: "video", Description: "video description"},
|
||||
{
|
||||
URL: "https://example.com/path/to/image1.png",
|
||||
Type: "image",
|
||||
Description: "description 1",
|
||||
},
|
||||
{
|
||||
URL: "https://example.com/path/to/image2.png",
|
||||
Type: "image",
|
||||
Description: "description 2",
|
||||
},
|
||||
{
|
||||
URL: "https://example.com/path/to/video1.mp4",
|
||||
Type: "video",
|
||||
Description: "video description",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestSafeXMLReaderPartial1(t *testing.T) {
|
||||
f = NewSafeXMLReader(f)
|
||||
|
||||
buf := make([]byte, 1)
|
||||
for i := 0; i < len(want); i++ {
|
||||
for i := range want {
|
||||
n, err := f.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fyne.io/systray"
|
||||
"github.com/nkanaev/yarr/src/server"
|
||||
"github.com/nkanaev/yarr/src/systray"
|
||||
)
|
||||
|
||||
func Start(s *server.Server) {
|
||||
systrayOnReady := func() {
|
||||
systray.SetIcon(Icon)
|
||||
systray.SetTemplateIcon(Icon, Icon)
|
||||
systray.SetTooltip("yarr")
|
||||
|
||||
menuOpen := systray.AddMenuItem("Open", "")
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="feather feather-anchor"
|
||||
version="1.1"
|
||||
id="svg905"
|
||||
sodipodi:docname="icon.svg"
|
||||
inkscape:version="1.0.1 (c497b03c, 2020-09-10)"
|
||||
inkscape:export-filename="/Users/nkanaev/Desktop/icon.png"
|
||||
inkscape:export-xdpi="2048"
|
||||
inkscape:export-ydpi="2048">
|
||||
<metadata
|
||||
id="metadata911">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs909">
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : 24 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_z="48 : 24 : 1"
|
||||
inkscape:persp3d-origin="24 : 16 : 1"
|
||||
id="perspective842" />
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1440"
|
||||
inkscape:window-height="900"
|
||||
id="namedview907"
|
||||
showgrid="false"
|
||||
inkscape:zoom="4.9128436"
|
||||
inkscape:cx="30.960444"
|
||||
inkscape:cy="52.71331"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg905"
|
||||
inkscape:document-rotation="0" />
|
||||
<rect
|
||||
style="fill:#212529;stroke:none;stroke-width:3;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
|
||||
id="rect913"
|
||||
width="48"
|
||||
height="48"
|
||||
x="0"
|
||||
y="0"
|
||||
ry="24"
|
||||
rx="0" />
|
||||
<g
|
||||
id="g940"
|
||||
transform="matrix(1.4545455,0,0,1.4545455,6.545454,6.545454)"
|
||||
style="fill:none;stroke:#ffffff;stroke-opacity:1">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="5"
|
||||
id="circle899"
|
||||
r="3"
|
||||
style="fill:none;stroke:#ffffff;stroke-opacity:1" />
|
||||
<line
|
||||
x1="12"
|
||||
y1="22"
|
||||
x2="12"
|
||||
y2="8"
|
||||
id="line901"
|
||||
style="fill:none;stroke:#ffffff;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5,12 H 2 a 10,10 0 0 0 20,0 h -3"
|
||||
id="path903"
|
||||
style="fill:none;stroke:#ffffff;stroke-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -4,5 +4,5 @@ package platform
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed icon.png
|
||||
//go:embed icon_mac.png
|
||||
var Icon []byte
|
||||
|
||||
BIN
src/platform/icon_mac.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
118
src/platform/icon_mac.svg
Normal file
@@ -0,0 +1,118 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="feather feather-anchor"
|
||||
version="1.1"
|
||||
id="svg905"
|
||||
sodipodi:docname="icon_mac.svg"
|
||||
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
|
||||
inkscape:export-filename="icon_mac.png"
|
||||
inkscape:export-xdpi="2048"
|
||||
inkscape:export-ydpi="2048"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata
|
||||
id="metadata911">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs909">
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : 24 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_z="48 : 24 : 1"
|
||||
inkscape:persp3d-origin="24 : 16 : 1"
|
||||
id="perspective842" />
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1440"
|
||||
inkscape:window-height="895"
|
||||
id="namedview907"
|
||||
showgrid="false"
|
||||
inkscape:zoom="4.9128436"
|
||||
inkscape:cx="31.14286"
|
||||
inkscape:cy="52.718959"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="33"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg905"
|
||||
inkscape:document-rotation="0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<rect
|
||||
style="display:none;fill:#800000;stroke-width:4;stroke-linecap:round;stroke-dasharray:none"
|
||||
id="rect3"
|
||||
width="89.561165"
|
||||
height="70.427643"
|
||||
x="-21.576099"
|
||||
y="-7.734828"
|
||||
ry="24"
|
||||
inkscape:label="background-test" />
|
||||
<rect
|
||||
style="display:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect913"
|
||||
width="48"
|
||||
height="48"
|
||||
x="0"
|
||||
y="0"
|
||||
ry="24"
|
||||
rx="0"
|
||||
inkscape:label="circle" />
|
||||
<path
|
||||
id="rect2"
|
||||
style="fill:#000000;stroke:none;stroke-width:3"
|
||||
inkscape:label="circle-hollow"
|
||||
d="M 24 0 C 10.704 0 0 10.704 0 24 C 0 37.296 10.704 48 24 48 C 37.296 48 48 37.296 48 24 C 48 10.704 37.296 0 24 0 z M 24 7.4550781 C 27.49085 7.4550781 30.363281 10.327509 30.363281 13.818359 C 30.363281 16.611253 28.523046 19.006548 26 19.853516 L 26 36.380859 C 31.271218 35.519062 35.266025 31.300336 36.144531 26 L 34.181641 26 A 2.0000001 2.0000001 0 0 1 32.181641 24 A 2.0000001 2.0000001 0 0 1 34.181641 22 L 38.544922 22 A 2.0002001 2.0002001 0 0 1 40.544922 24 C 40.544922 33.114118 33.114111 40.544922 24 40.544922 C 14.885889 40.544922 7.4550781 33.114118 7.4550781 24 A 2.0002001 2.0002001 0 0 1 9.4550781 22 L 13.818359 22 A 2.0000001 2.0000001 0 0 1 15.818359 24 A 2.0000001 2.0000001 0 0 1 13.818359 26 L 11.855469 26 C 12.733975 31.300336 16.728783 35.519062 22 36.380859 L 22 19.853516 C 19.476954 19.006548 17.636719 16.611253 17.636719 13.818359 C 17.636719 10.327509 20.50915 7.4550781 24 7.4550781 z M 24 11.455078 C 22.670911 11.455078 21.636719 12.48927 21.636719 13.818359 C 21.636719 15.147449 22.670911 16.181641 24 16.181641 C 25.329089 16.181641 26.363281 15.147449 26.363281 13.818359 C 26.363281 12.48927 25.329089 11.455078 24 11.455078 z " />
|
||||
<g
|
||||
id="g1"
|
||||
transform="matrix(1.4545455,0,0,1.4545455,6.545454,6.545454)"
|
||||
style="display:none;fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:label="anchor_backup">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="5"
|
||||
id="circle1"
|
||||
r="3"
|
||||
style="fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<line
|
||||
x1="12"
|
||||
y1="22"
|
||||
x2="12"
|
||||
y2="8"
|
||||
id="line1"
|
||||
style="fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5,12 H 2 a 10,10 0 0 0 20,0 h -3"
|
||||
id="path1"
|
||||
style="fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -1,4 +1,4 @@
|
||||
//go:build linux
|
||||
//go:build linux || freebsd || openbsd
|
||||
|
||||
package platform
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func IsAuthenticated(req *http.Request, username, password string) bool {
|
||||
@@ -24,10 +23,12 @@ func IsAuthenticated(req *http.Request, username, password string) bool {
|
||||
|
||||
func Authenticate(rw http.ResponseWriter, username, password, basepath string) {
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: "auth",
|
||||
Value: username + ":" + secret(username, password),
|
||||
Expires: time.Now().Add(time.Hour * 24 * 7), // 1 week,
|
||||
Path: basepath,
|
||||
Name: "auth",
|
||||
Value: username + ":" + secret(username, password),
|
||||
MaxAge: 604800, // 1 week
|
||||
Path: basepath,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,6 @@ type Middleware struct {
|
||||
DB *storage.Storage
|
||||
}
|
||||
|
||||
func unsafeMethod(method string) bool {
|
||||
return method == "POST" || method == "PUT" || method == "DELETE"
|
||||
}
|
||||
|
||||
func (m *Middleware) Handler(c *router.Context) {
|
||||
for _, path := range m.Public {
|
||||
if strings.HasPrefix(c.Req.URL.Path, m.BasePath+path) {
|
||||
@@ -48,7 +44,7 @@ func (m *Middleware) Handler(c *router.Context) {
|
||||
c.Redirect(rootUrl)
|
||||
return
|
||||
} else {
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]interface{}{
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||
"username": username,
|
||||
"error": "Invalid username/password",
|
||||
"settings": m.DB.GetSettings(),
|
||||
@@ -56,7 +52,7 @@ func (m *Middleware) Handler(c *router.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]interface{}{
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||
"settings": m.DB.GetSettings(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ type FeverFavicon struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
func writeFeverJSON(c *router.Context, data map[string]interface{}, lastRefreshed int64) {
|
||||
func writeFeverJSON(c *router.Context, data map[string]any, lastRefreshed int64) {
|
||||
data["api_version"] = 3
|
||||
data["auth"] = 1
|
||||
data["last_refreshed_on_time"] = lastRefreshed
|
||||
@@ -78,7 +78,7 @@ func (s *Server) feverAuth(c *router.Context) bool {
|
||||
if s.Username != "" && s.Password != "" {
|
||||
apiKey := c.Req.FormValue("api_key")
|
||||
apiKey = strings.ToLower(apiKey)
|
||||
md5HashValue := md5.Sum([]byte(fmt.Sprintf("%s:%s", s.Username, s.Password)))
|
||||
md5HashValue := md5.Sum(fmt.Appendf(nil, "%s:%s", s.Username, s.Password))
|
||||
hexMD5HashValue := fmt.Sprintf("%x", md5HashValue[:])
|
||||
if !auth.StringsEqual(apiKey, hexMD5HashValue) {
|
||||
return false
|
||||
@@ -97,7 +97,7 @@ func formHasValue(values url.Values, value string) bool {
|
||||
func (s *Server) handleFever(c *router.Context) {
|
||||
c.Req.ParseForm()
|
||||
if !s.feverAuth(c) {
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"api_version": 3,
|
||||
"auth": 0,
|
||||
"last_refreshed_on_time": 0,
|
||||
@@ -123,7 +123,7 @@ func (s *Server) handleFever(c *router.Context) {
|
||||
case formHasValue(c.Req.Form, "mark"):
|
||||
s.feverMarkHandler(c)
|
||||
default:
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"api_version": 3,
|
||||
"auth": 1,
|
||||
"last_refreshed_on_time": getLastRefreshedOnTime(s.db.ListHTTPStates()),
|
||||
@@ -168,7 +168,7 @@ func (s *Server) feverGroupsHandler(c *router.Context) {
|
||||
for i, folder := range folders {
|
||||
groups[i] = &FeverGroup{ID: folder.Id, Title: folder.Title}
|
||||
}
|
||||
writeFeverJSON(c, map[string]interface{}{
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"groups": groups,
|
||||
"feeds_groups": feedGroups(s.db),
|
||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||
@@ -194,7 +194,7 @@ func (s *Server) feverFeedsHandler(c *router.Context) {
|
||||
LastUpdated: lastUpdated,
|
||||
}
|
||||
}
|
||||
writeFeverJSON(c, map[string]interface{}{
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"feeds": feverFeeds,
|
||||
"feeds_groups": feedGroups(s.db),
|
||||
}, getLastRefreshedOnTime(httpStates))
|
||||
@@ -216,7 +216,7 @@ func (s *Server) feverFaviconsHandler(c *router.Context) {
|
||||
favicons[i] = &FeverFavicon{ID: feed.Id, Data: data}
|
||||
}
|
||||
|
||||
writeFeverJSON(c, map[string]interface{}{
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"favicons": favicons,
|
||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||
}
|
||||
@@ -278,17 +278,17 @@ func (s *Server) feverItemsHandler(c *router.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
totalItems := s.db.CountItems(storage.ItemFilter{})
|
||||
totalItems := s.db.CountItems()
|
||||
|
||||
writeFeverJSON(c, map[string]interface{}{
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"items": feverItems,
|
||||
"total_items": totalItems,
|
||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||
}
|
||||
|
||||
func (s *Server) feverLinksHandler(c *router.Context) {
|
||||
writeFeverJSON(c, map[string]interface{}{
|
||||
"links": make([]interface{}, 0),
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"links": make([]any, 0),
|
||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
|
||||
}
|
||||
itemFilter.After = &items[len(items)-1].Id
|
||||
}
|
||||
writeFeverJSON(c, map[string]interface{}{
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"unread_item_ids": joinInts(itemIds),
|
||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||
}
|
||||
@@ -331,7 +331,7 @@ func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
|
||||
}
|
||||
itemFilter.After = &items[len(items)-1].Id
|
||||
}
|
||||
writeFeverJSON(c, map[string]interface{}{
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"saved_item_ids": joinInts(itemIds),
|
||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||
}
|
||||
@@ -367,7 +367,7 @@ func (s *Server) feverMarkHandler(c *router.Context) {
|
||||
markFilter := storage.MarkFilter{FeedID: &id}
|
||||
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
|
||||
if x > 0 {
|
||||
before := time.Unix(x, 0)
|
||||
before := time.Unix(x, 0).UTC()
|
||||
markFilter.Before = &before
|
||||
}
|
||||
s.db.MarkItemsRead(markFilter)
|
||||
@@ -375,10 +375,13 @@ func (s *Server) feverMarkHandler(c *router.Context) {
|
||||
if c.Req.Form.Get("as") != "read" {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
markFilter := storage.MarkFilter{FolderID: &id}
|
||||
markFilter := storage.MarkFilter{}
|
||||
if id > 0 {
|
||||
markFilter.FolderID = &id
|
||||
}
|
||||
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
|
||||
if x > 0 {
|
||||
before := time.Unix(x, 0)
|
||||
before := time.Unix(x, 0).UTC()
|
||||
markFilter.Before = &before
|
||||
}
|
||||
s.db.MarkItemsRead(markFilter)
|
||||
@@ -386,7 +389,7 @@ func (s *Server) feverMarkHandler(c *router.Context) {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"api_version": 3,
|
||||
"auth": 1,
|
||||
})
|
||||
|
||||
@@ -78,7 +78,11 @@ func TestParseFallback(t *testing.T) {
|
||||
Folders: []Folder{{
|
||||
Title: "foldertitle",
|
||||
Feeds: []Feed{
|
||||
{Title: "feedtext", FeedUrl: "https://example.com/feed.xml", SiteUrl: "https://example.com"},
|
||||
{
|
||||
Title: "feedtext",
|
||||
FeedUrl: "https://example.com/feed.xml",
|
||||
SiteUrl: "https://example.com",
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func (c *Context) Next() {
|
||||
c.chain[c.index](c)
|
||||
}
|
||||
|
||||
func (c *Context) JSON(status int, data interface{}) {
|
||||
func (c *Context) JSON(status int, data any) {
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -35,7 +35,7 @@ func (c *Context) JSON(status int, data interface{}) {
|
||||
c.Out.Write([]byte("\n"))
|
||||
}
|
||||
|
||||
func (c *Context) HTML(status int, tmpl *template.Template, data interface{}) {
|
||||
func (c *Context) HTML(status int, tmpl *template.Template, data any) {
|
||||
c.Out.Header().Set("Content-Type", "text/html")
|
||||
c.Out.WriteHeader(status)
|
||||
tmpl.Execute(c.Out, data)
|
||||
|
||||
@@ -34,7 +34,7 @@ func (s *Server) handler() http.Handler {
|
||||
BasePath: s.BasePath,
|
||||
Username: s.Username,
|
||||
Password: s.Password,
|
||||
Public: []string{"/static", "/fever"},
|
||||
Public: []string{"/static", "/fever", "/manifest.json"},
|
||||
DB: s.db,
|
||||
}
|
||||
r.Use(a.Handler)
|
||||
@@ -64,7 +64,7 @@ func (s *Server) handler() http.Handler {
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(c *router.Context) {
|
||||
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]interface{}{
|
||||
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]any{
|
||||
"settings": s.db.GetSettings(),
|
||||
"authenticated": s.Username != "" && s.Password != "",
|
||||
})
|
||||
@@ -77,18 +77,19 @@ func (s *Server) handleStatic(c *router.Context) {
|
||||
c.Out.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.StripPrefix(s.BasePath+"/static/", http.FileServer(http.FS(assets.FS))).ServeHTTP(c.Out, c.Req)
|
||||
http.StripPrefix(s.BasePath+"/static/", http.FileServer(http.FS(assets.FS))).
|
||||
ServeHTTP(c.Out, c.Req)
|
||||
}
|
||||
|
||||
func (s *Server) handleManifest(c *router.Context) {
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
|
||||
"name": "yarr!",
|
||||
"short_name": "yarr",
|
||||
"description": "yet another rss reader",
|
||||
"display": "standalone",
|
||||
"start_url": "/" + strings.TrimPrefix(s.BasePath, "/"),
|
||||
"icons": []map[string]interface{}{
|
||||
"icons": []map[string]any{
|
||||
{
|
||||
"src": s.BasePath + "/static/graphicarts/favicon.png",
|
||||
"sizes": "64x64",
|
||||
@@ -99,7 +100,7 @@ func (s *Server) handleManifest(c *router.Context) {
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(c *router.Context) {
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"running": s.worker.FeedsPending(),
|
||||
"stats": s.db.FeedStats(),
|
||||
})
|
||||
@@ -236,7 +237,10 @@ func (s *Server) handleFeedList(c *router.Context) {
|
||||
log.Printf("Faild to discover feed for %s: %s", form.Url, err)
|
||||
c.JSON(http.StatusOK, map[string]string{"status": "notfound"})
|
||||
case len(result.Sources) > 0:
|
||||
c.JSON(http.StatusOK, map[string]interface{}{"status": "multiple", "choice": result.Sources})
|
||||
c.JSON(
|
||||
http.StatusOK,
|
||||
map[string]any{"status": "multiple", "choice": result.Sources},
|
||||
)
|
||||
case result.Feed != nil:
|
||||
feed := s.db.CreateFeed(
|
||||
result.Feed.Title,
|
||||
@@ -248,12 +252,11 @@ func (s *Server) handleFeedList(c *router.Context) {
|
||||
items := worker.ConvertItems(result.Feed.Items, *feed)
|
||||
if len(items) > 0 {
|
||||
s.db.CreateItems(items)
|
||||
s.db.SetFeedSize(feed.Id, len(items))
|
||||
s.db.SyncSearch()
|
||||
}
|
||||
s.worker.FindFeedFavicon(*feed)
|
||||
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"status": "success",
|
||||
"feed": feed,
|
||||
})
|
||||
@@ -275,30 +278,34 @@ func (s *Server) handleFeed(c *router.Context) {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
body := make(map[string]any)
|
||||
if err := json.NewDecoder(c.Req.Body).Decode(&body); err != nil {
|
||||
log.Print(err)
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
params := storage.UpdateFeedParams{}
|
||||
if title, ok := body["title"]; ok {
|
||||
if reflect.TypeOf(title).Kind() == reflect.String {
|
||||
s.db.RenameFeed(id, title.(string))
|
||||
t := title.(string)
|
||||
params.Title = &t
|
||||
}
|
||||
}
|
||||
if f_id, ok := body["folder_id"]; ok {
|
||||
if f_id == nil {
|
||||
s.db.UpdateFeedFolder(id, nil)
|
||||
params.FolderID = storage.SetNullable[int64](nil)
|
||||
} else if reflect.TypeOf(f_id).Kind() == reflect.Float64 {
|
||||
folderId := int64(f_id.(float64))
|
||||
s.db.UpdateFeedFolder(id, &folderId)
|
||||
params.FolderID = storage.SetNullable(&folderId)
|
||||
}
|
||||
}
|
||||
if link, ok := body["feed_link"]; ok {
|
||||
if reflect.TypeOf(link).Kind() == reflect.String {
|
||||
s.db.UpdateFeedLink(id, link.(string))
|
||||
l := link.(string)
|
||||
params.FeedLink = &l
|
||||
}
|
||||
}
|
||||
s.db.UpdateFeed(id, params)
|
||||
c.Out.WriteHeader(http.StatusOK)
|
||||
} else if c.Req.Method == "DELETE" {
|
||||
s.db.DeleteFeed(id)
|
||||
@@ -387,7 +394,7 @@ func (s *Server) handleItemList(c *router.Context) {
|
||||
items[i].Title = htmlutil.TruncateText(text, 140)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"list": items,
|
||||
"has_more": hasMore,
|
||||
})
|
||||
@@ -411,7 +418,7 @@ func (s *Server) handleSettings(c *router.Context) {
|
||||
if c.Req.Method == "GET" {
|
||||
c.JSON(http.StatusOK, s.db.GetSettings())
|
||||
} else if c.Req.Method == "PUT" {
|
||||
settings := make(map[string]interface{})
|
||||
settings := make(map[string]any)
|
||||
if err := json.NewDecoder(c.Req.Body).Decode(&settings); err != nil {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
@@ -468,7 +475,6 @@ func (s *Server) handleOPMLExport(c *router.Context) {
|
||||
|
||||
feedsByFolderID := make(map[int64][]*storage.Feed)
|
||||
for _, feed := range s.db.ListFeeds() {
|
||||
feed := feed
|
||||
if feed.FolderId == nil {
|
||||
doc.Feeds = append(doc.Feeds, opml.Feed{
|
||||
Title: feed.Title,
|
||||
@@ -513,6 +519,10 @@ func (s *Server) handlePageCrawl(c *router.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if isInternalFromURL(url) {
|
||||
log.Printf("attempt to access internal IP %s from %s", url, c.Req.RemoteAddr)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := worker.GetBody(url)
|
||||
if err != nil {
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestFeedIcons(t *testing.T) {
|
||||
db, _ := storage.New(":memory:")
|
||||
icon := []byte("test")
|
||||
feed := db.CreateFeed("", "", "", "", nil)
|
||||
db.UpdateFeedIcon(feed.Id, &icon)
|
||||
db.UpdateFeed(feed.Id, storage.UpdateFeedParams{Icon: storage.SetNullable(&icon)})
|
||||
log.SetOutput(os.Stderr)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
@@ -2,7 +2,10 @@ package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
@@ -13,7 +16,7 @@ type Server struct {
|
||||
Addr string
|
||||
db *storage.Storage
|
||||
worker *worker.Worker
|
||||
cache map[string]interface{}
|
||||
cache map[string]any
|
||||
cache_mutex *sync.Mutex
|
||||
|
||||
BasePath string
|
||||
@@ -31,7 +34,7 @@ func NewServer(db *storage.Storage, addr string) *Server {
|
||||
db: db,
|
||||
Addr: addr,
|
||||
worker: worker.NewWorker(db),
|
||||
cache: make(map[string]interface{}),
|
||||
cache: make(map[string]any),
|
||||
cache_mutex: &sync.Mutex{},
|
||||
}
|
||||
}
|
||||
@@ -53,14 +56,31 @@ func (s *Server) Start() {
|
||||
s.worker.RefreshFeeds()
|
||||
}
|
||||
|
||||
httpserver := &http.Server{Addr: s.Addr, Handler: s.handler()}
|
||||
|
||||
var ln net.Listener
|
||||
var err error
|
||||
if s.CertFile != "" && s.KeyFile != "" {
|
||||
err = httpserver.ListenAndServeTLS(s.CertFile, s.KeyFile)
|
||||
|
||||
if path, isUnix := strings.CutPrefix(s.Addr, "unix:"); isUnix {
|
||||
err = os.Remove(path)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
ln, err = net.Listen("unix", path)
|
||||
} else {
|
||||
err = httpserver.ListenAndServe()
|
||||
ln, err = net.Listen("tcp", s.Addr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
httpserver := &http.Server{Handler: s.handler()}
|
||||
if s.CertFile != "" && s.KeyFile != "" {
|
||||
err = httpserver.ServeTLS(ln, s.CertFile, s.KeyFile)
|
||||
ln.Close()
|
||||
} else {
|
||||
err = httpserver.Serve(ln)
|
||||
}
|
||||
|
||||
if err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
35
src/server/util.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isInternalFromURL(urlStr string) bool {
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
host := parsedURL.Host
|
||||
|
||||
// Handle "host:port" format
|
||||
if strings.Contains(host, ":") {
|
||||
host, _, err = net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()
|
||||
}
|
||||
31
src/server/util_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package server
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsInternalFromURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
expected bool
|
||||
}{
|
||||
{"http://192.168.1.1:8080", true},
|
||||
{"http://10.0.0.5", true},
|
||||
{"http://172.16.0.1", true},
|
||||
{"http://172.31.255.255", true},
|
||||
{"http://172.32.0.1", false}, // outside private range
|
||||
{"http://127.0.0.1", true},
|
||||
{"http://127.0.0.1:7000", true},
|
||||
{"http://127.0.0.1:7000/secret", true},
|
||||
{"http://169.254.0.5", true},
|
||||
{"http://localhost", true}, // resolves to 127.0.0.1
|
||||
{"http://8.8.8.8", false},
|
||||
{"http://google.com", false}, // resolves to public IPs
|
||||
{"invalid-url", false}, // invalid format
|
||||
{"", false}, // empty string
|
||||
}
|
||||
for _, test := range tests {
|
||||
result := isInternalFromURL(test.url)
|
||||
if result != test.expected {
|
||||
t.Errorf("isInternalFromURL(%q) = %v; want %v", test.url, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,14 @@ func (s *Storage) CreateFeed(title, description, link, feedLink string, folderId
|
||||
}
|
||||
row := s.db.QueryRow(`
|
||||
insert into feeds (title, description, link, feed_link, folder_id)
|
||||
values (?, ?, ?, ?, ?)
|
||||
on conflict (feed_link) do update set folder_id = ?
|
||||
values (:title, :description, :link, :feed_link, :folder_id)
|
||||
on conflict (feed_link) do update set folder_id = :folder_id
|
||||
returning id`,
|
||||
title, description, link, feedLink, folderId,
|
||||
folderId,
|
||||
sql.Named("title", title),
|
||||
sql.Named("description", description),
|
||||
sql.Named("link", link),
|
||||
sql.Named("feed_link", feedLink),
|
||||
sql.Named("folder_id", folderId),
|
||||
)
|
||||
|
||||
var id int64
|
||||
@@ -46,7 +49,7 @@ func (s *Storage) CreateFeed(title, description, link, feedLink string, folderId
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteFeed(feedId int64) bool {
|
||||
result, err := s.db.Exec(`delete from feeds where id = ?`, feedId)
|
||||
result, err := s.db.Exec(`delete from feeds where id = :id`, sql.Named("id", feedId))
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
@@ -61,24 +64,35 @@ func (s *Storage) DeleteFeed(feedId int64) bool {
|
||||
return nrows == 1
|
||||
}
|
||||
|
||||
func (s *Storage) RenameFeed(feedId int64, newTitle string) bool {
|
||||
_, err := s.db.Exec(`update feeds set title = ? where id = ?`, newTitle, feedId)
|
||||
return err == nil
|
||||
type UpdateFeedParams struct {
|
||||
Title *string
|
||||
FeedLink *string
|
||||
FolderID Nullable[int64]
|
||||
Icon Nullable[[]byte]
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateFeedFolder(feedId int64, newFolderId *int64) bool {
|
||||
_, err := s.db.Exec(`update feeds set folder_id = ? where id = ?`, newFolderId, feedId)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateFeedLink(feedId int64, newLink string) bool {
|
||||
_, err := s.db.Exec(`update feeds set feed_link = ? where id = ?`, newLink, feedId)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateFeedIcon(feedId int64, icon *[]byte) bool {
|
||||
_, err := s.db.Exec(`update feeds set icon = ? where id = ?`, icon, feedId)
|
||||
return err == nil
|
||||
func (s *Storage) UpdateFeed(feedId int64, params UpdateFeedParams) (bool, error) {
|
||||
_, err := s.db.Exec(`
|
||||
update feeds set
|
||||
title = coalesce(:title, title),
|
||||
feed_link = coalesce(:feed_link, feed_link),
|
||||
folder_id = case when :update_folder_id then :folder_id else folder_id end,
|
||||
icon = case when :update_icon then :icon else icon end
|
||||
where id = :id
|
||||
`,
|
||||
sql.Named("id", feedId),
|
||||
sql.Named("title", params.Title),
|
||||
sql.Named("feed_link", params.FeedLink),
|
||||
sql.Named("update_folder_id", params.FolderID.Set),
|
||||
sql.Named("folder_id", params.FolderID.Value),
|
||||
sql.Named("update_icon", params.Icon.Set),
|
||||
sql.Named("icon", params.Icon.Value),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Storage) ListFeeds() []Feed {
|
||||
@@ -149,8 +163,8 @@ func (s *Storage) GetFeed(id int64) *Feed {
|
||||
select
|
||||
id, folder_id, title, link, feed_link,
|
||||
icon, ifnull(icon, '') != '' as has_icon
|
||||
from feeds where id = ?
|
||||
`, id).Scan(
|
||||
from feeds where id = :id
|
||||
`, sql.Named("id", id)).Scan(
|
||||
&f.Id, &f.FolderId, &f.Title, &f.Link, &f.FeedLink,
|
||||
&f.Icon, &f.HasIcon,
|
||||
)
|
||||
@@ -172,9 +186,10 @@ func (s *Storage) ResetFeedErrors() {
|
||||
func (s *Storage) SetFeedError(feedID int64, lastError error) {
|
||||
_, err := s.db.Exec(`
|
||||
insert into feed_errors (feed_id, error)
|
||||
values (?, ?)
|
||||
values (:feed_id, :error)
|
||||
on conflict (feed_id) do update set error = excluded.error`,
|
||||
feedID, lastError.Error(),
|
||||
sql.Named("feed_id", feedID),
|
||||
sql.Named("error", lastError.Error()),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
@@ -200,15 +215,3 @@ func (s *Storage) GetFeedErrors() map[int64]string {
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
func (s *Storage) SetFeedSize(feedId int64, size int) {
|
||||
_, err := s.db.Exec(`
|
||||
insert into feed_sizes (feed_id, size)
|
||||
values (?, ?)
|
||||
on conflict (feed_id) do update set size = excluded.size`,
|
||||
feedId, size,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestCreateFeedSameLink(t *testing.T) {
|
||||
t.Fatal("expected feed")
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
db.CreateFeed("title", "", "", "http://example2.com/feed.xml", nil)
|
||||
}
|
||||
|
||||
@@ -54,9 +54,12 @@ func TestUpdateFeed(t *testing.T) {
|
||||
folder := db.CreateFolder("test")
|
||||
icon := []byte("icon")
|
||||
|
||||
db.RenameFeed(feed1.Id, "newtitle")
|
||||
db.UpdateFeedFolder(feed1.Id, &folder.Id)
|
||||
db.UpdateFeedIcon(feed1.Id, &icon)
|
||||
title := "newtitle"
|
||||
db.UpdateFeed(feed1.Id, UpdateFeedParams{
|
||||
Title: &title,
|
||||
FolderID: SetNullable(&folder.Id),
|
||||
Icon: SetNullable(&icon),
|
||||
})
|
||||
|
||||
feed2 := db.GetFeed(feed1.Id)
|
||||
if feed2.Title != "newtitle" {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
)
|
||||
|
||||
@@ -13,12 +14,11 @@ type Folder struct {
|
||||
func (s *Storage) CreateFolder(title string) *Folder {
|
||||
expanded := true
|
||||
row := s.db.QueryRow(`
|
||||
insert into folders (title, is_expanded) values (?, ?)
|
||||
on conflict (title) do update set title = ?
|
||||
insert into folders (title, is_expanded) values (:title, :is_expanded)
|
||||
on conflict (title) do update set title = :title
|
||||
returning id`,
|
||||
title, expanded,
|
||||
// provide title again so that we can extract row id
|
||||
title,
|
||||
sql.Named("title", title),
|
||||
sql.Named("is_expanded", expanded),
|
||||
)
|
||||
var id int64
|
||||
err := row.Scan(&id)
|
||||
@@ -31,7 +31,7 @@ func (s *Storage) CreateFolder(title string) *Folder {
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteFolder(folderId int64) bool {
|
||||
_, err := s.db.Exec(`delete from folders where id = ?`, folderId)
|
||||
_, err := s.db.Exec(`delete from folders where id = :id`, sql.Named("id", folderId))
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
@@ -39,17 +39,23 @@ func (s *Storage) DeleteFolder(folderId int64) bool {
|
||||
}
|
||||
|
||||
func (s *Storage) RenameFolder(folderId int64, newTitle string) bool {
|
||||
_, err := s.db.Exec(`update folders set title = ? where id = ?`, newTitle, folderId)
|
||||
_, err := s.db.Exec(`update folders set title = :title where id = :id`,
|
||||
sql.Named("title", newTitle),
|
||||
sql.Named("id", folderId),
|
||||
)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Storage) ToggleFolderExpanded(folderId int64, isExpanded bool) bool {
|
||||
_, err := s.db.Exec(`update folders set is_expanded = ? where id = ?`, isExpanded, folderId)
|
||||
_, err := s.db.Exec(`update folders set is_expanded = :is_expanded where id = :id`,
|
||||
sql.Named("is_expanded", isExpanded),
|
||||
sql.Named("id", folderId),
|
||||
)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Storage) ListFolders() []Folder {
|
||||
result := make([]Folder, 0, 0)
|
||||
result := make([]Folder, 0)
|
||||
rows, err := s.db.Query(`
|
||||
select id, title, is_expanded
|
||||
from folders
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
@@ -40,8 +41,8 @@ func (s *Storage) ListHTTPStates() map[int64]HTTPState {
|
||||
func (s *Storage) GetHTTPState(feedID int64) *HTTPState {
|
||||
row := s.db.QueryRow(`
|
||||
select feed_id, last_refreshed, last_modified, etag
|
||||
from http_states where feed_id = ?
|
||||
`, feedID)
|
||||
from http_states where feed_id = :feed_id
|
||||
`, sql.Named("feed_id", feedID))
|
||||
|
||||
if row == nil {
|
||||
return nil
|
||||
@@ -60,12 +61,11 @@ func (s *Storage) GetHTTPState(feedID int64) *HTTPState {
|
||||
func (s *Storage) SetHTTPState(feedID int64, lastModified, etag string) {
|
||||
_, err := s.db.Exec(`
|
||||
insert into http_states (feed_id, last_modified, etag, last_refreshed)
|
||||
values (?, ?, ?, datetime())
|
||||
on conflict (feed_id) do update set last_modified = ?, etag = ?, last_refreshed = datetime()`,
|
||||
// insert
|
||||
feedID, lastModified, etag,
|
||||
// upsert
|
||||
lastModified, etag,
|
||||
values (:feed_id, :last_modified, :etag, datetime())
|
||||
on conflict (feed_id) do update set last_modified = :last_modified, etag = :etag, last_refreshed = datetime()`,
|
||||
sql.Named("feed_id", feedID),
|
||||
sql.Named("last_modified", lastModified),
|
||||
sql.Named("etag", etag),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -54,10 +55,14 @@ type MediaLink struct {
|
||||
type MediaLinks []MediaLink
|
||||
|
||||
func (m *MediaLinks) Scan(src any) error {
|
||||
if data, ok := src.([]byte); ok {
|
||||
switch data := src.(type) {
|
||||
case []byte:
|
||||
return json.Unmarshal(data, m)
|
||||
case string:
|
||||
return json.Unmarshal([]byte(data), m)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m MediaLinks) Value() (driver.Value, error) {
|
||||
@@ -130,17 +135,25 @@ func (s *Storage) CreateItems(items []Item) bool {
|
||||
insert into items (
|
||||
guid, feed_id, title, link, date,
|
||||
content, media_links,
|
||||
date_arrived, status
|
||||
date_arrived, last_arrived, status
|
||||
)
|
||||
values (
|
||||
?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', ?),
|
||||
?, ?,
|
||||
?, ?
|
||||
:guid, :feed_id, :title, :link, strftime('%Y-%m-%d %H:%M:%f', :date),
|
||||
:content, :media_links,
|
||||
:date_arrived, :last_arrived, :status
|
||||
)
|
||||
on conflict (feed_id, guid) do nothing`,
|
||||
item.GUID, item.FeedId, item.Title, item.Link, item.Date,
|
||||
item.Content, item.MediaLinks,
|
||||
now, UNREAD,
|
||||
on conflict (feed_id, guid) do update set
|
||||
last_arrived = :last_arrived`,
|
||||
sql.Named("guid", item.GUID),
|
||||
sql.Named("feed_id", item.FeedId),
|
||||
sql.Named("title", item.Title),
|
||||
sql.Named("link", item.Link),
|
||||
sql.Named("date", item.Date),
|
||||
sql.Named("content", item.Content),
|
||||
sql.Named("media_links", item.MediaLinks),
|
||||
sql.Named("date_arrived", now),
|
||||
sql.Named("last_arrived", now),
|
||||
sql.Named("status", UNREAD),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
@@ -158,20 +171,20 @@ func (s *Storage) CreateItems(items []Item) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interface{}) {
|
||||
func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []any) {
|
||||
cond := make([]string, 0)
|
||||
args := make([]interface{}, 0)
|
||||
args := make([]any, 0)
|
||||
if filter.FolderID != nil {
|
||||
cond = append(cond, "i.feed_id in (select id from feeds where folder_id = ?)")
|
||||
args = append(args, *filter.FolderID)
|
||||
cond = append(cond, "i.feed_id in (select id from feeds where folder_id = :folder_id)")
|
||||
args = append(args, sql.Named("folder_id", *filter.FolderID))
|
||||
}
|
||||
if filter.FeedID != nil {
|
||||
cond = append(cond, "i.feed_id = ?")
|
||||
args = append(args, *filter.FeedID)
|
||||
cond = append(cond, "i.feed_id = :feed_id")
|
||||
args = append(args, sql.Named("feed_id", *filter.FeedID))
|
||||
}
|
||||
if filter.Status != nil {
|
||||
cond = append(cond, "i.status = ?")
|
||||
args = append(args, *filter.Status)
|
||||
cond = append(cond, "i.status = :status")
|
||||
args = append(args, sql.Named("status", *filter.Status))
|
||||
}
|
||||
if filter.Search != nil {
|
||||
words := strings.Fields(*filter.Search)
|
||||
@@ -180,38 +193,46 @@ func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interfac
|
||||
terms[idx] = word + "*"
|
||||
}
|
||||
|
||||
cond = append(cond, "i.search_rowid in (select rowid from search where search match ?)")
|
||||
args = append(args, strings.Join(terms, " "))
|
||||
cond = append(
|
||||
cond,
|
||||
"i.search_rowid in (select rowid from search where search match :search)",
|
||||
)
|
||||
args = append(args, sql.Named("search", strings.Join(terms, " ")))
|
||||
}
|
||||
if filter.After != nil {
|
||||
compare := ">"
|
||||
if newestFirst {
|
||||
compare = "<"
|
||||
}
|
||||
cond = append(cond, fmt.Sprintf("(i.date, i.id) %s (select date, id from items where id = ?)", compare))
|
||||
args = append(args, *filter.After)
|
||||
cond = append(
|
||||
cond,
|
||||
fmt.Sprintf(
|
||||
"(i.date, i.id) %s (select date, id from items where id = :after_id)",
|
||||
compare,
|
||||
),
|
||||
)
|
||||
args = append(args, sql.Named("after_id", *filter.After))
|
||||
}
|
||||
if filter.IDs != nil && len(*filter.IDs) > 0 {
|
||||
qmarks := make([]string, len(*filter.IDs))
|
||||
idargs := make([]interface{}, len(*filter.IDs))
|
||||
for i, id := range *filter.IDs {
|
||||
qmarks[i] = "?"
|
||||
idargs[i] = id
|
||||
name := fmt.Sprintf("id%d", i)
|
||||
qmarks[i] = ":" + name
|
||||
args = append(args, sql.Named(name, id))
|
||||
}
|
||||
cond = append(cond, "i.id in ("+strings.Join(qmarks, ",")+")")
|
||||
args = append(args, idargs...)
|
||||
}
|
||||
if filter.SinceID != nil {
|
||||
cond = append(cond, "i.id > ?")
|
||||
args = append(args, filter.SinceID)
|
||||
cond = append(cond, "i.id > :since_id")
|
||||
args = append(args, sql.Named("since_id", filter.SinceID))
|
||||
}
|
||||
if filter.MaxID != nil {
|
||||
cond = append(cond, "i.id < ?")
|
||||
args = append(args, filter.MaxID)
|
||||
cond = append(cond, "i.id < :max_id")
|
||||
args = append(args, sql.Named("max_id", filter.MaxID))
|
||||
}
|
||||
if filter.Before != nil {
|
||||
cond = append(cond, "i.date < ?")
|
||||
args = append(args, filter.Before)
|
||||
cond = append(cond, "i.date < :before")
|
||||
args = append(args, sql.Named("before", filter.Before))
|
||||
}
|
||||
|
||||
predicate := "1"
|
||||
@@ -222,16 +243,9 @@ func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interfac
|
||||
return predicate, args
|
||||
}
|
||||
|
||||
func (s *Storage) CountItems(filter ItemFilter) int {
|
||||
predicate, args := listQueryPredicate(filter, false)
|
||||
|
||||
func (s *Storage) CountItems() int {
|
||||
var count int
|
||||
query := fmt.Sprintf(`
|
||||
select count(*)
|
||||
from items
|
||||
where %s
|
||||
`, predicate)
|
||||
err := s.db.QueryRow(query, args...).Scan(&count)
|
||||
err := s.db.QueryRow(`select count(*) from items`).Scan(&count)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return 0
|
||||
@@ -239,9 +253,14 @@ func (s *Storage) CountItems(filter ItemFilter) int {
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool, withContent bool) []Item {
|
||||
func (s *Storage) ListItems(
|
||||
filter ItemFilter,
|
||||
limit int,
|
||||
newestFirst bool,
|
||||
withContent bool,
|
||||
) []Item {
|
||||
predicate, args := listQueryPredicate(filter, newestFirst)
|
||||
result := make([]Item, 0, 0)
|
||||
result := make([]Item, 0)
|
||||
|
||||
order := "date desc, id desc"
|
||||
if !newestFirst {
|
||||
@@ -295,8 +314,8 @@ func (s *Storage) GetItem(id int64) *Item {
|
||||
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
|
||||
i.date, i.status, i.media_links
|
||||
from items i
|
||||
where i.id = ?
|
||||
`, id).Scan(
|
||||
where i.id = :id
|
||||
`, sql.Named("id", id)).Scan(
|
||||
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
|
||||
&i.Date, &i.Status, &i.MediaLinks,
|
||||
)
|
||||
@@ -308,7 +327,10 @@ func (s *Storage) GetItem(id int64) *Item {
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateItemStatus(item_id int64, status ItemStatus) bool {
|
||||
_, err := s.db.Exec(`update items set status = ? where id = ?`, status, item_id)
|
||||
_, err := s.db.Exec(`update items set status = :status where id = :id`,
|
||||
sql.Named("status", status),
|
||||
sql.Named("id", item_id),
|
||||
)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -377,8 +399,9 @@ func (s *Storage) SyncSearch() {
|
||||
|
||||
for _, item := range items {
|
||||
result, err := s.db.Exec(`
|
||||
insert into search (title, description, content) values (?, "", ?)`,
|
||||
item.Title, htmlutil.ExtractText(item.Content),
|
||||
insert into search (title, description, content) values (:title, "", :content)`,
|
||||
sql.Named("title", item.Title),
|
||||
sql.Named("content", htmlutil.ExtractText(item.Content)),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
@@ -387,8 +410,9 @@ func (s *Storage) SyncSearch() {
|
||||
if numrows, err := result.RowsAffected(); err == nil && numrows == 1 {
|
||||
if rowId, err := result.LastInsertId(); err == nil {
|
||||
s.db.Exec(
|
||||
`update items set search_rowid = ? where id = ?`,
|
||||
rowId, item.Id,
|
||||
`update items set search_rowid = :search_rowid where id = :id`,
|
||||
sql.Named("search_rowid", rowId),
|
||||
sql.Named("id", item.Id),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -404,61 +428,35 @@ var (
|
||||
//
|
||||
// The rules:
|
||||
// - Never delete starred entries.
|
||||
// - Keep at least the same amount of articles the feed provides (default: 50).
|
||||
// This prevents from deleting items for rarely updated and/or ever-growing
|
||||
// feeds which might eventually reappear as unread.
|
||||
// - Keep entries for a certain period (default: 90 days).
|
||||
// - Keep at least 50 latest items for each feed.
|
||||
// - Delete entries older than 90 days relative to the latest arrived item in the same feed.
|
||||
func (s *Storage) DeleteOldItems() {
|
||||
rows, err := s.db.Query(`
|
||||
select
|
||||
i.feed_id,
|
||||
max(coalesce(s.size, 0), ?) as max_items,
|
||||
count(*) as num_items
|
||||
from items i
|
||||
left outer join feed_sizes s on s.feed_id = i.feed_id
|
||||
where status != ?
|
||||
group by i.feed_id
|
||||
`, itemsKeepSize, STARRED)
|
||||
|
||||
result, err := s.db.Exec(`
|
||||
delete from items
|
||||
where id in (
|
||||
select id
|
||||
from (
|
||||
select
|
||||
id,
|
||||
row_number() over (partition by feed_id order by date desc) as rn,
|
||||
last_arrived,
|
||||
max(last_arrived) over (partition by feed_id) as max_la
|
||||
from items
|
||||
where status != :starred_status
|
||||
)
|
||||
where rn > :keep_size
|
||||
and last_arrived < datetime(max_la, :keep_days_limit)
|
||||
)`,
|
||||
sql.Named("starred_status", STARRED),
|
||||
sql.Named("keep_size", itemsKeepSize),
|
||||
sql.Named("keep_days_limit", fmt.Sprintf("-%d days", itemsKeepDays)),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
|
||||
feedLimits := make(map[int64]int64, 0)
|
||||
for rows.Next() {
|
||||
var feedId, limit int64
|
||||
rows.Scan(&feedId, &limit, nil)
|
||||
feedLimits[feedId] = limit
|
||||
}
|
||||
|
||||
for feedId, limit := range feedLimits {
|
||||
result, err := s.db.Exec(`
|
||||
delete from items
|
||||
where id in (
|
||||
select i.id
|
||||
from items i
|
||||
where i.feed_id = ? and status != ?
|
||||
order by date desc
|
||||
limit -1 offset ?
|
||||
) and date_arrived < ?
|
||||
`,
|
||||
feedId,
|
||||
STARRED,
|
||||
limit,
|
||||
time.Now().UTC().Add(-time.Hour*time.Duration(24*itemsKeepDays)),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
numDeleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
if numDeleted > 0 {
|
||||
log.Printf("Deleted %d old items (feed: %d)", numDeleted, feedId)
|
||||
}
|
||||
numDeleted, err := result.RowsAffected()
|
||||
if err == nil && numDeleted > 0 {
|
||||
log.Printf("Deleted %d old items", numDeleted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -46,21 +48,62 @@ func testItemsSetup(db *Storage) testItemScope {
|
||||
db.CreateItems([]Item{
|
||||
// feed11
|
||||
{GUID: "item111", FeedId: feed11.Id, Title: "title111", Date: now.Add(time.Hour * 24 * 1)},
|
||||
{GUID: "item112", FeedId: feed11.Id, Title: "title112", Date: now.Add(time.Hour * 24 * 2)}, // read
|
||||
{GUID: "item113", FeedId: feed11.Id, Title: "title113", Date: now.Add(time.Hour * 24 * 3)}, // starred
|
||||
{
|
||||
GUID: "item112",
|
||||
FeedId: feed11.Id,
|
||||
Title: "title112",
|
||||
Date: now.Add(time.Hour * 24 * 2),
|
||||
}, // read
|
||||
{
|
||||
GUID: "item113",
|
||||
FeedId: feed11.Id,
|
||||
Title: "title113",
|
||||
Date: now.Add(time.Hour * 24 * 3),
|
||||
}, // starred
|
||||
// feed12
|
||||
{GUID: "item121", FeedId: feed12.Id, Title: "title121", Date: now.Add(time.Hour * 24 * 4)},
|
||||
{GUID: "item122", FeedId: feed12.Id, Title: "title122", Date: now.Add(time.Hour * 24 * 5)}, // read
|
||||
{
|
||||
GUID: "item122",
|
||||
FeedId: feed12.Id,
|
||||
Title: "title122",
|
||||
Date: now.Add(time.Hour * 24 * 5),
|
||||
}, // read
|
||||
// feed21
|
||||
{GUID: "item211", FeedId: feed21.Id, Title: "title211", Date: now.Add(time.Hour * 24 * 6)}, // read
|
||||
{GUID: "item212", FeedId: feed21.Id, Title: "title212", Date: now.Add(time.Hour * 24 * 7)}, // starred
|
||||
{
|
||||
GUID: "item211",
|
||||
FeedId: feed21.Id,
|
||||
Title: "title211",
|
||||
Date: now.Add(time.Hour * 24 * 6),
|
||||
}, // read
|
||||
{
|
||||
GUID: "item212",
|
||||
FeedId: feed21.Id,
|
||||
Title: "title212",
|
||||
Date: now.Add(time.Hour * 24 * 7),
|
||||
}, // starred
|
||||
// feed01
|
||||
{GUID: "item011", FeedId: feed01.Id, Title: "title011", Date: now.Add(time.Hour * 24 * 8)},
|
||||
{GUID: "item012", FeedId: feed01.Id, Title: "title012", Date: now.Add(time.Hour * 24 * 9)}, // read
|
||||
{GUID: "item013", FeedId: feed01.Id, Title: "title013", Date: now.Add(time.Hour * 24 * 10)}, // starred
|
||||
{
|
||||
GUID: "item012",
|
||||
FeedId: feed01.Id,
|
||||
Title: "title012",
|
||||
Date: now.Add(time.Hour * 24 * 9),
|
||||
}, // read
|
||||
{
|
||||
GUID: "item013",
|
||||
FeedId: feed01.Id,
|
||||
Title: "title013",
|
||||
Date: now.Add(time.Hour * 24 * 10),
|
||||
}, // starred
|
||||
})
|
||||
db.db.Exec(`update items set status = ? where guid in ("item112", "item122", "item211", "item012")`, READ)
|
||||
db.db.Exec(`update items set status = ? where guid in ("item113", "item212", "item013")`, STARRED)
|
||||
db.db.Exec(
|
||||
`update items set status = :status where guid in ("item112", "item122", "item211", "item012")`,
|
||||
sql.Named("status", READ),
|
||||
)
|
||||
db.db.Exec(
|
||||
`update items set status = :status where guid in ("item113", "item212", "item013")`,
|
||||
sql.Named("status", STARRED),
|
||||
)
|
||||
|
||||
return testItemScope{
|
||||
feed11: feed11,
|
||||
@@ -79,8 +122,8 @@ func getItem(db *Storage, guid string) *Item {
|
||||
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
|
||||
i.date, i.status, i.media_links
|
||||
from items i
|
||||
where i.guid = ?
|
||||
`, guid).Scan(
|
||||
where i.guid = :guid
|
||||
`, sql.Named("guid", guid)).Scan(
|
||||
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
|
||||
&i.Date, &i.Status, &i.MediaLinks,
|
||||
)
|
||||
@@ -207,7 +250,9 @@ func TestListItemsPaginated(t *testing.T) {
|
||||
|
||||
// unread, newest first
|
||||
unread := UNREAD
|
||||
have = getItemGuids(db.ListItems(ItemFilter{After: &item012.Id, Status: &unread}, 3, true, false))
|
||||
have = getItemGuids(
|
||||
db.ListItems(ItemFilter{After: &item012.Id, Status: &unread}, 3, true, false),
|
||||
)
|
||||
want = []string{"item011", "item121", "item111"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
@@ -217,7 +262,9 @@ func TestListItemsPaginated(t *testing.T) {
|
||||
|
||||
// starred, oldest first
|
||||
starred := STARRED
|
||||
have = getItemGuids(db.ListItems(ItemFilter{After: &item121.Id, Status: &starred}, 3, false, false))
|
||||
have = getItemGuids(
|
||||
db.ListItems(ItemFilter{After: &item121.Id, Status: &starred}, 3, false, false),
|
||||
)
|
||||
want = []string{"item212", "item013"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
@@ -274,57 +321,114 @@ func TestMarkItemsRead(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteOldItems(t *testing.T) {
|
||||
extraItems := 10
|
||||
|
||||
now := time.Now().UTC()
|
||||
db := testDB()
|
||||
feed := db.CreateFeed("feed", "", "", "http://test.com/feed11.xml", nil)
|
||||
starred := STARRED
|
||||
|
||||
items := make([]Item, 0)
|
||||
for i := 0; i < itemsKeepSize+extraItems; i++ {
|
||||
istr := strconv.Itoa(i)
|
||||
items = append(items, Item{
|
||||
GUID: istr,
|
||||
FeedId: feed.Id,
|
||||
Title: istr,
|
||||
Date: now.Add(time.Hour * time.Duration(i)),
|
||||
})
|
||||
}
|
||||
db.CreateItems(items)
|
||||
t.Run("keeps at least 50 items", func(t *testing.T) {
|
||||
db := testDB()
|
||||
feed := db.CreateFeed("f", "", "", "http://f.xml", nil)
|
||||
items := make([]Item, 100)
|
||||
for i := range 100 {
|
||||
items[i] = Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Hour * 24)}
|
||||
}
|
||||
db.CreateItems(items)
|
||||
|
||||
db.SetFeedSize(feed.Id, itemsKeepSize)
|
||||
var feedSize int
|
||||
err := db.db.QueryRow(
|
||||
`select size from feed_sizes where feed_id = ?`, feed.Id,
|
||||
).Scan(&feedSize)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if feedSize != itemsKeepSize {
|
||||
t.Fatalf(
|
||||
"expected feed size to get updated\nwant: %d\nhave: %d",
|
||||
itemsKeepSize+extraItems,
|
||||
feedSize,
|
||||
)
|
||||
}
|
||||
// // Set 1 recent (latest), 100 old (100 days ago)
|
||||
db.db.Exec(`update items set last_arrived = :la where guid = "99"`, sql.Named("la", now))
|
||||
db.db.Exec(`update items set last_arrived = :la where guid != "99"`, sql.Named("la", now.Add(-time.Hour*24*100)))
|
||||
|
||||
// expire only the first 3 articles
|
||||
_, err = db.db.Exec(
|
||||
`update items set date_arrived = ?
|
||||
where id in (select id from items limit 3)`,
|
||||
now.Add(-time.Hour*time.Duration(itemsKeepDays*24)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.DeleteOldItems()
|
||||
var have int
|
||||
db.db.QueryRow("select count(*) from items where feed_id = ?", feed.Id).Scan(&have)
|
||||
if have != 50 {
|
||||
t.Errorf("expected 50 items, have %d", have)
|
||||
}
|
||||
})
|
||||
|
||||
db.DeleteOldItems()
|
||||
feedItems := db.ListItems(ItemFilter{FeedID: &feed.Id}, 1000, false, false)
|
||||
if len(feedItems) != len(items)-3 {
|
||||
t.Fatalf(
|
||||
"invalid number of old items kept\nwant: %d\nhave: %d",
|
||||
len(items)-3,
|
||||
len(feedItems),
|
||||
)
|
||||
}
|
||||
t.Run("keeps all less than 90 days old", func(t *testing.T) {
|
||||
db := testDB()
|
||||
feed := db.CreateFeed("f", "", "", "http://f.xml", nil)
|
||||
items := make([]Item, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
items[i] = Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Second)}
|
||||
}
|
||||
db.CreateItems(items)
|
||||
|
||||
// Latest item at "now"
|
||||
// All others at 80 days ago (keep)
|
||||
db.db.Exec(`update items set last_arrived = :la where guid = "99"`, sql.Named("la", now))
|
||||
db.db.Exec(`update items set last_arrived = :la where guid != "99"`, sql.Named("la", now.Add(-time.Hour*24*80)))
|
||||
|
||||
db.DeleteOldItems()
|
||||
var have int
|
||||
db.db.QueryRow("select count(*) from items where feed_id = ?", feed.Id).Scan(&have)
|
||||
if have != 100 {
|
||||
t.Errorf("expected 100 items, have %d", have)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keeps starred", func(t *testing.T) {
|
||||
db := testDB()
|
||||
feed := db.CreateFeed("f", "", "", "http://f.xml", nil)
|
||||
items := make([]Item, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
items[i] = Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Second)}
|
||||
}
|
||||
db.CreateItems(items)
|
||||
|
||||
// Set all to 100 days ago, except one recent
|
||||
db.db.Exec(`update items set last_arrived = :la`, sql.Named("la", now.Add(-time.Hour*24*100)))
|
||||
db.db.Exec(`update items set last_arrived = :la where guid = "99"`, sql.Named("la", now))
|
||||
// Star 10 old items that would otherwise be deleted (rn > 50 and old)
|
||||
db.db.Exec(`update items set status = :s where cast(guid as integer) < 10`, sql.Named("s", starred))
|
||||
|
||||
db.DeleteOldItems()
|
||||
var have int
|
||||
db.db.QueryRow("select count(*) from items where feed_id = ?", feed.Id).Scan(&have)
|
||||
// 50 (limit) + 10 (starred) = 60 items should remain.
|
||||
if have != 60 {
|
||||
t.Errorf("expected 60 items, have %d", have)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
func TestCreateItemsLastArrived(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
db := testDB()
|
||||
defer db.db.Close()
|
||||
feed := db.CreateFeed("test feed", "", "", "http://example.com/feed", nil)
|
||||
|
||||
item := Item{
|
||||
GUID: "item1",
|
||||
FeedId: feed.Id,
|
||||
Title: "Title 1",
|
||||
Date: time.Now(),
|
||||
}
|
||||
|
||||
// 1. Initial creation
|
||||
db.CreateItems([]Item{item})
|
||||
|
||||
var lastArrived1 time.Time
|
||||
err := db.db.QueryRow("select last_arrived from items where guid = ?", item.GUID).Scan(&lastArrived1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(time.Second * 10)
|
||||
|
||||
// 2. Update on conflict
|
||||
db.CreateItems([]Item{item})
|
||||
|
||||
var lastArrived2 time.Time
|
||||
err = db.db.QueryRow("select last_arrived from items where guid = ?", item.GUID).Scan(&lastArrived2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !lastArrived2.After(lastArrived1) {
|
||||
t.Errorf("expected last_arrived to be updated. old: %v, new: %v", lastArrived1, lastArrived2)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ var migrations = []func(*sql.Tx) error{
|
||||
m08_normalize_datetime,
|
||||
m09_change_item_index,
|
||||
m10_add_item_medialinks,
|
||||
m11_add_item_last_arrived,
|
||||
m12_remove_feed_sizes,
|
||||
}
|
||||
|
||||
var maxVersion = int64(len(migrations))
|
||||
@@ -290,7 +292,10 @@ func m08_normalize_datetime(tx *sql.Tx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`update items set date_arrived = ? where id = ?;`, dateArrived.UTC(), id)
|
||||
_, err = tx.Exec(`update items set date_arrived = :date_arrived where id = :id;`,
|
||||
sql.Named("date_arrived", dateArrived.UTC()),
|
||||
sql.Named("id", id),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -329,3 +334,14 @@ func m10_add_item_medialinks(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(sql)
|
||||
return err
|
||||
}
|
||||
|
||||
func m11_add_item_last_arrived(tx *sql.Tx) error {
|
||||
sql := `alter table items add column last_arrived datetime`
|
||||
_, err := tx.Exec(sql)
|
||||
return err
|
||||
}
|
||||
|
||||
func m12_remove_feed_sizes(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`drop table if exists feed_sizes`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
)
|
||||
|
||||
func settingsDefaults() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func settingsDefaults() map[string]any {
|
||||
return map[string]any{
|
||||
"filter": "",
|
||||
"feed": "",
|
||||
"feed_list_width": 300,
|
||||
@@ -16,11 +17,12 @@ func settingsDefaults() map[string]interface{} {
|
||||
"theme_font": "",
|
||||
"theme_size": 1,
|
||||
"refresh_rate": 0,
|
||||
"language": "en",
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Storage) GetSettingsValue(key string) interface{} {
|
||||
row := s.db.QueryRow(`select val from settings where key=?`, key)
|
||||
func (s *Storage) GetSettingsValue(key string) any {
|
||||
row := s.db.QueryRow(`select val from settings where key=:key`, sql.Named("key", key))
|
||||
if row == nil {
|
||||
return settingsDefaults()[key]
|
||||
}
|
||||
@@ -29,7 +31,7 @@ func (s *Storage) GetSettingsValue(key string) interface{} {
|
||||
if len(val) == 0 {
|
||||
return nil
|
||||
}
|
||||
var valDecoded interface{}
|
||||
var valDecoded any
|
||||
if err := json.Unmarshal([]byte(val), &valDecoded); err != nil {
|
||||
log.Print(err)
|
||||
return nil
|
||||
@@ -47,7 +49,7 @@ func (s *Storage) GetSettingsValueInt64(key string) int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Storage) GetSettings() map[string]interface{} {
|
||||
func (s *Storage) GetSettings() map[string]any {
|
||||
result := settingsDefaults()
|
||||
rows, err := s.db.Query(`select key, val from settings;`)
|
||||
if err != nil {
|
||||
@@ -57,7 +59,7 @@ func (s *Storage) GetSettings() map[string]interface{} {
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var val []byte
|
||||
var valDecoded interface{}
|
||||
var valDecoded any
|
||||
|
||||
rows.Scan(&key, &val)
|
||||
if err = json.Unmarshal([]byte(val), &valDecoded); err != nil {
|
||||
@@ -69,7 +71,7 @@ func (s *Storage) GetSettings() map[string]interface{} {
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateSettings(kv map[string]interface{}) bool {
|
||||
func (s *Storage) UpdateSettings(kv map[string]any) bool {
|
||||
defaults := settingsDefaults()
|
||||
for key, val := range kv {
|
||||
if defaults[key] == nil {
|
||||
@@ -81,9 +83,10 @@ func (s *Storage) UpdateSettings(kv map[string]interface{}) bool {
|
||||
return false
|
||||
}
|
||||
_, err = s.db.Exec(`
|
||||
insert into settings (key, val) values (?, ?)
|
||||
on conflict (key) do update set val=?`,
|
||||
key, valEncoded, valEncoded,
|
||||
insert into settings (key, val) values (:key, :val)
|
||||
on conflict (key) do update set val=:val`,
|
||||
sql.Named("key", key),
|
||||
sql.Named("val", valEncoded),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
|
||||
@@ -12,6 +12,15 @@ type Storage struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type Nullable[T any] struct {
|
||||
Set bool
|
||||
Value *T
|
||||
}
|
||||
|
||||
func SetNullable[T any](v *T) Nullable[T] {
|
||||
return Nullable[T]{Set: true, Value: v}
|
||||
}
|
||||
|
||||
func New(path string) (*Storage, error) {
|
||||
if pos := strings.IndexRune(path, '?'); pos == -1 {
|
||||
params := "_journal=WAL&_sync=NORMAL&_busy_timeout=5000&cache=shared"
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
taken from:
|
||||
|
||||
repo:
|
||||
https://github.com/getlantern/systray
|
||||
|
||||
hash:
|
||||
2c0986dda9aea361e925f90e848d9036be7b5367
|
||||
|
||||
changes:
|
||||
|
||||
-removed `getlantern/golog` dependency
|
||||
-prevent from compiling in linux
|
||||
@@ -1,120 +0,0 @@
|
||||
systray is a cross-platform Go library to place an icon and menu in the notification area.
|
||||
|
||||
## Features
|
||||
|
||||
* Supported on Windows, macOS, and Linux
|
||||
* Menu items can be checked and/or disabled
|
||||
* Methods may be called from any Goroutine
|
||||
|
||||
## API
|
||||
|
||||
```go
|
||||
func main() {
|
||||
systray.Run(onReady, onExit)
|
||||
}
|
||||
|
||||
func onReady() {
|
||||
systray.SetIcon(icon.Data)
|
||||
systray.SetTitle("Awesome App")
|
||||
systray.SetTooltip("Pretty awesome超级棒")
|
||||
mQuit := systray.AddMenuItem("Quit", "Quit the whole app")
|
||||
|
||||
// Sets the icon of a menu item. Only available on Mac and Windows.
|
||||
mQuit.SetIcon(icon.Data)
|
||||
}
|
||||
|
||||
func onExit() {
|
||||
// clean up here
|
||||
}
|
||||
```
|
||||
|
||||
See [full API](https://pkg.go.dev/github.com/getlantern/systray?tab=doc) as well as [CHANGELOG](https://github.com/getlantern/systray/tree/master/CHANGELOG.md).
|
||||
|
||||
## Try the example app!
|
||||
|
||||
Have go v1.12+ or higher installed? Here's an example to get started on macOS:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/getlantern/systray
|
||||
cd example
|
||||
env GO111MODULE=on go build
|
||||
./example
|
||||
```
|
||||
|
||||
On Windows, you should build like this:
|
||||
|
||||
```
|
||||
env GO111MODULE=on go build -ldflags "-H=windowsgui"
|
||||
```
|
||||
|
||||
The following text will then appear on the console:
|
||||
|
||||
|
||||
```sh
|
||||
go: finding github.com/skratchdot/open-golang latest
|
||||
go: finding github.com/getlantern/systray latest
|
||||
go: finding github.com/getlantern/golog latest
|
||||
```
|
||||
|
||||
Now look for *Awesome App* in your menu bar!
|
||||
|
||||

|
||||
|
||||
## The Webview example
|
||||
|
||||
The code under `webview_example` is to demostrate how it can co-exist with other UI elements. Note that the example doesn't work on macOS versions older than 10.15 Catalina.
|
||||
|
||||
## Platform notes
|
||||
|
||||
### Linux
|
||||
|
||||
* Building apps requires gcc as well as the `gtk3` and `libappindicator3` development headers to be installed. For Debian or Ubuntu, you may install these using:
|
||||
|
||||
```sh
|
||||
sudo apt-get install gcc libgtk-3-dev libappindicator3-dev
|
||||
```
|
||||
|
||||
On Linux Mint, `libxapp-dev` is also required .
|
||||
|
||||
To build `webview_example`, you also need to install `libwebkit2gtk-4.0-dev` and remove `webview_example/rsrc.syso` which is required on Windows.
|
||||
|
||||
### Windows
|
||||
|
||||
* To avoid opening a console at application startup, use these compile flags:
|
||||
|
||||
```sh
|
||||
go build -ldflags -H=windowsgui
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
On macOS, you will need to create an application bundle to wrap the binary; simply folders with the following minimal structure and assets:
|
||||
|
||||
```
|
||||
SystrayApp.app/
|
||||
Contents/
|
||||
Info.plist
|
||||
MacOS/
|
||||
go-executable
|
||||
Resources/
|
||||
SystrayApp.icns
|
||||
```
|
||||
|
||||
When running as an app bundle, you may want to add one or both of the following to your Info.plist:
|
||||
|
||||
```xml
|
||||
<!-- avoid having a blurry icon and text -->
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>True</string>
|
||||
|
||||
<!-- avoid showing the app on the Dock -->
|
||||
<key>LSUIElement</key>
|
||||
<string>1</string>
|
||||
```
|
||||
|
||||
Consult the [Official Apple Documentation here](https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html#//apple_ref/doc/uid/10000123i-CH101-SW1).
|
||||
|
||||
## Credits
|
||||
|
||||
- https://github.com/xilp/systray
|
||||
- https://github.com/cratonica/trayhost
|
||||
@@ -1,268 +0,0 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <limits.h>
|
||||
#include <libappindicator/app-indicator.h>
|
||||
#include "systray.h"
|
||||
|
||||
static AppIndicator *global_app_indicator;
|
||||
static GtkWidget *global_tray_menu = NULL;
|
||||
static GList *global_menu_items = NULL;
|
||||
static char temp_file_name[PATH_MAX] = "";
|
||||
|
||||
typedef struct {
|
||||
GtkWidget *menu_item;
|
||||
int menu_id;
|
||||
long signalHandlerId;
|
||||
} MenuItemNode;
|
||||
|
||||
typedef struct {
|
||||
int menu_id;
|
||||
int parent_menu_id;
|
||||
char* title;
|
||||
char* tooltip;
|
||||
short disabled;
|
||||
short checked;
|
||||
short isCheckable;
|
||||
} MenuItemInfo;
|
||||
|
||||
void registerSystray(void) {
|
||||
gtk_init(0, NULL);
|
||||
global_app_indicator = app_indicator_new("systray", "", APP_INDICATOR_CATEGORY_APPLICATION_STATUS);
|
||||
app_indicator_set_status(global_app_indicator, APP_INDICATOR_STATUS_ACTIVE);
|
||||
global_tray_menu = gtk_menu_new();
|
||||
app_indicator_set_menu(global_app_indicator, GTK_MENU(global_tray_menu));
|
||||
systray_ready();
|
||||
}
|
||||
|
||||
int nativeLoop(void) {
|
||||
gtk_main();
|
||||
systray_on_exit();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void _unlink_temp_file() {
|
||||
if (strlen(temp_file_name) != 0) {
|
||||
int ret = unlink(temp_file_name);
|
||||
if (ret == -1) {
|
||||
printf("failed to remove temp icon file %s: %s\n", temp_file_name, strerror(errno));
|
||||
}
|
||||
temp_file_name[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// runs in main thread, should always return FALSE to prevent gtk to execute it again
|
||||
gboolean do_set_icon(gpointer data) {
|
||||
_unlink_temp_file();
|
||||
char *tmpdir = getenv("TMPDIR");
|
||||
if (NULL == tmpdir) {
|
||||
tmpdir = "/tmp";
|
||||
}
|
||||
strncpy(temp_file_name, tmpdir, PATH_MAX-1);
|
||||
strncat(temp_file_name, "/systray_XXXXXX", PATH_MAX-1);
|
||||
temp_file_name[PATH_MAX-1] = '\0';
|
||||
|
||||
GBytes* bytes = (GBytes*)data;
|
||||
int fd = mkstemp(temp_file_name);
|
||||
if (fd == -1) {
|
||||
printf("failed to create temp icon file %s: %s\n", temp_file_name, strerror(errno));
|
||||
return FALSE;
|
||||
}
|
||||
gsize size = 0;
|
||||
gconstpointer icon_data = g_bytes_get_data(bytes, &size);
|
||||
ssize_t written = write(fd, icon_data, size);
|
||||
close(fd);
|
||||
if(written != size) {
|
||||
printf("failed to write temp icon file %s: %s\n", temp_file_name, strerror(errno));
|
||||
return FALSE;
|
||||
}
|
||||
app_indicator_set_icon_full(global_app_indicator, temp_file_name, "");
|
||||
app_indicator_set_attention_icon_full(global_app_indicator, temp_file_name, "");
|
||||
g_bytes_unref(bytes);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void _systray_menu_item_selected(int *id) {
|
||||
systray_menu_item_selected(*id);
|
||||
}
|
||||
|
||||
GtkMenuItem* find_menu_by_id(int id) {
|
||||
GList* it;
|
||||
for(it = global_menu_items; it != NULL; it = it->next) {
|
||||
MenuItemNode* item = (MenuItemNode*)(it->data);
|
||||
if(item->menu_id == id) {
|
||||
return GTK_MENU_ITEM(item->menu_item);
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// runs in main thread, should always return FALSE to prevent gtk to execute it again
|
||||
gboolean do_add_or_update_menu_item(gpointer data) {
|
||||
MenuItemInfo *mii = (MenuItemInfo*)data;
|
||||
GList* it;
|
||||
for(it = global_menu_items; it != NULL; it = it->next) {
|
||||
MenuItemNode* item = (MenuItemNode*)(it->data);
|
||||
if(item->menu_id == mii->menu_id) {
|
||||
gtk_menu_item_set_label(GTK_MENU_ITEM(item->menu_item), mii->title);
|
||||
|
||||
if (mii->isCheckable) {
|
||||
// We need to block the "activate" event, to emulate the same behaviour as in the windows version
|
||||
// A Check/Uncheck does change the checkbox, but does not trigger the checkbox menuItem channel
|
||||
g_signal_handler_block(GTK_CHECK_MENU_ITEM(item->menu_item), item->signalHandlerId);
|
||||
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item->menu_item), mii->checked == 1);
|
||||
g_signal_handler_unblock(GTK_CHECK_MENU_ITEM(item->menu_item), item->signalHandlerId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// menu id doesn't exist, add new item
|
||||
if(it == NULL) {
|
||||
GtkWidget *menu_item;
|
||||
if (mii->isCheckable) {
|
||||
menu_item = gtk_check_menu_item_new_with_label(mii->title);
|
||||
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(menu_item), mii->checked == 1);
|
||||
} else {
|
||||
menu_item = gtk_menu_item_new_with_label(mii->title);
|
||||
}
|
||||
int *id = malloc(sizeof(int));
|
||||
*id = mii->menu_id;
|
||||
long signalHandlerId = g_signal_connect_swapped(
|
||||
G_OBJECT(menu_item),
|
||||
"activate",
|
||||
G_CALLBACK(_systray_menu_item_selected),
|
||||
id
|
||||
);
|
||||
|
||||
if (mii->parent_menu_id == 0) {
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(global_tray_menu), menu_item);
|
||||
} else {
|
||||
GtkMenuItem* parentMenuItem = find_menu_by_id(mii->parent_menu_id);
|
||||
GtkWidget* parentMenu = gtk_menu_item_get_submenu(parentMenuItem);
|
||||
|
||||
if(parentMenu == NULL) {
|
||||
parentMenu = gtk_menu_new();
|
||||
gtk_menu_item_set_submenu(parentMenuItem, parentMenu);
|
||||
}
|
||||
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(parentMenu), menu_item);
|
||||
}
|
||||
|
||||
MenuItemNode* new_item = malloc(sizeof(MenuItemNode));
|
||||
new_item->menu_id = mii->menu_id;
|
||||
new_item->signalHandlerId = signalHandlerId;
|
||||
new_item->menu_item = menu_item;
|
||||
GList* new_node = malloc(sizeof(GList));
|
||||
new_node->data = new_item;
|
||||
new_node->next = global_menu_items;
|
||||
if(global_menu_items != NULL) {
|
||||
global_menu_items->prev = new_node;
|
||||
}
|
||||
global_menu_items = new_node;
|
||||
it = new_node;
|
||||
}
|
||||
GtkWidget* menu_item = GTK_WIDGET(((MenuItemNode*)(it->data))->menu_item);
|
||||
gtk_widget_set_sensitive(menu_item, mii->disabled != 1);
|
||||
gtk_widget_show(menu_item);
|
||||
|
||||
free(mii->title);
|
||||
free(mii->tooltip);
|
||||
free(mii);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
gboolean do_add_separator(gpointer data) {
|
||||
GtkWidget *separator = gtk_separator_menu_item_new();
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(global_tray_menu), separator);
|
||||
gtk_widget_show(separator);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// runs in main thread, should always return FALSE to prevent gtk to execute it again
|
||||
gboolean do_hide_menu_item(gpointer data) {
|
||||
MenuItemInfo *mii = (MenuItemInfo*)data;
|
||||
GList* it;
|
||||
for(it = global_menu_items; it != NULL; it = it->next) {
|
||||
MenuItemNode* item = (MenuItemNode*)(it->data);
|
||||
if(item->menu_id == mii->menu_id){
|
||||
gtk_widget_hide(GTK_WIDGET(item->menu_item));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// runs in main thread, should always return FALSE to prevent gtk to execute it again
|
||||
gboolean do_show_menu_item(gpointer data) {
|
||||
MenuItemInfo *mii = (MenuItemInfo*)data;
|
||||
GList* it;
|
||||
for(it = global_menu_items; it != NULL; it = it->next) {
|
||||
MenuItemNode* item = (MenuItemNode*)(it->data);
|
||||
if(item->menu_id == mii->menu_id){
|
||||
gtk_widget_show(GTK_WIDGET(item->menu_item));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// runs in main thread, should always return FALSE to prevent gtk to execute it again
|
||||
gboolean do_quit(gpointer data) {
|
||||
_unlink_temp_file();
|
||||
// app indicator doesn't provide a way to remove it, hide it as a workaround
|
||||
app_indicator_set_status(global_app_indicator, APP_INDICATOR_STATUS_PASSIVE);
|
||||
gtk_main_quit();
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void setIcon(const char* iconBytes, int length, bool template) {
|
||||
GBytes* bytes = g_bytes_new_static(iconBytes, length);
|
||||
g_idle_add(do_set_icon, bytes);
|
||||
}
|
||||
|
||||
void setTitle(char* ctitle) {
|
||||
app_indicator_set_label(global_app_indicator, ctitle, "");
|
||||
free(ctitle);
|
||||
}
|
||||
|
||||
void setTooltip(char* ctooltip) {
|
||||
free(ctooltip);
|
||||
}
|
||||
|
||||
void setMenuItemIcon(const char* iconBytes, int length, int menuId, bool template) {
|
||||
}
|
||||
|
||||
void add_or_update_menu_item(int menu_id, int parent_menu_id, char* title, char* tooltip, short disabled, short checked, short isCheckable) {
|
||||
MenuItemInfo *mii = malloc(sizeof(MenuItemInfo));
|
||||
mii->menu_id = menu_id;
|
||||
mii->parent_menu_id = parent_menu_id;
|
||||
mii->title = title;
|
||||
mii->tooltip = tooltip;
|
||||
mii->disabled = disabled;
|
||||
mii->checked = checked;
|
||||
mii->isCheckable = isCheckable;
|
||||
g_idle_add(do_add_or_update_menu_item, mii);
|
||||
}
|
||||
|
||||
void add_separator(int menu_id) {
|
||||
MenuItemInfo *mii = malloc(sizeof(MenuItemInfo));
|
||||
mii->menu_id = menu_id;
|
||||
g_idle_add(do_add_separator, mii);
|
||||
}
|
||||
|
||||
void hide_menu_item(int menu_id) {
|
||||
MenuItemInfo *mii = malloc(sizeof(MenuItemInfo));
|
||||
mii->menu_id = menu_id;
|
||||
g_idle_add(do_hide_menu_item, mii);
|
||||
}
|
||||
|
||||
void show_menu_item(int menu_id) {
|
||||
MenuItemInfo *mii = malloc(sizeof(MenuItemInfo));
|
||||
mii->menu_id = menu_id;
|
||||
g_idle_add(do_show_menu_item, mii);
|
||||
}
|
||||
|
||||
void quit() {
|
||||
g_idle_add(do_quit, NULL);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package systray
|
||||
|
||||
/*
|
||||
#cgo darwin CFLAGS: -DDARWIN -x objective-c -fobjc-arc
|
||||
#cgo darwin LDFLAGS: -framework Cocoa -framework WebKit
|
||||
|
||||
#include "systray.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// SetTemplateIcon sets the systray icon as a template icon (on Mac), falling back
|
||||
// to a regular icon on other platforms.
|
||||
// templateIconBytes and regularIconBytes should be the content of .ico for windows and
|
||||
// .ico/.jpg/.png for other platforms.
|
||||
func SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) {
|
||||
cstr := (*C.char)(unsafe.Pointer(&templateIconBytes[0]))
|
||||
C.setIcon(cstr, (C.int)(len(templateIconBytes)), true)
|
||||
}
|
||||
|
||||
// SetIcon sets the icon of a menu item. Only works on macOS and Windows.
|
||||
// iconBytes should be the content of .ico/.jpg/.png
|
||||
func (item *MenuItem) SetIcon(iconBytes []byte) {
|
||||
cstr := (*C.char)(unsafe.Pointer(&iconBytes[0]))
|
||||
C.setMenuItemIcon(cstr, (C.int)(len(iconBytes)), C.int(item.id), false)
|
||||
}
|
||||
|
||||
// SetTemplateIcon sets the icon of a menu item as a template icon (on macOS). On Windows, it
|
||||
// falls back to the regular icon bytes and on Linux it does nothing.
|
||||
// templateIconBytes and regularIconBytes should be the content of .ico for windows and
|
||||
// .ico/.jpg/.png for other platforms.
|
||||
func (item *MenuItem) SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) {
|
||||
cstr := (*C.char)(unsafe.Pointer(&templateIconBytes[0]))
|
||||
C.setMenuItemIcon(cstr, (C.int)(len(templateIconBytes)), C.int(item.id), true)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
//go:build never
|
||||
// +build never
|
||||
|
||||
package systray
|
||||
|
||||
/*
|
||||
#cgo darwin CFLAGS: -DDARWIN -x objective-c -fobjc-arc
|
||||
#cgo darwin LDFLAGS: -framework Cocoa -framework WebKit
|
||||
|
||||
#include "systray.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
// SetTemplateIcon sets the systray icon as a template icon (on macOS), falling back
|
||||
// to a regular icon on other platforms.
|
||||
// templateIconBytes and iconBytes should be the content of .ico for windows and
|
||||
// .ico/.jpg/.png for other platforms.
|
||||
func SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) {
|
||||
SetIcon(regularIconBytes)
|
||||
}
|
||||
|
||||
// SetIcon sets the icon of a menu item. Only works on macOS and Windows.
|
||||
// iconBytes should be the content of .ico/.jpg/.png
|
||||
func (item *MenuItem) SetIcon(iconBytes []byte) {
|
||||
}
|
||||
|
||||
// SetTemplateIcon sets the icon of a menu item as a template icon (on macOS). On Windows, it
|
||||
// falls back to the regular icon bytes and on Linux it does nothing.
|
||||
// templateIconBytes and regularIconBytes should be the content of .ico for windows and
|
||||
// .ico/.jpg/.png for other platforms.
|
||||
func (item *MenuItem) SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) {
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
package systray
|
||||
|
||||
/*
|
||||
#cgo darwin CFLAGS: -DDARWIN -x objective-c -fobjc-arc
|
||||
#cgo darwin LDFLAGS: -framework Cocoa
|
||||
|
||||
#include "systray.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func registerSystray() {
|
||||
C.registerSystray()
|
||||
}
|
||||
|
||||
func nativeLoop() {
|
||||
C.nativeLoop()
|
||||
}
|
||||
|
||||
func quit() {
|
||||
C.quit()
|
||||
}
|
||||
|
||||
// SetIcon sets the systray icon.
|
||||
// iconBytes should be the content of .ico for windows and .ico/.jpg/.png
|
||||
// for other platforms.
|
||||
func SetIcon(iconBytes []byte) {
|
||||
cstr := (*C.char)(unsafe.Pointer(&iconBytes[0]))
|
||||
C.setIcon(cstr, (C.int)(len(iconBytes)), false)
|
||||
}
|
||||
|
||||
// SetTitle sets the systray title, only available on Mac and Linux.
|
||||
func SetTitle(title string) {
|
||||
C.setTitle(C.CString(title))
|
||||
}
|
||||
|
||||
// SetTooltip sets the systray tooltip to display on mouse hover of the tray icon,
|
||||
// only available on Mac and Windows.
|
||||
func SetTooltip(tooltip string) {
|
||||
C.setTooltip(C.CString(tooltip))
|
||||
}
|
||||
|
||||
func addOrUpdateMenuItem(item *MenuItem) {
|
||||
var disabled C.short
|
||||
if item.disabled {
|
||||
disabled = 1
|
||||
}
|
||||
var checked C.short
|
||||
if item.checked {
|
||||
checked = 1
|
||||
}
|
||||
var isCheckable C.short
|
||||
if item.isCheckable {
|
||||
isCheckable = 1
|
||||
}
|
||||
var parentID uint32 = 0
|
||||
if item.parent != nil {
|
||||
parentID = item.parent.id
|
||||
}
|
||||
C.add_or_update_menu_item(
|
||||
C.int(item.id),
|
||||
C.int(parentID),
|
||||
C.CString(item.title),
|
||||
C.CString(item.tooltip),
|
||||
disabled,
|
||||
checked,
|
||||
isCheckable,
|
||||
)
|
||||
}
|
||||
|
||||
func addSeparator(id uint32) {
|
||||
C.add_separator(C.int(id))
|
||||
}
|
||||
|
||||
func hideMenuItem(item *MenuItem) {
|
||||
C.hide_menu_item(
|
||||
C.int(item.id),
|
||||
)
|
||||
}
|
||||
|
||||
func showMenuItem(item *MenuItem) {
|
||||
C.show_menu_item(
|
||||
C.int(item.id),
|
||||
)
|
||||
}
|
||||
|
||||
//export systray_ready
|
||||
func systray_ready() {
|
||||
systrayReady()
|
||||
}
|
||||
|
||||
//export systray_on_exit
|
||||
func systray_on_exit() {
|
||||
systrayExit()
|
||||
}
|
||||
|
||||
//export systray_menu_item_selected
|
||||
func systray_menu_item_selected(cID C.int) {
|
||||
systrayMenuItemSelected(uint32(cID))
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package systray
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const iconFilePath = "example/icon/iconwin.ico"
|
||||
|
||||
func TestBaseWindowsTray(t *testing.T) {
|
||||
systrayReady = func() {}
|
||||
systrayExit = func() {}
|
||||
|
||||
runtime.LockOSThread()
|
||||
|
||||
if err := wt.initInstance(); err != nil {
|
||||
t.Fatalf("initInstance failed: %s", err)
|
||||
}
|
||||
|
||||
if err := wt.createMenu(); err != nil {
|
||||
t.Fatalf("createMenu failed: %s", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
pDestroyWindow.Call(uintptr(wt.window))
|
||||
wt.wcex.unregister()
|
||||
}()
|
||||
|
||||
if err := wt.setIcon(iconFilePath); err != nil {
|
||||
t.Errorf("SetIcon failed: %s", err)
|
||||
}
|
||||
|
||||
if err := wt.setTooltip("Cyrillic tooltip тест:)"); err != nil {
|
||||
t.Errorf("SetIcon failed: %s", err)
|
||||
}
|
||||
|
||||
var id int32 = 0
|
||||
err := wt.addOrUpdateMenuItem(atomic.AddInt32(&id, 1), "Simple enabled", false, false)
|
||||
if err != nil {
|
||||
t.Errorf("mergeMenuItem failed: %s", err)
|
||||
}
|
||||
err = wt.addOrUpdateMenuItem(atomic.AddInt32(&id, 1), "Simple disabled", true, false)
|
||||
if err != nil {
|
||||
t.Errorf("mergeMenuItem failed: %s", err)
|
||||
}
|
||||
err = wt.addSeparatorMenuItem(atomic.AddInt32(&id, 1))
|
||||
if err != nil {
|
||||
t.Errorf("addSeparatorMenuItem failed: %s", err)
|
||||
}
|
||||
err = wt.addOrUpdateMenuItem(atomic.AddInt32(&id, 1), "Simple checked enabled", false, true)
|
||||
if err != nil {
|
||||
t.Errorf("mergeMenuItem failed: %s", err)
|
||||
}
|
||||
err = wt.addOrUpdateMenuItem(atomic.AddInt32(&id, 1), "Simple checked disabled", true, true)
|
||||
if err != nil {
|
||||
t.Errorf("mergeMenuItem failed: %s", err)
|
||||
}
|
||||
|
||||
err = wt.hideMenuItem(1)
|
||||
if err != nil {
|
||||
t.Errorf("hideMenuItem failed: %s", err)
|
||||
}
|
||||
|
||||
err = wt.hideMenuItem(100)
|
||||
if err == nil {
|
||||
t.Error("hideMenuItem failed: must return error on invalid item id")
|
||||
}
|
||||
|
||||
err = wt.addOrUpdateMenuItem(2, "Simple disabled update", true, false)
|
||||
if err != nil {
|
||||
t.Errorf("mergeMenuItem failed: %s", err)
|
||||
}
|
||||
|
||||
time.AfterFunc(1*time.Second, quit)
|
||||
|
||||
m := struct {
|
||||
WindowHandle windows.Handle
|
||||
Message uint32
|
||||
Wparam uintptr
|
||||
Lparam uintptr
|
||||
Time uint32
|
||||
Pt point
|
||||
}{}
|
||||
for {
|
||||
ret, _, err := pGetMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
|
||||
res := int32(ret)
|
||||
if res == -1 {
|
||||
t.Errorf("win32 GetMessage failed: %v", err)
|
||||
return
|
||||
} else if res == 0 {
|
||||
break
|
||||
}
|
||||
pTranslateMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||
pDispatchMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsRun(t *testing.T) {
|
||||
onReady := func() {
|
||||
b, err := ioutil.ReadFile(iconFilePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Can't load icon file: %v", err)
|
||||
}
|
||||
SetIcon(b)
|
||||
SetTitle("Test title с кириллицей")
|
||||
|
||||
bSomeBtn := AddMenuItem("Йа кнопко", "")
|
||||
bSomeBtn.Check()
|
||||
AddSeparator()
|
||||
bQuit := AddMenuItem("Quit", "Quit the whole app")
|
||||
go func() {
|
||||
<-bQuit.ClickedCh
|
||||
t.Log("Quit reqested")
|
||||
Quit()
|
||||
}()
|
||||
time.AfterFunc(1*time.Second, Quit)
|
||||
}
|
||||
|
||||
onExit := func() {
|
||||
t.Log("Exit success")
|
||||
}
|
||||
|
||||
Run(onReady, onExit)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -69,10 +68,10 @@ func DiscoverFeed(candidateUrl string) (*DiscoverResult, error) {
|
||||
}
|
||||
switch {
|
||||
case len(sources) == 0:
|
||||
return nil, errors.New("No feeds found at the given url")
|
||||
return nil, errors.New("no feeds found at the given url")
|
||||
case len(sources) == 1:
|
||||
if sources[0].Url == candidateUrl {
|
||||
return nil, errors.New("Recursion!")
|
||||
return nil, errors.New("recursion")
|
||||
}
|
||||
return DiscoverFeed(sources[0].Url)
|
||||
}
|
||||
@@ -103,7 +102,7 @@ func findFavicon(siteUrl, feedUrl string) (*[]byte, error) {
|
||||
if siteUrl != "" {
|
||||
if res, err := client.get(siteUrl); err == nil {
|
||||
defer res.Body.Close()
|
||||
if body, err := ioutil.ReadAll(res.Body); err == nil {
|
||||
if body, err := io.ReadAll(res.Body); err == nil {
|
||||
urls = append(urls, scraper.FindIcons(string(body), siteUrl)...)
|
||||
if c := favicon(siteUrl); c != "" {
|
||||
urls = append(urls, c)
|
||||
@@ -126,7 +125,7 @@ func findFavicon(siteUrl, feedUrl string) (*[]byte, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := ioutil.ReadAll(res.Body)
|
||||
content, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -142,7 +141,6 @@ func findFavicon(siteUrl, feedUrl string) (*[]byte, error) {
|
||||
func ConvertItems(items []parser.Item, feed storage.Feed) []storage.Item {
|
||||
result := make([]storage.Item, len(items))
|
||||
for i, item := range items {
|
||||
item := item
|
||||
mediaLinks := make(storage.MediaLinks, 0)
|
||||
for _, link := range item.MediaLinks {
|
||||
mediaLinks = append(mediaLinks, storage.MediaLink(link))
|
||||
|
||||
@@ -53,7 +53,7 @@ func (w *Worker) FindFeedFavicon(feed storage.Feed) {
|
||||
log.Printf("Failed to find favicon for %s (%s): %s", feed.FeedLink, feed.Link, err)
|
||||
}
|
||||
if icon != nil {
|
||||
w.db.UpdateFeedIcon(feed.Id, icon)
|
||||
w.db.UpdateFeed(feed.Id, storage.UpdateFeedParams{Icon: storage.SetNullable(icon)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,18 +113,17 @@ func (w *Worker) refresher(feeds []storage.Feed) {
|
||||
srcqueue := make(chan storage.Feed, len(feeds))
|
||||
dstqueue := make(chan []storage.Item)
|
||||
|
||||
for i := 0; i < NUM_WORKERS; i++ {
|
||||
for range NUM_WORKERS {
|
||||
go w.worker(srcqueue, dstqueue)
|
||||
}
|
||||
|
||||
for _, feed := range feeds {
|
||||
srcqueue <- feed
|
||||
}
|
||||
for i := 0; i < len(feeds); i++ {
|
||||
for range feeds {
|
||||
items := <-dstqueue
|
||||
if len(items) > 0 {
|
||||
w.db.CreateItems(items)
|
||||
w.db.SetFeedSize(items[0].FeedId, len(items))
|
||||
}
|
||||
atomic.AddInt32(w.pending, -1)
|
||||
w.db.SyncSearch()
|
||||
|
||||
1
src/systray/.gitignore → vendor/fyne.io/systray/.gitignore
generated
vendored
@@ -10,3 +10,4 @@ dll/systray_unsigned.dll
|
||||
out.txt
|
||||
.vs
|
||||
on_exit*.txt
|
||||
.vscode
|
||||
0
src/systray/CHANGELOG.md → vendor/fyne.io/systray/CHANGELOG.md
generated
vendored
0
src/systray/LICENSE → vendor/fyne.io/systray/LICENSE
generated
vendored
0
src/systray/Makefile → vendor/fyne.io/systray/Makefile
generated
vendored
147
vendor/fyne.io/systray/README.md
generated
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
# Systray
|
||||
|
||||
systray is a cross-platform Go library to place an icon and menu in the notification area.
|
||||
This repository is a fork of [getlantern/systray](https://github.com/getlantern/systray)
|
||||
removing the GTK dependency and support for legacy linux system tray.
|
||||
|
||||
## Features
|
||||
|
||||
* Supported on Windows, macOS, Linux and many BSD systems
|
||||
* Menu items can be checked and/or disabled
|
||||
* Methods may be called from any Goroutine
|
||||
|
||||
## API
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "fyne.io/systray"
|
||||
import "fyne.io/systray/example/icon"
|
||||
|
||||
func main() {
|
||||
systray.Run(onReady, onExit)
|
||||
}
|
||||
|
||||
func onReady() {
|
||||
systray.SetIcon(icon.Data)
|
||||
systray.SetTitle("Awesome App")
|
||||
systray.SetTooltip("Pretty awesome超级棒")
|
||||
mQuit := systray.AddMenuItem("Quit", "Quit the whole app")
|
||||
|
||||
// Sets the icon of a menu item.
|
||||
mQuit.SetIcon(icon.Data)
|
||||
}
|
||||
|
||||
func onExit() {
|
||||
// clean up here
|
||||
}
|
||||
```
|
||||
|
||||
### Running in a Fyne app
|
||||
|
||||
This repository is designed to allow any toolkit to integrate system tray without any additional dependencies.
|
||||
It is maintained by the Fyne team, but if you are using Fyne there is an even easier to use API in the main repository that wraps this project.
|
||||
|
||||
In your app you can use a standard `fyne.Menu` structure and pass it to `SetSystemTrayMenu` when your app is a desktop app, as follows:
|
||||
|
||||
```go
|
||||
menu := fyne.NewMenu("MyApp",
|
||||
fyne.NewMenuItem("Show", func() {
|
||||
log.Println("Tapped show")
|
||||
}))
|
||||
|
||||
if desk, ok := myApp.(desktop.App); ok {
|
||||
desk.SetSystemTrayMenu(menu)
|
||||
}
|
||||
```
|
||||
|
||||
You can find out more in the toolkit documentation:
|
||||
[System Tray Menu](https://developer.fyne.io/explore/systray).
|
||||
|
||||
### Run in another toolkit
|
||||
|
||||
Most graphical toolkits will grab the main loop so the `Run` code above is not possible.
|
||||
For this reason there is another entry point `RunWithExternalLoop`.
|
||||
This function of the library returns a start and end function that should be called
|
||||
when the application has started and will end, to loop in appropriate features.
|
||||
|
||||
See [full API](https://pkg.go.dev/fyne.io/systray?tab=doc) as well as [CHANGELOG](https://github.com/fyne-io/systray/tree/master/CHANGELOG.md).
|
||||
|
||||
Note: this package requires cgo, so make sure you set `CGO_ENABLED=1` before building.
|
||||
|
||||
## Try the example app!
|
||||
|
||||
Have go v1.12+ or higher installed? Here's an example to get started on macOS or Linux:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/fyne-io/systray
|
||||
cd systray/example
|
||||
go run .
|
||||
```
|
||||
|
||||
On Windows, you should follow the instructions above, but use the followign run command:
|
||||
|
||||
```
|
||||
go run -ldflags "-H=windowsgui" .
|
||||
```
|
||||
|
||||
Now look for *Awesome App* in your menu bar!
|
||||
|
||||

|
||||
|
||||
## Platform notes
|
||||
|
||||
### Linux/BSD
|
||||
|
||||
This implementation uses DBus to communicate through the SystemNotifier/AppIndicator spec, older tray implementations may not load the icon.
|
||||
|
||||
If you are running an older desktop environment, or system tray provider, you may require a proxy app which can convert the new DBus calls to the old format.
|
||||
The recommended tool for Gnome based trays is [snixembed](https://git.sr.ht/~steef/snixembed), others are available.
|
||||
Search for "StatusNotifierItems XEmbedded" in your package manager.
|
||||
|
||||
### Windows
|
||||
|
||||
* To avoid opening a console at application startup, use "fyne package" for your app or manually use these compile flags:
|
||||
|
||||
```sh
|
||||
go build -ldflags -H=windowsgui
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
On macOS, you will need to create an application bundle to wrap the binary; simply use "fyne package" or add folders with the following minimal structure and assets:
|
||||
|
||||
```
|
||||
SystrayApp.app/
|
||||
Contents/
|
||||
Info.plist
|
||||
MacOS/
|
||||
go-executable
|
||||
Resources/
|
||||
SystrayApp.icns
|
||||
```
|
||||
|
||||
If bundling manually, you may want to add one or both of the following to your Info.plist:
|
||||
|
||||
```xml
|
||||
<!-- avoid having a blurry icon and text -->
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>True</string>
|
||||
|
||||
<!-- avoid showing the app on the Dock -->
|
||||
<key>LSUIElement</key>
|
||||
<string>1</string>
|
||||
```
|
||||
|
||||
Consult the [Official Apple Documentation here](https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html#//apple_ref/doc/uid/10000123i-CH101-SW1).
|
||||
|
||||
On macOS, it's possible to set the underlying
|
||||
[`NSStatusItemBehavior`](https://developer.apple.com/documentation/appkit/nsstatusitembehavior?language=objc)
|
||||
with `systray.SetRemovalAllowed(true)`. When enabled, the user can cmd-drag the
|
||||
icon off the menu bar.
|
||||
|
||||
## Credits
|
||||
|
||||
- https://github.com/getlantern/systray
|
||||
- https://github.com/xilp/systray
|
||||
- https://github.com/cratonica/trayhost
|
||||
484
vendor/fyne.io/systray/internal/generated/menu/dbus_menu.go
generated
vendored
Normal file
@@ -0,0 +1,484 @@
|
||||
// Code generated by dbus-codegen-go DO NOT EDIT.
|
||||
package menu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/godbus/dbus/v5/introspect"
|
||||
)
|
||||
|
||||
var (
|
||||
// Introspection for com.canonical.dbusmenu
|
||||
IntrospectDataDbusmenu = introspect.Interface{
|
||||
Name: "com.canonical.dbusmenu",
|
||||
Methods: []introspect.Method{{Name: "GetLayout", Args: []introspect.Arg{
|
||||
{Name: "parentId", Type: "i", Direction: "in"},
|
||||
{Name: "recursionDepth", Type: "i", Direction: "in"},
|
||||
{Name: "propertyNames", Type: "as", Direction: "in"},
|
||||
{Name: "revision", Type: "u", Direction: "out"},
|
||||
{Name: "layout", Type: "(ia{sv}av)", Direction: "out"},
|
||||
}},
|
||||
{Name: "GetGroupProperties", Args: []introspect.Arg{
|
||||
{Name: "ids", Type: "ai", Direction: "in"},
|
||||
{Name: "propertyNames", Type: "as", Direction: "in"},
|
||||
{Name: "properties", Type: "a(ia{sv})", Direction: "out"},
|
||||
}},
|
||||
{Name: "GetProperty", Args: []introspect.Arg{
|
||||
{Name: "id", Type: "i", Direction: "in"},
|
||||
{Name: "name", Type: "s", Direction: "in"},
|
||||
{Name: "value", Type: "v", Direction: "out"},
|
||||
}},
|
||||
{Name: "Event", Args: []introspect.Arg{
|
||||
{Name: "id", Type: "i", Direction: "in"},
|
||||
{Name: "eventId", Type: "s", Direction: "in"},
|
||||
{Name: "data", Type: "v", Direction: "in"},
|
||||
{Name: "timestamp", Type: "u", Direction: "in"},
|
||||
}},
|
||||
{Name: "EventGroup", Args: []introspect.Arg{
|
||||
{Name: "events", Type: "a(isvu)", Direction: "in"},
|
||||
{Name: "idErrors", Type: "ai", Direction: "out"},
|
||||
}},
|
||||
{Name: "AboutToShow", Args: []introspect.Arg{
|
||||
{Name: "id", Type: "i", Direction: "in"},
|
||||
{Name: "needUpdate", Type: "b", Direction: "out"},
|
||||
}},
|
||||
{Name: "AboutToShowGroup", Args: []introspect.Arg{
|
||||
{Name: "ids", Type: "ai", Direction: "in"},
|
||||
{Name: "updatesNeeded", Type: "ai", Direction: "out"},
|
||||
{Name: "idErrors", Type: "ai", Direction: "out"},
|
||||
}},
|
||||
},
|
||||
Signals: []introspect.Signal{{Name: "ItemsPropertiesUpdated", Args: []introspect.Arg{
|
||||
{Name: "updatedProps", Type: "a(ia{sv})", Direction: "out"},
|
||||
{Name: "removedProps", Type: "a(ias)", Direction: "out"},
|
||||
}},
|
||||
{Name: "LayoutUpdated", Args: []introspect.Arg{
|
||||
{Name: "revision", Type: "u", Direction: "out"},
|
||||
{Name: "parent", Type: "i", Direction: "out"},
|
||||
}},
|
||||
{Name: "ItemActivationRequested", Args: []introspect.Arg{
|
||||
{Name: "id", Type: "i", Direction: "out"},
|
||||
{Name: "timestamp", Type: "u", Direction: "out"},
|
||||
}},
|
||||
},
|
||||
Properties: []introspect.Property{{Name: "Version", Type: "u", Access: "read"},
|
||||
{Name: "TextDirection", Type: "s", Access: "read"},
|
||||
{Name: "Status", Type: "s", Access: "read"},
|
||||
{Name: "IconThemePath", Type: "as", Access: "read"},
|
||||
},
|
||||
Annotations: []introspect.Annotation{},
|
||||
}
|
||||
)
|
||||
|
||||
// Signal is a common interface for all signals.
|
||||
type Signal interface {
|
||||
Name() string
|
||||
Interface() string
|
||||
Sender() string
|
||||
|
||||
path() dbus.ObjectPath
|
||||
values() []interface{}
|
||||
}
|
||||
|
||||
// Emit sends the given signal to the bus.
|
||||
func Emit(conn *dbus.Conn, s Signal) error {
|
||||
return conn.Emit(s.path(), s.Interface()+"."+s.Name(), s.values()...)
|
||||
}
|
||||
|
||||
// ErrUnknownSignal is returned by LookupSignal when a signal cannot be resolved.
|
||||
var ErrUnknownSignal = errors.New("unknown signal")
|
||||
|
||||
// LookupSignal converts the given raw D-Bus signal with variable body
|
||||
// into one with typed structured body or returns ErrUnknownSignal error.
|
||||
func LookupSignal(signal *dbus.Signal) (Signal, error) {
|
||||
switch signal.Name {
|
||||
case InterfaceDbusmenu + "." + "ItemsPropertiesUpdated":
|
||||
v0, ok := signal.Body[0].([]struct {
|
||||
V0 int32
|
||||
V1 map[string]dbus.Variant
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("prop .UpdatedProps is %T, not []struct {V0 int32;V1 map[string]dbus.Variant}", signal.Body[0])
|
||||
}
|
||||
v1, ok := signal.Body[1].([]struct {
|
||||
V0 int32
|
||||
V1 []string
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("prop .RemovedProps is %T, not []struct {V0 int32;V1 []string}", signal.Body[1])
|
||||
}
|
||||
return &Dbusmenu_ItemsPropertiesUpdatedSignal{
|
||||
sender: signal.Sender,
|
||||
Path: signal.Path,
|
||||
Body: &Dbusmenu_ItemsPropertiesUpdatedSignalBody{
|
||||
UpdatedProps: v0,
|
||||
RemovedProps: v1,
|
||||
},
|
||||
}, nil
|
||||
case InterfaceDbusmenu + "." + "LayoutUpdated":
|
||||
v0, ok := signal.Body[0].(uint32)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("prop .Revision is %T, not uint32", signal.Body[0])
|
||||
}
|
||||
v1, ok := signal.Body[1].(int32)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("prop .Parent is %T, not int32", signal.Body[1])
|
||||
}
|
||||
return &Dbusmenu_LayoutUpdatedSignal{
|
||||
sender: signal.Sender,
|
||||
Path: signal.Path,
|
||||
Body: &Dbusmenu_LayoutUpdatedSignalBody{
|
||||
Revision: v0,
|
||||
Parent: v1,
|
||||
},
|
||||
}, nil
|
||||
case InterfaceDbusmenu + "." + "ItemActivationRequested":
|
||||
v0, ok := signal.Body[0].(int32)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("prop .Id is %T, not int32", signal.Body[0])
|
||||
}
|
||||
v1, ok := signal.Body[1].(uint32)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("prop .Timestamp is %T, not uint32", signal.Body[1])
|
||||
}
|
||||
return &Dbusmenu_ItemActivationRequestedSignal{
|
||||
sender: signal.Sender,
|
||||
Path: signal.Path,
|
||||
Body: &Dbusmenu_ItemActivationRequestedSignalBody{
|
||||
Id: v0,
|
||||
Timestamp: v1,
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return nil, ErrUnknownSignal
|
||||
}
|
||||
}
|
||||
|
||||
// AddMatchSignal registers a match rule for the given signal,
|
||||
// opts are appended to the automatically generated signal's rules.
|
||||
func AddMatchSignal(conn *dbus.Conn, s Signal, opts ...dbus.MatchOption) error {
|
||||
return conn.AddMatchSignal(append([]dbus.MatchOption{
|
||||
dbus.WithMatchInterface(s.Interface()),
|
||||
dbus.WithMatchMember(s.Name()),
|
||||
}, opts...)...)
|
||||
}
|
||||
|
||||
// RemoveMatchSignal unregisters the previously registered subscription.
|
||||
func RemoveMatchSignal(conn *dbus.Conn, s Signal, opts ...dbus.MatchOption) error {
|
||||
return conn.RemoveMatchSignal(append([]dbus.MatchOption{
|
||||
dbus.WithMatchInterface(s.Interface()),
|
||||
dbus.WithMatchMember(s.Name()),
|
||||
}, opts...)...)
|
||||
}
|
||||
|
||||
// Interface name constants.
|
||||
const (
|
||||
InterfaceDbusmenu = "com.canonical.dbusmenu"
|
||||
)
|
||||
|
||||
// Dbusmenuer is com.canonical.dbusmenu interface.
|
||||
type Dbusmenuer interface {
|
||||
// GetLayout is com.canonical.dbusmenu.GetLayout method.
|
||||
GetLayout(parentId int32, recursionDepth int32, propertyNames []string) (revision uint32, layout struct {
|
||||
V0 int32
|
||||
V1 map[string]dbus.Variant
|
||||
V2 []dbus.Variant
|
||||
}, err *dbus.Error)
|
||||
// GetGroupProperties is com.canonical.dbusmenu.GetGroupProperties method.
|
||||
GetGroupProperties(ids []int32, propertyNames []string) (properties []struct {
|
||||
V0 int32
|
||||
V1 map[string]dbus.Variant
|
||||
}, err *dbus.Error)
|
||||
// GetProperty is com.canonical.dbusmenu.GetProperty method.
|
||||
GetProperty(id int32, name string) (value dbus.Variant, err *dbus.Error)
|
||||
// Event is com.canonical.dbusmenu.Event method.
|
||||
Event(id int32, eventId string, data dbus.Variant, timestamp uint32) (err *dbus.Error)
|
||||
// EventGroup is com.canonical.dbusmenu.EventGroup method.
|
||||
EventGroup(events []struct {
|
||||
V0 int32
|
||||
V1 string
|
||||
V2 dbus.Variant
|
||||
V3 uint32
|
||||
}) (idErrors []int32, err *dbus.Error)
|
||||
// AboutToShow is com.canonical.dbusmenu.AboutToShow method.
|
||||
AboutToShow(id int32) (needUpdate bool, err *dbus.Error)
|
||||
// AboutToShowGroup is com.canonical.dbusmenu.AboutToShowGroup method.
|
||||
AboutToShowGroup(ids []int32) (updatesNeeded []int32, idErrors []int32, err *dbus.Error)
|
||||
}
|
||||
|
||||
// ExportDbusmenu exports the given object that implements com.canonical.dbusmenu on the bus.
|
||||
func ExportDbusmenu(conn *dbus.Conn, path dbus.ObjectPath, v Dbusmenuer) error {
|
||||
return conn.ExportSubtreeMethodTable(map[string]interface{}{
|
||||
"GetLayout": v.GetLayout,
|
||||
"GetGroupProperties": v.GetGroupProperties,
|
||||
"GetProperty": v.GetProperty,
|
||||
"Event": v.Event,
|
||||
"EventGroup": v.EventGroup,
|
||||
"AboutToShow": v.AboutToShow,
|
||||
"AboutToShowGroup": v.AboutToShowGroup,
|
||||
}, path, InterfaceDbusmenu)
|
||||
}
|
||||
|
||||
// UnexportDbusmenu unexports com.canonical.dbusmenu interface on the named path.
|
||||
func UnexportDbusmenu(conn *dbus.Conn, path dbus.ObjectPath) error {
|
||||
return conn.Export(nil, path, InterfaceDbusmenu)
|
||||
}
|
||||
|
||||
// UnimplementedDbusmenu can be embedded to have forward compatible server implementations.
|
||||
type UnimplementedDbusmenu struct{}
|
||||
|
||||
func (*UnimplementedDbusmenu) iface() string {
|
||||
return InterfaceDbusmenu
|
||||
}
|
||||
|
||||
func (*UnimplementedDbusmenu) GetLayout(parentId int32, recursionDepth int32, propertyNames []string) (revision uint32, layout struct {
|
||||
V0 int32
|
||||
V1 map[string]dbus.Variant
|
||||
V2 []dbus.Variant
|
||||
}, err *dbus.Error) {
|
||||
err = &dbus.ErrMsgUnknownMethod
|
||||
return
|
||||
}
|
||||
|
||||
func (*UnimplementedDbusmenu) GetGroupProperties(ids []int32, propertyNames []string) (properties []struct {
|
||||
V0 int32
|
||||
V1 map[string]dbus.Variant
|
||||
}, err *dbus.Error) {
|
||||
err = &dbus.ErrMsgUnknownMethod
|
||||
return
|
||||
}
|
||||
|
||||
func (*UnimplementedDbusmenu) GetProperty(id int32, name string) (value dbus.Variant, err *dbus.Error) {
|
||||
err = &dbus.ErrMsgUnknownMethod
|
||||
return
|
||||
}
|
||||
|
||||
func (*UnimplementedDbusmenu) Event(id int32, eventId string, data dbus.Variant, timestamp uint32) (err *dbus.Error) {
|
||||
err = &dbus.ErrMsgUnknownMethod
|
||||
return
|
||||
}
|
||||
|
||||
func (*UnimplementedDbusmenu) EventGroup(events []struct {
|
||||
V0 int32
|
||||
V1 string
|
||||
V2 dbus.Variant
|
||||
V3 uint32
|
||||
}) (idErrors []int32, err *dbus.Error) {
|
||||
err = &dbus.ErrMsgUnknownMethod
|
||||
return
|
||||
}
|
||||
|
||||
func (*UnimplementedDbusmenu) AboutToShow(id int32) (needUpdate bool, err *dbus.Error) {
|
||||
err = &dbus.ErrMsgUnknownMethod
|
||||
return
|
||||
}
|
||||
|
||||
func (*UnimplementedDbusmenu) AboutToShowGroup(ids []int32) (updatesNeeded []int32, idErrors []int32, err *dbus.Error) {
|
||||
err = &dbus.ErrMsgUnknownMethod
|
||||
return
|
||||
}
|
||||
|
||||
// NewDbusmenu creates and allocates com.canonical.dbusmenu.
|
||||
func NewDbusmenu(object dbus.BusObject) *Dbusmenu {
|
||||
return &Dbusmenu{object}
|
||||
}
|
||||
|
||||
// Dbusmenu implements com.canonical.dbusmenu D-Bus interface.
|
||||
type Dbusmenu struct {
|
||||
object dbus.BusObject
|
||||
}
|
||||
|
||||
// GetLayout calls com.canonical.dbusmenu.GetLayout method.
|
||||
func (o *Dbusmenu) GetLayout(ctx context.Context, parentId int32, recursionDepth int32, propertyNames []string) (revision uint32, layout struct {
|
||||
V0 int32
|
||||
V1 map[string]dbus.Variant
|
||||
V2 []dbus.Variant
|
||||
}, err error) {
|
||||
err = o.object.CallWithContext(ctx, InterfaceDbusmenu+".GetLayout", 0, parentId, recursionDepth, propertyNames).Store(&revision, &layout)
|
||||
return
|
||||
}
|
||||
|
||||
// GetGroupProperties calls com.canonical.dbusmenu.GetGroupProperties method.
|
||||
func (o *Dbusmenu) GetGroupProperties(ctx context.Context, ids []int32, propertyNames []string) (properties []struct {
|
||||
V0 int32
|
||||
V1 map[string]dbus.Variant
|
||||
}, err error) {
|
||||
err = o.object.CallWithContext(ctx, InterfaceDbusmenu+".GetGroupProperties", 0, ids, propertyNames).Store(&properties)
|
||||
return
|
||||
}
|
||||
|
||||
// GetProperty calls com.canonical.dbusmenu.GetProperty method.
|
||||
func (o *Dbusmenu) GetProperty(ctx context.Context, id int32, name string) (value dbus.Variant, err error) {
|
||||
err = o.object.CallWithContext(ctx, InterfaceDbusmenu+".GetProperty", 0, id, name).Store(&value)
|
||||
return
|
||||
}
|
||||
|
||||
// Event calls com.canonical.dbusmenu.Event method.
|
||||
func (o *Dbusmenu) Event(ctx context.Context, id int32, eventId string, data dbus.Variant, timestamp uint32) (err error) {
|
||||
err = o.object.CallWithContext(ctx, InterfaceDbusmenu+".Event", 0, id, eventId, data, timestamp).Store()
|
||||
return
|
||||
}
|
||||
|
||||
// EventGroup calls com.canonical.dbusmenu.EventGroup method.
|
||||
func (o *Dbusmenu) EventGroup(ctx context.Context, events []struct {
|
||||
V0 int32
|
||||
V1 string
|
||||
V2 dbus.Variant
|
||||
V3 uint32
|
||||
}) (idErrors []int32, err error) {
|
||||
err = o.object.CallWithContext(ctx, InterfaceDbusmenu+".EventGroup", 0, events).Store(&idErrors)
|
||||
return
|
||||
}
|
||||
|
||||
// AboutToShow calls com.canonical.dbusmenu.AboutToShow method.
|
||||
func (o *Dbusmenu) AboutToShow(ctx context.Context, id int32) (needUpdate bool, err error) {
|
||||
err = o.object.CallWithContext(ctx, InterfaceDbusmenu+".AboutToShow", 0, id).Store(&needUpdate)
|
||||
return
|
||||
}
|
||||
|
||||
// AboutToShowGroup calls com.canonical.dbusmenu.AboutToShowGroup method.
|
||||
func (o *Dbusmenu) AboutToShowGroup(ctx context.Context, ids []int32) (updatesNeeded []int32, idErrors []int32, err error) {
|
||||
err = o.object.CallWithContext(ctx, InterfaceDbusmenu+".AboutToShowGroup", 0, ids).Store(&updatesNeeded, &idErrors)
|
||||
return
|
||||
}
|
||||
|
||||
// GetVersion gets com.canonical.dbusmenu.Version property.
|
||||
func (o *Dbusmenu) GetVersion(ctx context.Context) (version uint32, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceDbusmenu, "Version").Store(&version)
|
||||
return
|
||||
}
|
||||
|
||||
// GetTextDirection gets com.canonical.dbusmenu.TextDirection property.
|
||||
func (o *Dbusmenu) GetTextDirection(ctx context.Context) (textDirection string, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceDbusmenu, "TextDirection").Store(&textDirection)
|
||||
return
|
||||
}
|
||||
|
||||
// GetStatus gets com.canonical.dbusmenu.Status property.
|
||||
func (o *Dbusmenu) GetStatus(ctx context.Context) (status string, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceDbusmenu, "Status").Store(&status)
|
||||
return
|
||||
}
|
||||
|
||||
// GetIconThemePath gets com.canonical.dbusmenu.IconThemePath property.
|
||||
func (o *Dbusmenu) GetIconThemePath(ctx context.Context) (iconThemePath []string, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceDbusmenu, "IconThemePath").Store(&iconThemePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Dbusmenu_ItemsPropertiesUpdatedSignal represents com.canonical.dbusmenu.ItemsPropertiesUpdated signal.
|
||||
type Dbusmenu_ItemsPropertiesUpdatedSignal struct {
|
||||
sender string
|
||||
Path dbus.ObjectPath
|
||||
Body *Dbusmenu_ItemsPropertiesUpdatedSignalBody
|
||||
}
|
||||
|
||||
// Name returns the signal's name.
|
||||
func (s *Dbusmenu_ItemsPropertiesUpdatedSignal) Name() string {
|
||||
return "ItemsPropertiesUpdated"
|
||||
}
|
||||
|
||||
// Interface returns the signal's interface.
|
||||
func (s *Dbusmenu_ItemsPropertiesUpdatedSignal) Interface() string {
|
||||
return InterfaceDbusmenu
|
||||
}
|
||||
|
||||
// Sender returns the signal's sender unique name.
|
||||
func (s *Dbusmenu_ItemsPropertiesUpdatedSignal) Sender() string {
|
||||
return s.sender
|
||||
}
|
||||
|
||||
func (s *Dbusmenu_ItemsPropertiesUpdatedSignal) path() dbus.ObjectPath {
|
||||
return s.Path
|
||||
}
|
||||
|
||||
func (s *Dbusmenu_ItemsPropertiesUpdatedSignal) values() []interface{} {
|
||||
return []interface{}{s.Body.UpdatedProps, s.Body.RemovedProps}
|
||||
}
|
||||
|
||||
// Dbusmenu_ItemsPropertiesUpdatedSignalBody is body container.
|
||||
type Dbusmenu_ItemsPropertiesUpdatedSignalBody struct {
|
||||
UpdatedProps []struct {
|
||||
V0 int32
|
||||
V1 map[string]dbus.Variant
|
||||
}
|
||||
RemovedProps []struct {
|
||||
V0 int32
|
||||
V1 []string
|
||||
}
|
||||
}
|
||||
|
||||
// Dbusmenu_LayoutUpdatedSignal represents com.canonical.dbusmenu.LayoutUpdated signal.
|
||||
type Dbusmenu_LayoutUpdatedSignal struct {
|
||||
sender string
|
||||
Path dbus.ObjectPath
|
||||
Body *Dbusmenu_LayoutUpdatedSignalBody
|
||||
}
|
||||
|
||||
// Name returns the signal's name.
|
||||
func (s *Dbusmenu_LayoutUpdatedSignal) Name() string {
|
||||
return "LayoutUpdated"
|
||||
}
|
||||
|
||||
// Interface returns the signal's interface.
|
||||
func (s *Dbusmenu_LayoutUpdatedSignal) Interface() string {
|
||||
return InterfaceDbusmenu
|
||||
}
|
||||
|
||||
// Sender returns the signal's sender unique name.
|
||||
func (s *Dbusmenu_LayoutUpdatedSignal) Sender() string {
|
||||
return s.sender
|
||||
}
|
||||
|
||||
func (s *Dbusmenu_LayoutUpdatedSignal) path() dbus.ObjectPath {
|
||||
return s.Path
|
||||
}
|
||||
|
||||
func (s *Dbusmenu_LayoutUpdatedSignal) values() []interface{} {
|
||||
return []interface{}{s.Body.Revision, s.Body.Parent}
|
||||
}
|
||||
|
||||
// Dbusmenu_LayoutUpdatedSignalBody is body container.
|
||||
type Dbusmenu_LayoutUpdatedSignalBody struct {
|
||||
Revision uint32
|
||||
Parent int32
|
||||
}
|
||||
|
||||
// Dbusmenu_ItemActivationRequestedSignal represents com.canonical.dbusmenu.ItemActivationRequested signal.
|
||||
type Dbusmenu_ItemActivationRequestedSignal struct {
|
||||
sender string
|
||||
Path dbus.ObjectPath
|
||||
Body *Dbusmenu_ItemActivationRequestedSignalBody
|
||||
}
|
||||
|
||||
// Name returns the signal's name.
|
||||
func (s *Dbusmenu_ItemActivationRequestedSignal) Name() string {
|
||||
return "ItemActivationRequested"
|
||||
}
|
||||
|
||||
// Interface returns the signal's interface.
|
||||
func (s *Dbusmenu_ItemActivationRequestedSignal) Interface() string {
|
||||
return InterfaceDbusmenu
|
||||
}
|
||||
|
||||
// Sender returns the signal's sender unique name.
|
||||
func (s *Dbusmenu_ItemActivationRequestedSignal) Sender() string {
|
||||
return s.sender
|
||||
}
|
||||
|
||||
func (s *Dbusmenu_ItemActivationRequestedSignal) path() dbus.ObjectPath {
|
||||
return s.Path
|
||||
}
|
||||
|
||||
func (s *Dbusmenu_ItemActivationRequestedSignal) values() []interface{} {
|
||||
return []interface{}{s.Body.Id, s.Body.Timestamp}
|
||||
}
|
||||
|
||||
// Dbusmenu_ItemActivationRequestedSignalBody is body container.
|
||||
type Dbusmenu_ItemActivationRequestedSignalBody struct {
|
||||
Id int32
|
||||
Timestamp uint32
|
||||
}
|
||||
633
vendor/fyne.io/systray/internal/generated/notifier/status_notifier_item.go
generated
vendored
Normal file
@@ -0,0 +1,633 @@
|
||||
// Code generated by dbus-codegen-go DO NOT EDIT.
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/godbus/dbus/v5/introspect"
|
||||
)
|
||||
|
||||
var (
|
||||
// Introspection for org.kde.StatusNotifierItem
|
||||
IntrospectDataStatusNotifierItem = introspect.Interface{
|
||||
Name: "org.kde.StatusNotifierItem",
|
||||
Methods: []introspect.Method{{Name: "ContextMenu", Args: []introspect.Arg{
|
||||
{Name: "x", Type: "i", Direction: "in"},
|
||||
{Name: "y", Type: "i", Direction: "in"},
|
||||
}},
|
||||
{Name: "Activate", Args: []introspect.Arg{
|
||||
{Name: "x", Type: "i", Direction: "in"},
|
||||
{Name: "y", Type: "i", Direction: "in"},
|
||||
}},
|
||||
{Name: "SecondaryActivate", Args: []introspect.Arg{
|
||||
{Name: "x", Type: "i", Direction: "in"},
|
||||
{Name: "y", Type: "i", Direction: "in"},
|
||||
}},
|
||||
{Name: "Scroll", Args: []introspect.Arg{
|
||||
{Name: "delta", Type: "i", Direction: "in"},
|
||||
{Name: "orientation", Type: "s", Direction: "in"},
|
||||
}},
|
||||
},
|
||||
Signals: []introspect.Signal{{Name: "NewTitle"},
|
||||
{Name: "NewIcon"},
|
||||
{Name: "NewAttentionIcon"},
|
||||
{Name: "NewOverlayIcon"},
|
||||
{Name: "NewStatus", Args: []introspect.Arg{
|
||||
{Name: "status", Type: "s", Direction: ""},
|
||||
}},
|
||||
{Name: "NewIconThemePath", Args: []introspect.Arg{
|
||||
{Name: "icon_theme_path", Type: "s", Direction: "out"},
|
||||
}},
|
||||
{Name: "NewMenu"},
|
||||
},
|
||||
Properties: []introspect.Property{{Name: "Category", Type: "s", Access: "read"},
|
||||
{Name: "Id", Type: "s", Access: "read"},
|
||||
{Name: "Title", Type: "s", Access: "read"},
|
||||
{Name: "Status", Type: "s", Access: "read"},
|
||||
{Name: "WindowId", Type: "i", Access: "read"},
|
||||
{Name: "IconThemePath", Type: "s", Access: "read"},
|
||||
{Name: "Menu", Type: "o", Access: "read"},
|
||||
{Name: "ItemIsMenu", Type: "b", Access: "read"},
|
||||
{Name: "IconName", Type: "s", Access: "read"},
|
||||
{Name: "IconPixmap", Type: "a(iiay)", Access: "read", Annotations: []introspect.Annotation{
|
||||
{Name: "org.qtproject.QtDBus.QtTypeName", Value: "KDbusImageVector"},
|
||||
}},
|
||||
{Name: "OverlayIconName", Type: "s", Access: "read"},
|
||||
{Name: "OverlayIconPixmap", Type: "a(iiay)", Access: "read", Annotations: []introspect.Annotation{
|
||||
{Name: "org.qtproject.QtDBus.QtTypeName", Value: "KDbusImageVector"},
|
||||
}},
|
||||
{Name: "AttentionIconName", Type: "s", Access: "read"},
|
||||
{Name: "AttentionIconPixmap", Type: "a(iiay)", Access: "read", Annotations: []introspect.Annotation{
|
||||
{Name: "org.qtproject.QtDBus.QtTypeName", Value: "KDbusImageVector"},
|
||||
}},
|
||||
{Name: "AttentionMovieName", Type: "s", Access: "read"},
|
||||
{Name: "ToolTip", Type: "(sa(iiay)ss)", Access: "read", Annotations: []introspect.Annotation{
|
||||
{Name: "org.qtproject.QtDBus.QtTypeName", Value: "KDbusToolTipStruct"},
|
||||
}},
|
||||
},
|
||||
Annotations: []introspect.Annotation{},
|
||||
}
|
||||
)
|
||||
|
||||
// Signal is a common interface for all signals.
|
||||
type Signal interface {
|
||||
Name() string
|
||||
Interface() string
|
||||
Sender() string
|
||||
|
||||
path() dbus.ObjectPath
|
||||
values() []interface{}
|
||||
}
|
||||
|
||||
// Emit sends the given signal to the bus.
|
||||
func Emit(conn *dbus.Conn, s Signal) error {
|
||||
return conn.Emit(s.path(), s.Interface()+"."+s.Name(), s.values()...)
|
||||
}
|
||||
|
||||
// ErrUnknownSignal is returned by LookupSignal when a signal cannot be resolved.
|
||||
var ErrUnknownSignal = errors.New("unknown signal")
|
||||
|
||||
// LookupSignal converts the given raw D-Bus signal with variable body
|
||||
// into one with typed structured body or returns ErrUnknownSignal error.
|
||||
func LookupSignal(signal *dbus.Signal) (Signal, error) {
|
||||
switch signal.Name {
|
||||
case InterfaceStatusNotifierItem + "." + "NewTitle":
|
||||
return &StatusNotifierItem_NewTitleSignal{
|
||||
sender: signal.Sender,
|
||||
Path: signal.Path,
|
||||
Body: &StatusNotifierItem_NewTitleSignalBody{},
|
||||
}, nil
|
||||
case InterfaceStatusNotifierItem + "." + "NewIcon":
|
||||
return &StatusNotifierItem_NewIconSignal{
|
||||
sender: signal.Sender,
|
||||
Path: signal.Path,
|
||||
Body: &StatusNotifierItem_NewIconSignalBody{},
|
||||
}, nil
|
||||
case InterfaceStatusNotifierItem + "." + "NewAttentionIcon":
|
||||
return &StatusNotifierItem_NewAttentionIconSignal{
|
||||
sender: signal.Sender,
|
||||
Path: signal.Path,
|
||||
Body: &StatusNotifierItem_NewAttentionIconSignalBody{},
|
||||
}, nil
|
||||
case InterfaceStatusNotifierItem + "." + "NewOverlayIcon":
|
||||
return &StatusNotifierItem_NewOverlayIconSignal{
|
||||
sender: signal.Sender,
|
||||
Path: signal.Path,
|
||||
Body: &StatusNotifierItem_NewOverlayIconSignalBody{},
|
||||
}, nil
|
||||
case InterfaceStatusNotifierItem + "." + "NewStatus":
|
||||
v0, ok := signal.Body[0].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("prop .Status is %T, not string", signal.Body[0])
|
||||
}
|
||||
return &StatusNotifierItem_NewStatusSignal{
|
||||
sender: signal.Sender,
|
||||
Path: signal.Path,
|
||||
Body: &StatusNotifierItem_NewStatusSignalBody{
|
||||
Status: v0,
|
||||
},
|
||||
}, nil
|
||||
case InterfaceStatusNotifierItem + "." + "NewIconThemePath":
|
||||
v0, ok := signal.Body[0].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("prop .IconThemePath is %T, not string", signal.Body[0])
|
||||
}
|
||||
return &StatusNotifierItem_NewIconThemePathSignal{
|
||||
sender: signal.Sender,
|
||||
Path: signal.Path,
|
||||
Body: &StatusNotifierItem_NewIconThemePathSignalBody{
|
||||
IconThemePath: v0,
|
||||
},
|
||||
}, nil
|
||||
case InterfaceStatusNotifierItem + "." + "NewMenu":
|
||||
return &StatusNotifierItem_NewMenuSignal{
|
||||
sender: signal.Sender,
|
||||
Path: signal.Path,
|
||||
Body: &StatusNotifierItem_NewMenuSignalBody{},
|
||||
}, nil
|
||||
default:
|
||||
return nil, ErrUnknownSignal
|
||||
}
|
||||
}
|
||||
|
||||
// AddMatchSignal registers a match rule for the given signal,
|
||||
// opts are appended to the automatically generated signal's rules.
|
||||
func AddMatchSignal(conn *dbus.Conn, s Signal, opts ...dbus.MatchOption) error {
|
||||
return conn.AddMatchSignal(append([]dbus.MatchOption{
|
||||
dbus.WithMatchInterface(s.Interface()),
|
||||
dbus.WithMatchMember(s.Name()),
|
||||
}, opts...)...)
|
||||
}
|
||||
|
||||
// RemoveMatchSignal unregisters the previously registered subscription.
|
||||
func RemoveMatchSignal(conn *dbus.Conn, s Signal, opts ...dbus.MatchOption) error {
|
||||
return conn.RemoveMatchSignal(append([]dbus.MatchOption{
|
||||
dbus.WithMatchInterface(s.Interface()),
|
||||
dbus.WithMatchMember(s.Name()),
|
||||
}, opts...)...)
|
||||
}
|
||||
|
||||
// Interface name constants.
|
||||
const (
|
||||
InterfaceStatusNotifierItem = "org.kde.StatusNotifierItem"
|
||||
)
|
||||
|
||||
// StatusNotifierItemer is org.kde.StatusNotifierItem interface.
|
||||
type StatusNotifierItemer interface {
|
||||
// ContextMenu is org.kde.StatusNotifierItem.ContextMenu method.
|
||||
ContextMenu(x int32, y int32) (err *dbus.Error)
|
||||
// Activate is org.kde.StatusNotifierItem.Activate method.
|
||||
Activate(x int32, y int32) (err *dbus.Error)
|
||||
// SecondaryActivate is org.kde.StatusNotifierItem.SecondaryActivate method.
|
||||
SecondaryActivate(x int32, y int32) (err *dbus.Error)
|
||||
// Scroll is org.kde.StatusNotifierItem.Scroll method.
|
||||
Scroll(delta int32, orientation string) (err *dbus.Error)
|
||||
}
|
||||
|
||||
// ExportStatusNotifierItem exports the given object that implements org.kde.StatusNotifierItem on the bus.
|
||||
func ExportStatusNotifierItem(conn *dbus.Conn, path dbus.ObjectPath, v StatusNotifierItemer) error {
|
||||
return conn.ExportSubtreeMethodTable(map[string]interface{}{
|
||||
"ContextMenu": v.ContextMenu,
|
||||
"Activate": v.Activate,
|
||||
"SecondaryActivate": v.SecondaryActivate,
|
||||
"Scroll": v.Scroll,
|
||||
}, path, InterfaceStatusNotifierItem)
|
||||
}
|
||||
|
||||
// UnexportStatusNotifierItem unexports org.kde.StatusNotifierItem interface on the named path.
|
||||
func UnexportStatusNotifierItem(conn *dbus.Conn, path dbus.ObjectPath) error {
|
||||
return conn.Export(nil, path, InterfaceStatusNotifierItem)
|
||||
}
|
||||
|
||||
// UnimplementedStatusNotifierItem can be embedded to have forward compatible server implementations.
|
||||
type UnimplementedStatusNotifierItem struct{}
|
||||
|
||||
func (*UnimplementedStatusNotifierItem) iface() string {
|
||||
return InterfaceStatusNotifierItem
|
||||
}
|
||||
|
||||
func (*UnimplementedStatusNotifierItem) ContextMenu(x int32, y int32) (err *dbus.Error) {
|
||||
err = &dbus.ErrMsgUnknownMethod
|
||||
return
|
||||
}
|
||||
|
||||
func (*UnimplementedStatusNotifierItem) Activate(x int32, y int32) (err *dbus.Error) {
|
||||
err = &dbus.ErrMsgUnknownMethod
|
||||
return
|
||||
}
|
||||
|
||||
func (*UnimplementedStatusNotifierItem) SecondaryActivate(x int32, y int32) (err *dbus.Error) {
|
||||
err = &dbus.ErrMsgUnknownMethod
|
||||
return
|
||||
}
|
||||
|
||||
func (*UnimplementedStatusNotifierItem) Scroll(delta int32, orientation string) (err *dbus.Error) {
|
||||
err = &dbus.ErrMsgUnknownMethod
|
||||
return
|
||||
}
|
||||
|
||||
// NewStatusNotifierItem creates and allocates org.kde.StatusNotifierItem.
|
||||
func NewStatusNotifierItem(object dbus.BusObject) *StatusNotifierItem {
|
||||
return &StatusNotifierItem{object}
|
||||
}
|
||||
|
||||
// StatusNotifierItem implements org.kde.StatusNotifierItem D-Bus interface.
|
||||
type StatusNotifierItem struct {
|
||||
object dbus.BusObject
|
||||
}
|
||||
|
||||
// ContextMenu calls org.kde.StatusNotifierItem.ContextMenu method.
|
||||
func (o *StatusNotifierItem) ContextMenu(ctx context.Context, x int32, y int32) (err error) {
|
||||
err = o.object.CallWithContext(ctx, InterfaceStatusNotifierItem+".ContextMenu", 0, x, y).Store()
|
||||
return
|
||||
}
|
||||
|
||||
// Activate calls org.kde.StatusNotifierItem.Activate method.
|
||||
func (o *StatusNotifierItem) Activate(ctx context.Context, x int32, y int32) (err error) {
|
||||
err = o.object.CallWithContext(ctx, InterfaceStatusNotifierItem+".Activate", 0, x, y).Store()
|
||||
return
|
||||
}
|
||||
|
||||
// SecondaryActivate calls org.kde.StatusNotifierItem.SecondaryActivate method.
|
||||
func (o *StatusNotifierItem) SecondaryActivate(ctx context.Context, x int32, y int32) (err error) {
|
||||
err = o.object.CallWithContext(ctx, InterfaceStatusNotifierItem+".SecondaryActivate", 0, x, y).Store()
|
||||
return
|
||||
}
|
||||
|
||||
// Scroll calls org.kde.StatusNotifierItem.Scroll method.
|
||||
func (o *StatusNotifierItem) Scroll(ctx context.Context, delta int32, orientation string) (err error) {
|
||||
err = o.object.CallWithContext(ctx, InterfaceStatusNotifierItem+".Scroll", 0, delta, orientation).Store()
|
||||
return
|
||||
}
|
||||
|
||||
// GetCategory gets org.kde.StatusNotifierItem.Category property.
|
||||
func (o *StatusNotifierItem) GetCategory(ctx context.Context) (category string, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "Category").Store(&category)
|
||||
return
|
||||
}
|
||||
|
||||
// GetId gets org.kde.StatusNotifierItem.Id property.
|
||||
func (o *StatusNotifierItem) GetId(ctx context.Context) (id string, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "Id").Store(&id)
|
||||
return
|
||||
}
|
||||
|
||||
// GetTitle gets org.kde.StatusNotifierItem.Title property.
|
||||
func (o *StatusNotifierItem) GetTitle(ctx context.Context) (title string, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "Title").Store(&title)
|
||||
return
|
||||
}
|
||||
|
||||
// GetStatus gets org.kde.StatusNotifierItem.Status property.
|
||||
func (o *StatusNotifierItem) GetStatus(ctx context.Context) (status string, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "Status").Store(&status)
|
||||
return
|
||||
}
|
||||
|
||||
// GetWindowId gets org.kde.StatusNotifierItem.WindowId property.
|
||||
func (o *StatusNotifierItem) GetWindowId(ctx context.Context) (windowId int32, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "WindowId").Store(&windowId)
|
||||
return
|
||||
}
|
||||
|
||||
// GetIconThemePath gets org.kde.StatusNotifierItem.IconThemePath property.
|
||||
func (o *StatusNotifierItem) GetIconThemePath(ctx context.Context) (iconThemePath string, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "IconThemePath").Store(&iconThemePath)
|
||||
return
|
||||
}
|
||||
|
||||
// GetMenu gets org.kde.StatusNotifierItem.Menu property.
|
||||
func (o *StatusNotifierItem) GetMenu(ctx context.Context) (menu dbus.ObjectPath, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "Menu").Store(&menu)
|
||||
return
|
||||
}
|
||||
|
||||
// GetItemIsMenu gets org.kde.StatusNotifierItem.ItemIsMenu property.
|
||||
func (o *StatusNotifierItem) GetItemIsMenu(ctx context.Context) (itemIsMenu bool, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "ItemIsMenu").Store(&itemIsMenu)
|
||||
return
|
||||
}
|
||||
|
||||
// GetIconName gets org.kde.StatusNotifierItem.IconName property.
|
||||
func (o *StatusNotifierItem) GetIconName(ctx context.Context) (iconName string, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "IconName").Store(&iconName)
|
||||
return
|
||||
}
|
||||
|
||||
// GetIconPixmap gets org.kde.StatusNotifierItem.IconPixmap property.
|
||||
//
|
||||
// Annotations:
|
||||
// @org.qtproject.QtDBus.QtTypeName = KDbusImageVector
|
||||
func (o *StatusNotifierItem) GetIconPixmap(ctx context.Context) (iconPixmap []struct {
|
||||
V0 int32
|
||||
V1 int32
|
||||
V2 []byte
|
||||
}, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "IconPixmap").Store(&iconPixmap)
|
||||
return
|
||||
}
|
||||
|
||||
// GetOverlayIconName gets org.kde.StatusNotifierItem.OverlayIconName property.
|
||||
func (o *StatusNotifierItem) GetOverlayIconName(ctx context.Context) (overlayIconName string, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "OverlayIconName").Store(&overlayIconName)
|
||||
return
|
||||
}
|
||||
|
||||
// GetOverlayIconPixmap gets org.kde.StatusNotifierItem.OverlayIconPixmap property.
|
||||
//
|
||||
// Annotations:
|
||||
// @org.qtproject.QtDBus.QtTypeName = KDbusImageVector
|
||||
func (o *StatusNotifierItem) GetOverlayIconPixmap(ctx context.Context) (overlayIconPixmap []struct {
|
||||
V0 int32
|
||||
V1 int32
|
||||
V2 []byte
|
||||
}, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "OverlayIconPixmap").Store(&overlayIconPixmap)
|
||||
return
|
||||
}
|
||||
|
||||
// GetAttentionIconName gets org.kde.StatusNotifierItem.AttentionIconName property.
|
||||
func (o *StatusNotifierItem) GetAttentionIconName(ctx context.Context) (attentionIconName string, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "AttentionIconName").Store(&attentionIconName)
|
||||
return
|
||||
}
|
||||
|
||||
// GetAttentionIconPixmap gets org.kde.StatusNotifierItem.AttentionIconPixmap property.
|
||||
//
|
||||
// Annotations:
|
||||
// @org.qtproject.QtDBus.QtTypeName = KDbusImageVector
|
||||
func (o *StatusNotifierItem) GetAttentionIconPixmap(ctx context.Context) (attentionIconPixmap []struct {
|
||||
V0 int32
|
||||
V1 int32
|
||||
V2 []byte
|
||||
}, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "AttentionIconPixmap").Store(&attentionIconPixmap)
|
||||
return
|
||||
}
|
||||
|
||||
// GetAttentionMovieName gets org.kde.StatusNotifierItem.AttentionMovieName property.
|
||||
func (o *StatusNotifierItem) GetAttentionMovieName(ctx context.Context) (attentionMovieName string, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "AttentionMovieName").Store(&attentionMovieName)
|
||||
return
|
||||
}
|
||||
|
||||
// GetToolTip gets org.kde.StatusNotifierItem.ToolTip property.
|
||||
//
|
||||
// Annotations:
|
||||
// @org.qtproject.QtDBus.QtTypeName = KDbusToolTipStruct
|
||||
func (o *StatusNotifierItem) GetToolTip(ctx context.Context) (toolTip struct {
|
||||
V0 string
|
||||
V1 []struct {
|
||||
V0 int32
|
||||
V1 int32
|
||||
V2 []byte
|
||||
}
|
||||
V2 string
|
||||
V3 string
|
||||
}, err error) {
|
||||
err = o.object.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, InterfaceStatusNotifierItem, "ToolTip").Store(&toolTip)
|
||||
return
|
||||
}
|
||||
|
||||
// StatusNotifierItem_NewTitleSignal represents org.kde.StatusNotifierItem.NewTitle signal.
|
||||
type StatusNotifierItem_NewTitleSignal struct {
|
||||
sender string
|
||||
Path dbus.ObjectPath
|
||||
Body *StatusNotifierItem_NewTitleSignalBody
|
||||
}
|
||||
|
||||
// Name returns the signal's name.
|
||||
func (s *StatusNotifierItem_NewTitleSignal) Name() string {
|
||||
return "NewTitle"
|
||||
}
|
||||
|
||||
// Interface returns the signal's interface.
|
||||
func (s *StatusNotifierItem_NewTitleSignal) Interface() string {
|
||||
return InterfaceStatusNotifierItem
|
||||
}
|
||||
|
||||
// Sender returns the signal's sender unique name.
|
||||
func (s *StatusNotifierItem_NewTitleSignal) Sender() string {
|
||||
return s.sender
|
||||
}
|
||||
|
||||
func (s *StatusNotifierItem_NewTitleSignal) path() dbus.ObjectPath {
|
||||
return s.Path
|
||||
}
|
||||
|
||||
func (s *StatusNotifierItem_NewTitleSignal) values() []interface{} {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
// StatusNotifierItem_NewTitleSignalBody is body container.
|
||||
type StatusNotifierItem_NewTitleSignalBody struct {
|
||||
}
|
||||
|
||||
// StatusNotifierItem_NewIconSignal represents org.kde.StatusNotifierItem.NewIcon signal.
|
||||
type StatusNotifierItem_NewIconSignal struct {
|
||||
sender string
|
||||
Path dbus.ObjectPath
|
||||
Body *StatusNotifierItem_NewIconSignalBody
|
||||
}
|
||||
|
||||
// Name returns the signal's name.
|
||||
func (s *StatusNotifierItem_NewIconSignal) Name() string {
|
||||
return "NewIcon"
|
||||
}
|
||||
|
||||
// Interface returns the signal's interface.
|
||||
func (s *StatusNotifierItem_NewIconSignal) Interface() string {
|
||||
return InterfaceStatusNotifierItem
|
||||
}
|
||||
|
||||
// Sender returns the signal's sender unique name.
|
||||
func (s *StatusNotifierItem_NewIconSignal) Sender() string {
|
||||
return s.sender
|
||||
}
|
||||
|
||||
func (s *StatusNotifierItem_NewIconSignal) path() dbus.ObjectPath {
|
||||
return s.Path
|
||||
}
|
||||
|
||||
func (s *StatusNotifierItem_NewIconSignal) values() []interface{} {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
// StatusNotifierItem_NewIconSignalBody is body container.
|
||||
type StatusNotifierItem_NewIconSignalBody struct {
|
||||
}
|
||||
|
||||
// StatusNotifierItem_NewAttentionIconSignal represents org.kde.StatusNotifierItem.NewAttentionIcon signal.
|
||||
type StatusNotifierItem_NewAttentionIconSignal struct {
|
||||
sender string
|
||||
Path dbus.ObjectPath
|
||||
Body *StatusNotifierItem_NewAttentionIconSignalBody
|
||||
}
|
||||
|
||||
// Name returns the signal's name.
|
||||
func (s *StatusNotifierItem_NewAttentionIconSignal) Name() string {
|
||||
return "NewAttentionIcon"
|
||||
}
|
||||
|
||||
// Interface returns the signal's interface.
|
||||
func (s *StatusNotifierItem_NewAttentionIconSignal) Interface() string {
|
||||
return InterfaceStatusNotifierItem
|
||||
}
|
||||
|
||||
// Sender returns the signal's sender unique name.
|
||||
func (s *StatusNotifierItem_NewAttentionIconSignal) Sender() string {
|
||||
return s.sender
|
||||
}
|
||||
|
||||
func (s *StatusNotifierItem_NewAttentionIconSignal) path() dbus.ObjectPath {
|
||||
return s.Path
|
||||
}
|
||||
|
||||
func (s *StatusNotifierItem_NewAttentionIconSignal) values() []interface{} {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
// StatusNotifierItem_NewAttentionIconSignalBody is body container.
|
||||
type StatusNotifierItem_NewAttentionIconSignalBody struct {
|
||||
}
|
||||
|
||||
// StatusNotifierItem_NewOverlayIconSignal represents org.kde.StatusNotifierItem.NewOverlayIcon signal.
|
||||
type StatusNotifierItem_NewOverlayIconSignal struct {
|
||||
sender string
|
||||
Path dbus.ObjectPath
|
||||
Body *StatusNotifierItem_NewOverlayIconSignalBody
|
||||
}
|
||||
|
||||
// Name returns the signal's name.
|
||||
func (s *StatusNotifierItem_NewOverlayIconSignal) Name() string {
|
||||
return "NewOverlayIcon"
|
||||
}
|
||||
|
||||
// Interface returns the signal's interface.
|
||||
func (s *StatusNotifierItem_NewOverlayIconSignal) Interface() string {
|
||||
return InterfaceStatusNotifierItem
|
||||
}
|
||||
|
||||
// Sender returns the signal's sender unique name.
|
||||
func (s *StatusNotifierItem_NewOverlayIconSignal) Sender() string {
|
||||
return s.sender
|
||||
}
|
||||
|
||||
func (s *StatusNotifierItem_NewOverlayIconSignal) path() dbus.ObjectPath {
|
||||
return s.Path
|
||||
}
|
||||
|
||||
func (s *StatusNotifierItem_NewOverlayIconSignal) values() []interface{} {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
// StatusNotifierItem_NewOverlayIconSignalBody is body container.
|
||||
type StatusNotifierItem_NewOverlayIconSignalBody struct {
|
||||
}
|
||||
|
||||
// StatusNotifierItem_NewStatusSignal represents org.kde.StatusNotifierItem.NewStatus signal.
|
||||
type StatusNotifierItem_NewStatusSignal struct {
|
||||
sender string
|
||||
Path dbus.ObjectPath
|
||||
Body *StatusNotifierItem_NewStatusSignalBody
|
||||
}
|
||||
|
||||
// Name returns the signal's name.
|
||||
func (s *StatusNotifierItem_NewStatusSignal) Name() string {
|
||||
return "NewStatus"
|
||||
}
|
||||
|
||||
// Interface returns the signal's interface.
|
||||
func (s *StatusNotifierItem_NewStatusSignal) Interface() string {
|
||||
return InterfaceStatusNotifierItem
|
||||
}
|
||||
|
||||
// Sender returns the signal's sender unique name.
|
||||
func (s *StatusNotifierItem_NewStatusSignal) Sender() string {
|
||||
return s.sender
|
||||
}
|
||||
|
||||
func (s *StatusNotifierItem_NewStatusSignal) path() dbus.ObjectPath {
|
||||
return s.Path
|
||||
}
|
||||
|
||||
func (s *StatusNotifierItem_NewStatusSignal) values() []interface{} {
|
||||
return []interface{}{s.Body.Status}
|
||||
}
|
||||
|
||||
// StatusNotifierItem_NewStatusSignalBody is body container.
|
||||
type StatusNotifierItem_NewStatusSignalBody struct {
|
||||
Status string
|
||||
}
|
||||
|
||||
// StatusNotifierItem_NewIconThemePathSignal represents org.kde.StatusNotifierItem.NewIconThemePath signal.
|
||||
type StatusNotifierItem_NewIconThemePathSignal struct {
|
||||
sender string
|
||||
Path dbus.ObjectPath
|
||||
Body *StatusNotifierItem_NewIconThemePathSignalBody
|
||||
}
|
||||
|
||||
// Name returns the signal's name.
|
||||
func (s *StatusNotifierItem_NewIconThemePathSignal) Name() string {
|
||||
return "NewIconThemePath"
|
||||
}
|
||||
|
||||
// Interface returns the signal's interface.
|
||||
func (s *StatusNotifierItem_NewIconThemePathSignal) Interface() string {
|
||||
return InterfaceStatusNotifierItem
|
||||
}
|
||||
|
||||
// Sender returns the signal's sender unique name.
|
||||
func (s *StatusNotifierItem_NewIconThemePathSignal) Sender() string {
|
||||
return s.sender
|
||||
}
|
||||
|
||||
func (s *StatusNotifierItem_NewIconThemePathSignal) path() dbus.ObjectPath {
|
||||
return s.Path
|
||||
}
|
||||
|
||||
func (s *StatusNotifierItem_NewIconThemePathSignal) values() []interface{} {
|
||||
return []interface{}{s.Body.IconThemePath}
|
||||
}
|
||||
|
||||
// StatusNotifierItem_NewIconThemePathSignalBody is body container.
|
||||
type StatusNotifierItem_NewIconThemePathSignalBody struct {
|
||||
IconThemePath string
|
||||
}
|
||||
|
||||
// StatusNotifierItem_NewMenuSignal represents org.kde.StatusNotifierItem.NewMenu signal.
|
||||
type StatusNotifierItem_NewMenuSignal struct {
|
||||
sender string
|
||||
Path dbus.ObjectPath
|
||||
Body *StatusNotifierItem_NewMenuSignalBody
|
||||
}
|
||||
|
||||
// Name returns the signal's name.
|
||||
func (s *StatusNotifierItem_NewMenuSignal) Name() string {
|
||||
return "NewMenu"
|
||||
}
|
||||
|
||||
// Interface returns the signal's interface.
|
||||
func (s *StatusNotifierItem_NewMenuSignal) Interface() string {
|
||||
return InterfaceStatusNotifierItem
|
||||
}
|
||||
|
||||
// Sender returns the signal's sender unique name.
|
||||
func (s *StatusNotifierItem_NewMenuSignal) Sender() string {
|
||||
return s.sender
|
||||
}
|
||||
|
||||
func (s *StatusNotifierItem_NewMenuSignal) path() dbus.ObjectPath {
|
||||
return s.Path
|
||||
}
|
||||
|
||||
func (s *StatusNotifierItem_NewMenuSignal) values() []interface{} {
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
// StatusNotifierItem_NewMenuSignalBody is body container.
|
||||
type StatusNotifierItem_NewMenuSignalBody struct {
|
||||
}
|
||||
109
src/systray/systray.go → vendor/fyne.io/systray/systray.go
generated
vendored
@@ -1,9 +1,4 @@
|
||||
//go:build darwin || windows
|
||||
// +build darwin windows
|
||||
|
||||
/*
|
||||
Package systray is a cross-platform Go library to place an icon and menu in the notification area.
|
||||
*/
|
||||
// Package systray is a cross-platform Go library to place an icon and menu in the notification area.
|
||||
package systray
|
||||
|
||||
import (
|
||||
@@ -15,15 +10,29 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
systrayReady func()
|
||||
systrayExit func()
|
||||
menuItems = make(map[uint32]*MenuItem)
|
||||
menuItemsLock sync.RWMutex
|
||||
systrayReady, systrayExit func()
|
||||
tappedLeft, tappedRight func()
|
||||
systrayExitCalled bool
|
||||
menuItems = make(map[uint32]*MenuItem)
|
||||
menuItemsLock sync.RWMutex
|
||||
|
||||
currentID = uint32(0)
|
||||
quitOnce sync.Once
|
||||
initialMenuBuilt sync.WaitGroup
|
||||
currentID atomic.Uint32
|
||||
quitOnce sync.Once
|
||||
|
||||
// TrayOpenedCh receives an entry each time the system tray menu is opened.
|
||||
TrayOpenedCh = make(chan struct{})
|
||||
)
|
||||
|
||||
// This helper function allows us to call systrayExit only once,
|
||||
// without accidentally calling it twice in the same lifetime.
|
||||
func runSystrayExit() {
|
||||
if !systrayExitCalled {
|
||||
systrayExitCalled = true
|
||||
systrayExit()
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
runtime.LockOSThread()
|
||||
}
|
||||
@@ -61,7 +70,7 @@ func (item *MenuItem) String() string {
|
||||
func newMenuItem(title string, tooltip string, parent *MenuItem) *MenuItem {
|
||||
return &MenuItem{
|
||||
ClickedCh: make(chan struct{}),
|
||||
id: atomic.AddUint32(¤tID, 1),
|
||||
id: currentID.Add(1),
|
||||
title: title,
|
||||
tooltip: tooltip,
|
||||
disabled: false,
|
||||
@@ -73,11 +82,24 @@ func newMenuItem(title string, tooltip string, parent *MenuItem) *MenuItem {
|
||||
|
||||
// Run initializes GUI and starts the event loop, then invokes the onReady
|
||||
// callback. It blocks until systray.Quit() is called.
|
||||
func Run(onReady func(), onExit func()) {
|
||||
func Run(onReady, onExit func()) {
|
||||
setInternalLoop(true)
|
||||
Register(onReady, onExit)
|
||||
|
||||
nativeLoop()
|
||||
}
|
||||
|
||||
// RunWithExternalLoop allows the system tray module to operate with other toolkits.
|
||||
// The returned start and end functions should be called by the toolkit when the application has started and will end.
|
||||
func RunWithExternalLoop(onReady, onExit func()) (start, end func()) {
|
||||
Register(onReady, onExit)
|
||||
|
||||
return nativeStart, func() {
|
||||
nativeEnd()
|
||||
Quit()
|
||||
}
|
||||
}
|
||||
|
||||
// Register initializes GUI and registers the callbacks but relies on the
|
||||
// caller to run the event loop somewhere else. It's useful if the program
|
||||
// needs to show other UI elements, for example, webview.
|
||||
@@ -89,9 +111,11 @@ func Register(onReady func(), onExit func()) {
|
||||
} else {
|
||||
// Run onReady on separate goroutine to avoid blocking event loop
|
||||
readyCh := make(chan interface{})
|
||||
initialMenuBuilt.Add(1)
|
||||
go func() {
|
||||
<-readyCh
|
||||
onReady()
|
||||
initialMenuBuilt.Done()
|
||||
}()
|
||||
systrayReady = func() {
|
||||
close(readyCh)
|
||||
@@ -103,14 +127,36 @@ func Register(onReady func(), onExit func()) {
|
||||
onExit = func() {}
|
||||
}
|
||||
systrayExit = onExit
|
||||
systrayExitCalled = false
|
||||
registerSystray()
|
||||
}
|
||||
|
||||
// ResetMenu will remove all menu items
|
||||
func ResetMenu() {
|
||||
menuItemsLock.Lock()
|
||||
id := currentID.Load()
|
||||
menuItemsLock.Unlock()
|
||||
for i, item := range menuItems {
|
||||
if i < id && item.parent == nil {
|
||||
item.Remove()
|
||||
}
|
||||
}
|
||||
resetMenu()
|
||||
}
|
||||
|
||||
// Quit the systray
|
||||
func Quit() {
|
||||
quitOnce.Do(quit)
|
||||
}
|
||||
|
||||
func SetOnTapped(f func()) {
|
||||
tappedLeft = f
|
||||
}
|
||||
|
||||
func SetOnSecondaryTapped(f func()) {
|
||||
tappedRight = f
|
||||
}
|
||||
|
||||
// AddMenuItem adds a menu item with the designated title and tooltip.
|
||||
// It can be safely invoked from different goroutines.
|
||||
// Created menu items are checkable on Windows and OSX by default. For Linux you have to use AddMenuItemCheckbox
|
||||
@@ -121,8 +167,8 @@ func AddMenuItem(title string, tooltip string) *MenuItem {
|
||||
}
|
||||
|
||||
// AddMenuItemCheckbox adds a menu item with the designated title and tooltip and a checkbox for Linux.
|
||||
// On other platforms there will be a check indicated next to the item if `checked` is true.
|
||||
// It can be safely invoked from different goroutines.
|
||||
// On Windows and OSX this is the same as calling AddMenuItem
|
||||
func AddMenuItemCheckbox(title string, tooltip string, checked bool) *MenuItem {
|
||||
item := newMenuItem(title, tooltip, nil)
|
||||
item.isCheckable = true
|
||||
@@ -133,7 +179,12 @@ func AddMenuItemCheckbox(title string, tooltip string, checked bool) *MenuItem {
|
||||
|
||||
// AddSeparator adds a separator bar to the menu
|
||||
func AddSeparator() {
|
||||
addSeparator(atomic.AddUint32(¤tID, 1))
|
||||
addSeparator(currentID.Add(1), 0)
|
||||
}
|
||||
|
||||
// AddSeparator adds a separator bar to the submenu
|
||||
func (item *MenuItem) AddSeparator() {
|
||||
addSeparator(currentID.Add(1), item.id)
|
||||
}
|
||||
|
||||
// AddSubMenuItem adds a nested sub-menu item with the designated title and tooltip.
|
||||
@@ -190,6 +241,30 @@ func (item *MenuItem) Hide() {
|
||||
hideMenuItem(item)
|
||||
}
|
||||
|
||||
// Remove removes a menu item
|
||||
func (item *MenuItem) Remove() {
|
||||
menuItemsLock.RLock()
|
||||
var childList []*MenuItem
|
||||
for _, child := range menuItems {
|
||||
if child.parent == item {
|
||||
childList = append(childList, child)
|
||||
}
|
||||
}
|
||||
menuItemsLock.RUnlock()
|
||||
for _, child := range childList {
|
||||
child.Remove()
|
||||
}
|
||||
removeMenuItem(item)
|
||||
menuItemsLock.Lock()
|
||||
delete(menuItems, item.id)
|
||||
select {
|
||||
case <-item.ClickedCh:
|
||||
default:
|
||||
}
|
||||
close(item.ClickedCh)
|
||||
menuItemsLock.Unlock()
|
||||
}
|
||||
|
||||
// Show shows a previously hidden menu item
|
||||
func (item *MenuItem) Show() {
|
||||
showMenuItem(item)
|
||||
@@ -225,7 +300,7 @@ func systrayMenuItemSelected(id uint32) {
|
||||
item, ok := menuItems[id]
|
||||
menuItemsLock.RUnlock()
|
||||
if !ok {
|
||||
log.Printf("No menu item with ID %v", id)
|
||||
log.Printf("systray error: no menu item with ID %d\n", id)
|
||||
return
|
||||
}
|
||||
select {
|
||||
11
src/systray/systray.h → vendor/fyne.io/systray/systray.h
generated
vendored
@@ -2,16 +2,25 @@
|
||||
|
||||
extern void systray_ready();
|
||||
extern void systray_on_exit();
|
||||
extern void systray_left_click();
|
||||
extern void systray_right_click();
|
||||
extern void systray_menu_item_selected(int menu_id);
|
||||
extern void systray_menu_will_open();
|
||||
void registerSystray(void);
|
||||
void nativeEnd(void);
|
||||
int nativeLoop(void);
|
||||
void nativeStart(void);
|
||||
|
||||
void setIcon(const char* iconBytes, int length, bool template);
|
||||
void setMenuItemIcon(const char* iconBytes, int length, int menuId, bool template);
|
||||
void setTitle(char* title);
|
||||
void setTooltip(char* tooltip);
|
||||
void setRemovalAllowed(bool allowed);
|
||||
void add_or_update_menu_item(int menuId, int parentMenuId, char* title, char* tooltip, short disabled, short checked, short isCheckable);
|
||||
void add_separator(int menuId);
|
||||
void add_separator(int menuId, int parentId);
|
||||
void hide_menu_item(int menuId);
|
||||
void remove_menu_item(int menuId);
|
||||
void show_menu_item(int menuId);
|
||||
void reset_menu();
|
||||
void show_menu();
|
||||
void quit();
|
||||
213
vendor/fyne.io/systray/systray_darwin.go
generated
vendored
Normal file
@@ -0,0 +1,213 @@
|
||||
//go:build !ios
|
||||
|
||||
package systray
|
||||
|
||||
/*
|
||||
#cgo darwin CFLAGS: -DDARWIN -x objective-c -fobjc-arc
|
||||
#cgo darwin LDFLAGS: -framework Cocoa
|
||||
|
||||
#include <stdbool.h>
|
||||
#include "systray.h"
|
||||
|
||||
void setInternalLoop(bool);
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// SetTemplateIcon sets the systray icon as a template icon (on Mac), falling back
|
||||
// to a regular icon on other platforms.
|
||||
// templateIconBytes and regularIconBytes should be the content of .ico for windows and
|
||||
// .ico/.jpg/.png for other platforms.
|
||||
func SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) {
|
||||
cstr := (*C.char)(unsafe.Pointer(&templateIconBytes[0]))
|
||||
C.setIcon(cstr, (C.int)(len(templateIconBytes)), true)
|
||||
}
|
||||
|
||||
// SetIcon sets the icon of a menu item. Only works on macOS and Windows.
|
||||
// iconBytes should be the content of .ico/.jpg/.png
|
||||
func (item *MenuItem) SetIcon(iconBytes []byte) {
|
||||
cstr := (*C.char)(unsafe.Pointer(&iconBytes[0]))
|
||||
C.setMenuItemIcon(cstr, (C.int)(len(iconBytes)), C.int(item.id), false)
|
||||
}
|
||||
|
||||
// SetIconFromFilePath sets the icon of a menu item from a file path.
|
||||
// iconFilePath should be the path to a .ico for windows and .ico/.jpg/.png for other platforms.
|
||||
func (item *MenuItem) SetIconFromFilePath(iconFilePath string) error {
|
||||
iconBytes, err := os.ReadFile(iconFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read icon file: %v", err)
|
||||
}
|
||||
item.SetIcon(iconBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTemplateIcon sets the icon of a menu item as a template icon (on macOS). On Windows, it
|
||||
// falls back to the regular icon bytes and on Linux it does nothing.
|
||||
// templateIconBytes and regularIconBytes should be the content of .ico for windows and
|
||||
// .ico/.jpg/.png for other platforms.
|
||||
func (item *MenuItem) SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) {
|
||||
cstr := (*C.char)(unsafe.Pointer(&templateIconBytes[0]))
|
||||
C.setMenuItemIcon(cstr, (C.int)(len(templateIconBytes)), C.int(item.id), true)
|
||||
}
|
||||
|
||||
// SetRemovalAllowed sets whether a user can remove the systray icon or not.
|
||||
// This is only supported on macOS.
|
||||
func SetRemovalAllowed(allowed bool) {
|
||||
C.setRemovalAllowed((C.bool)(allowed))
|
||||
}
|
||||
|
||||
func registerSystray() {
|
||||
C.registerSystray()
|
||||
}
|
||||
|
||||
func nativeLoop() {
|
||||
C.nativeLoop()
|
||||
}
|
||||
|
||||
func nativeEnd() {
|
||||
C.nativeEnd()
|
||||
}
|
||||
|
||||
func nativeStart() {
|
||||
C.nativeStart()
|
||||
}
|
||||
|
||||
func quit() {
|
||||
C.quit()
|
||||
}
|
||||
|
||||
func setInternalLoop(internal bool) {
|
||||
C.setInternalLoop(C.bool(internal))
|
||||
}
|
||||
|
||||
// SetIcon sets the systray icon.
|
||||
// iconBytes should be the content of .ico for windows and .ico/.jpg/.png
|
||||
// for other platforms.
|
||||
func SetIcon(iconBytes []byte) {
|
||||
cstr := (*C.char)(unsafe.Pointer(&iconBytes[0]))
|
||||
C.setIcon(cstr, (C.int)(len(iconBytes)), false)
|
||||
}
|
||||
|
||||
// SetIconFromFilePath sets the systray icon from a file path.
|
||||
// iconFilePath should be the path to a .ico for windows and .ico/.jpg/.png for other platforms.
|
||||
func SetIconFromFilePath(iconFilePath string) error {
|
||||
bytes, err := os.ReadFile(iconFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read icon file: %v", err)
|
||||
}
|
||||
SetIcon(bytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTitle sets the systray title, only available on Mac and Linux.
|
||||
func SetTitle(title string) {
|
||||
C.setTitle(C.CString(title))
|
||||
}
|
||||
|
||||
// SetTooltip sets the systray tooltip to display on mouse hover of the tray icon,
|
||||
// only available on Mac and Windows.
|
||||
func SetTooltip(tooltip string) {
|
||||
C.setTooltip(C.CString(tooltip))
|
||||
}
|
||||
|
||||
func addOrUpdateMenuItem(item *MenuItem) {
|
||||
var disabled C.short
|
||||
if item.disabled {
|
||||
disabled = 1
|
||||
}
|
||||
var checked C.short
|
||||
if item.checked {
|
||||
checked = 1
|
||||
}
|
||||
var isCheckable C.short
|
||||
if item.isCheckable {
|
||||
isCheckable = 1
|
||||
}
|
||||
var parentID uint32 = 0
|
||||
if item.parent != nil {
|
||||
parentID = item.parent.id
|
||||
}
|
||||
C.add_or_update_menu_item(
|
||||
C.int(item.id),
|
||||
C.int(parentID),
|
||||
C.CString(item.title),
|
||||
C.CString(item.tooltip),
|
||||
disabled,
|
||||
checked,
|
||||
isCheckable,
|
||||
)
|
||||
}
|
||||
|
||||
func addSeparator(id uint32, parent uint32) {
|
||||
C.add_separator(C.int(id), C.int(parent))
|
||||
}
|
||||
|
||||
func hideMenuItem(item *MenuItem) {
|
||||
C.hide_menu_item(
|
||||
C.int(item.id),
|
||||
)
|
||||
}
|
||||
|
||||
func showMenuItem(item *MenuItem) {
|
||||
C.show_menu_item(
|
||||
C.int(item.id),
|
||||
)
|
||||
}
|
||||
|
||||
func removeMenuItem(item *MenuItem) {
|
||||
C.remove_menu_item(
|
||||
C.int(item.id),
|
||||
)
|
||||
}
|
||||
|
||||
func resetMenu() {
|
||||
C.reset_menu()
|
||||
}
|
||||
|
||||
//export systray_left_click
|
||||
func systray_left_click() {
|
||||
if fn := tappedLeft; fn != nil {
|
||||
fn()
|
||||
return
|
||||
}
|
||||
|
||||
C.show_menu()
|
||||
}
|
||||
|
||||
//export systray_right_click
|
||||
func systray_right_click() {
|
||||
if fn := tappedRight; fn != nil {
|
||||
fn()
|
||||
return
|
||||
}
|
||||
|
||||
C.show_menu()
|
||||
}
|
||||
|
||||
//export systray_ready
|
||||
func systray_ready() {
|
||||
systrayReady()
|
||||
}
|
||||
|
||||
//export systray_on_exit
|
||||
func systray_on_exit() {
|
||||
runSystrayExit()
|
||||
}
|
||||
|
||||
//export systray_menu_item_selected
|
||||
func systray_menu_item_selected(cID C.int) {
|
||||
systrayMenuItemSelected(uint32(cID))
|
||||
}
|
||||
|
||||
//export systray_menu_will_open
|
||||
func systray_menu_will_open() {
|
||||
select {
|
||||
case TrayOpenedCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
223
src/systray/systray_darwin.m → vendor/fyne.io/systray/systray_darwin.m
generated
vendored
@@ -1,3 +1,5 @@
|
||||
//go:build !ios
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#include "systray.h"
|
||||
|
||||
@@ -50,13 +52,33 @@ withParentMenuId: (int)theParentMenuId
|
||||
}
|
||||
@end
|
||||
|
||||
@interface AppDelegate: NSObject <NSApplicationDelegate>
|
||||
@interface RightClickDetector : NSView
|
||||
|
||||
@property (copy) void (^onRightClicked)(NSEvent *);
|
||||
|
||||
@end
|
||||
|
||||
@implementation RightClickDetector
|
||||
|
||||
- (void)rightMouseUp:(NSEvent *)theEvent {
|
||||
if (!self.onRightClicked) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.onRightClicked(theEvent);
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
@interface SystrayAppDelegate: NSObject <NSApplicationDelegate, NSMenuDelegate>
|
||||
- (void) add_or_update_menu_item:(MenuItem*) item;
|
||||
- (IBAction)menuHandler:(id)sender;
|
||||
- (void)menuWillOpen:(NSMenu*)menu;
|
||||
@property (assign) IBOutlet NSWindow *window;
|
||||
@end
|
||||
@end
|
||||
|
||||
@implementation AppDelegate
|
||||
@implementation SystrayAppDelegate
|
||||
{
|
||||
NSStatusItem *statusItem;
|
||||
NSMenu *menu;
|
||||
@@ -68,17 +90,73 @@ withParentMenuId: (int)theParentMenuId
|
||||
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
|
||||
{
|
||||
self->statusItem = [[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength];
|
||||
|
||||
self->menu = [[NSMenu alloc] init];
|
||||
[self->menu setAutoenablesItems: FALSE];
|
||||
[self->statusItem setMenu:self->menu];
|
||||
self->menu.delegate = self;
|
||||
self->menu.autoenablesItems = FALSE;
|
||||
// Once the user has removed it, the item needs to be explicitly brought back,
|
||||
// even restarting the application is insufficient.
|
||||
// Since the interface from Go is relatively simple, for now we ensure it's
|
||||
// always visible at application startup.
|
||||
self->statusItem.visible = TRUE;
|
||||
|
||||
NSStatusBarButton *button = self->statusItem.button;
|
||||
button.action = @selector(leftMouseClicked);
|
||||
|
||||
[NSEvent addLocalMonitorForEventsMatchingMask: (NSEventTypeLeftMouseDown|NSEventTypeRightMouseDown)
|
||||
handler: ^NSEvent *(NSEvent *event) {
|
||||
if (event.window != self->statusItem.button.window) {
|
||||
return event;
|
||||
}
|
||||
|
||||
[self leftMouseClicked];
|
||||
|
||||
return nil;
|
||||
}];
|
||||
|
||||
NSSize size = [button frame].size;
|
||||
NSRect frame = CGRectMake(0, 0, size.width, size.height);
|
||||
RightClickDetector *rightClicker = [[RightClickDetector alloc] initWithFrame:frame];
|
||||
rightClicker.onRightClicked = ^(NSEvent *event) {
|
||||
[self rightMouseClicked];
|
||||
};
|
||||
|
||||
rightClicker.autoresizingMask = (NSViewWidthSizable |
|
||||
NSViewHeightSizable);
|
||||
button.autoresizesSubviews = YES;
|
||||
[button addSubview:rightClicker];
|
||||
|
||||
systray_ready();
|
||||
}
|
||||
|
||||
- (void)rightMouseClicked {
|
||||
systray_right_click();
|
||||
}
|
||||
|
||||
- (void)leftMouseClicked {
|
||||
systray_left_click();
|
||||
}
|
||||
|
||||
- (void)applicationWillTerminate:(NSNotification *)aNotification
|
||||
{
|
||||
systray_on_exit();
|
||||
}
|
||||
|
||||
- (void)setRemovalAllowed {
|
||||
NSStatusItemBehavior behavior = [self->statusItem behavior];
|
||||
behavior |= NSStatusItemBehaviorRemovalAllowed;
|
||||
self->statusItem.behavior = behavior;
|
||||
}
|
||||
|
||||
- (void)setRemovalForbidden {
|
||||
NSStatusItemBehavior behavior = [self->statusItem behavior];
|
||||
behavior &= ~NSStatusItemBehaviorRemovalAllowed;
|
||||
// Ensure the menu item is visible if it was removed, since we're now
|
||||
// disallowing removal.
|
||||
self->statusItem.visible = TRUE;
|
||||
self->statusItem.behavior = behavior;
|
||||
}
|
||||
|
||||
- (void)setIcon:(NSImage *)image {
|
||||
statusItem.button.image = image;
|
||||
[self updateTitleButtonStyle];
|
||||
@@ -89,7 +167,7 @@ withParentMenuId: (int)theParentMenuId
|
||||
[self updateTitleButtonStyle];
|
||||
}
|
||||
|
||||
-(void)updateTitleButtonStyle {
|
||||
- (void)updateTitleButtonStyle {
|
||||
if (statusItem.button.image != nil) {
|
||||
if ([statusItem.button.title length] == 0) {
|
||||
statusItem.button.imagePosition = NSImageOnly;
|
||||
@@ -112,6 +190,10 @@ withParentMenuId: (int)theParentMenuId
|
||||
systray_menu_item_selected(menuId.intValue);
|
||||
}
|
||||
|
||||
- (void)menuWillOpen:(NSMenu *)menu {
|
||||
systray_menu_will_open();
|
||||
}
|
||||
|
||||
- (void)add_or_update_menu_item:(MenuItem *)item {
|
||||
NSMenu *theMenu = self->menu;
|
||||
NSMenuItem *parentItem;
|
||||
@@ -125,9 +207,8 @@ withParentMenuId: (int)theParentMenuId
|
||||
[parentItem setSubmenu:theMenu];
|
||||
}
|
||||
}
|
||||
|
||||
NSMenuItem *menuItem;
|
||||
menuItem = find_menu_item(theMenu, item->menuId);
|
||||
|
||||
NSMenuItem *menuItem = find_menu_item(theMenu, item->menuId);
|
||||
if (menuItem == NULL) {
|
||||
menuItem = [theMenu addItemWithTitle:item->title
|
||||
action:@selector(menuHandler:)
|
||||
@@ -170,8 +251,15 @@ NSMenuItem *find_menu_item(NSMenu *ourMenu, NSNumber *menuId) {
|
||||
return NULL;
|
||||
};
|
||||
|
||||
- (void) add_separator:(NSNumber*) menuId
|
||||
- (void) add_separator:(NSNumber*) parentMenuId
|
||||
{
|
||||
if (parentMenuId.integerValue != 0) {
|
||||
NSMenuItem* menuItem = find_menu_item(menu, parentMenuId);
|
||||
if (menuItem != NULL) {
|
||||
[menuItem.submenu addItem: [NSMenuItem separatorItem]];
|
||||
return;
|
||||
}
|
||||
}
|
||||
[menu addItem: [NSMenuItem separatorItem]];
|
||||
}
|
||||
|
||||
@@ -195,6 +283,13 @@ NSMenuItem *find_menu_item(NSMenu *ourMenu, NSNumber *menuId) {
|
||||
menuItem.image = image;
|
||||
}
|
||||
|
||||
- (void)show_menu
|
||||
{
|
||||
[self->menu popUpMenuPositioningItem:nil
|
||||
atLocation:NSMakePoint(0, self->statusItem.button.bounds.size.height+6)
|
||||
inView:self->statusItem.button];
|
||||
}
|
||||
|
||||
- (void) show_menu_item:(NSNumber*) menuId
|
||||
{
|
||||
NSMenuItem* menuItem = find_menu_item(menu, menuId);
|
||||
@@ -203,16 +298,55 @@ NSMenuItem *find_menu_item(NSMenu *ourMenu, NSNumber *menuId) {
|
||||
}
|
||||
}
|
||||
|
||||
- (void) remove_menu_item:(NSNumber*) menuId
|
||||
{
|
||||
NSMenuItem* menuItem = find_menu_item(menu, menuId);
|
||||
if (menuItem != NULL) {
|
||||
[menuItem.menu removeItem:menuItem];
|
||||
}
|
||||
}
|
||||
|
||||
- (void) reset_menu
|
||||
{
|
||||
[self->menu removeAllItems];
|
||||
}
|
||||
|
||||
- (void) quit
|
||||
{
|
||||
[NSApp terminate:self];
|
||||
// This tells the app event loop to stop after processing remaining messages.
|
||||
[NSApp stop:self];
|
||||
// The event loop won't return until it processes another event.
|
||||
// https://stackoverflow.com/a/48064752/149482
|
||||
NSPoint eventLocation = NSMakePoint(0, 0);
|
||||
NSEvent *customEvent = [NSEvent otherEventWithType:NSEventTypeApplicationDefined
|
||||
location:eventLocation
|
||||
modifierFlags:0
|
||||
timestamp:0
|
||||
windowNumber:0
|
||||
context:nil
|
||||
subtype:0
|
||||
data1:0
|
||||
data2:0];
|
||||
[NSApp postEvent:customEvent atStart:NO];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
bool internalLoop = false;
|
||||
SystrayAppDelegate *owner;
|
||||
|
||||
void setInternalLoop(bool i) {
|
||||
internalLoop = i;
|
||||
}
|
||||
|
||||
void registerSystray(void) {
|
||||
AppDelegate *delegate = [[AppDelegate alloc] init];
|
||||
[[NSApplication sharedApplication] setDelegate:delegate];
|
||||
if (!internalLoop) { // with an external loop we don't take ownership of the app
|
||||
return;
|
||||
}
|
||||
|
||||
owner = [[SystrayAppDelegate alloc] init];
|
||||
[[NSApplication sharedApplication] setDelegate:owner];
|
||||
|
||||
// A workaround to avoid crashing on macOS versions before Catalina. Somehow
|
||||
// SIGSEGV would happen inside AppKit if [NSApp run] is called from a
|
||||
// different function, even if that function is called right after this.
|
||||
@@ -221,6 +355,10 @@ void registerSystray(void) {
|
||||
}
|
||||
}
|
||||
|
||||
void nativeEnd(void) {
|
||||
systray_on_exit();
|
||||
}
|
||||
|
||||
int nativeLoop(void) {
|
||||
if (floor(NSAppKitVersionNumber) > /*NSAppKitVersionNumber10_14*/ 1671){
|
||||
[NSApp run];
|
||||
@@ -228,8 +366,16 @@ int nativeLoop(void) {
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
void nativeStart(void) {
|
||||
owner = [[SystrayAppDelegate alloc] init];
|
||||
|
||||
NSNotification *launched = [NSNotification notificationWithName:NSApplicationDidFinishLaunchingNotification
|
||||
object:[NSApplication sharedApplication]];
|
||||
[owner applicationDidFinishLaunching:launched];
|
||||
}
|
||||
|
||||
void runInMainThread(SEL method, id object) {
|
||||
[(AppDelegate*)[NSApp delegate]
|
||||
[owner
|
||||
performSelectorOnMainThread:method
|
||||
withObject:object
|
||||
waitUntilDone: YES];
|
||||
@@ -237,19 +383,23 @@ void runInMainThread(SEL method, id object) {
|
||||
|
||||
void setIcon(const char* iconBytes, int length, bool template) {
|
||||
NSData* buffer = [NSData dataWithBytes: iconBytes length:length];
|
||||
NSImage *image = [[NSImage alloc] initWithData:buffer];
|
||||
[image setSize:NSMakeSize(16, 16)];
|
||||
image.template = template;
|
||||
runInMainThread(@selector(setIcon:), (id)image);
|
||||
@autoreleasepool {
|
||||
NSImage *image = [[NSImage alloc] initWithData:buffer];
|
||||
[image setSize:NSMakeSize(16, 16)];
|
||||
image.template = template;
|
||||
runInMainThread(@selector(setIcon:), (id)image);
|
||||
}
|
||||
}
|
||||
|
||||
void setMenuItemIcon(const char* iconBytes, int length, int menuId, bool template) {
|
||||
NSData* buffer = [NSData dataWithBytes: iconBytes length:length];
|
||||
NSImage *image = [[NSImage alloc] initWithData:buffer];
|
||||
[image setSize:NSMakeSize(16, 16)];
|
||||
image.template = template;
|
||||
NSNumber *mId = [NSNumber numberWithInt:menuId];
|
||||
runInMainThread(@selector(setMenuItemIcon:), @[image, (id)mId]);
|
||||
@autoreleasepool {
|
||||
NSImage *image = [[NSImage alloc] initWithData:buffer];
|
||||
[image setSize:NSMakeSize(16, 16)];
|
||||
image.template = template;
|
||||
NSNumber *mId = [NSNumber numberWithInt:menuId];
|
||||
runInMainThread(@selector(setMenuItemIcon:), @[image, (id)mId]);
|
||||
}
|
||||
}
|
||||
|
||||
void setTitle(char* ctitle) {
|
||||
@@ -266,6 +416,14 @@ void setTooltip(char* ctooltip) {
|
||||
runInMainThread(@selector(setTooltip:), (id)tooltip);
|
||||
}
|
||||
|
||||
void setRemovalAllowed(bool allowed) {
|
||||
if (allowed) {
|
||||
runInMainThread(@selector(setRemovalAllowed), nil);
|
||||
} else {
|
||||
runInMainThread(@selector(setRemovalForbidden), nil);
|
||||
}
|
||||
}
|
||||
|
||||
void add_or_update_menu_item(int menuId, int parentMenuId, char* title, char* tooltip, short disabled, short checked, short isCheckable) {
|
||||
MenuItem* item = [[MenuItem alloc] initWithId: menuId withParentMenuId: parentMenuId withTitle: title withTooltip: tooltip withDisabled: disabled withChecked: checked];
|
||||
free(title);
|
||||
@@ -273,9 +431,9 @@ void add_or_update_menu_item(int menuId, int parentMenuId, char* title, char* to
|
||||
runInMainThread(@selector(add_or_update_menu_item:), (id)item);
|
||||
}
|
||||
|
||||
void add_separator(int menuId) {
|
||||
NSNumber *mId = [NSNumber numberWithInt:menuId];
|
||||
runInMainThread(@selector(add_separator:), (id)mId);
|
||||
void add_separator(int menuId, int parentId) {
|
||||
NSNumber *pId = [NSNumber numberWithInt:parentId];
|
||||
runInMainThread(@selector(add_separator:), (id)pId);
|
||||
}
|
||||
|
||||
void hide_menu_item(int menuId) {
|
||||
@@ -283,11 +441,24 @@ void hide_menu_item(int menuId) {
|
||||
runInMainThread(@selector(hide_menu_item:), (id)mId);
|
||||
}
|
||||
|
||||
void remove_menu_item(int menuId) {
|
||||
NSNumber *mId = [NSNumber numberWithInt:menuId];
|
||||
runInMainThread(@selector(remove_menu_item:), (id)mId);
|
||||
}
|
||||
|
||||
void show_menu() {
|
||||
runInMainThread(@selector(show_menu), nil);
|
||||
}
|
||||
|
||||
void show_menu_item(int menuId) {
|
||||
NSNumber *mId = [NSNumber numberWithInt:menuId];
|
||||
runInMainThread(@selector(show_menu_item:), (id)mId);
|
||||
}
|
||||
|
||||
void reset_menu() {
|
||||
runInMainThread(@selector(reset_menu), nil);
|
||||
}
|
||||
|
||||
void quit() {
|
||||
runInMainThread(@selector(quit), nil);
|
||||
}
|
||||
370
vendor/fyne.io/systray/systray_menu_unix.go
generated
vendored
Normal file
@@ -0,0 +1,370 @@
|
||||
//go:build (linux || freebsd || openbsd || netbsd) && !android
|
||||
|
||||
package systray
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/godbus/dbus/v5/prop"
|
||||
|
||||
"fyne.io/systray/internal/generated/menu"
|
||||
)
|
||||
|
||||
// SetIcon sets the icon of a menu item.
|
||||
// iconBytes should be the content of .ico/.jpg/.png
|
||||
func (item *MenuItem) SetIcon(iconBytes []byte) {
|
||||
instance.menuLock.Lock()
|
||||
defer instance.menuLock.Unlock()
|
||||
m, exists := findLayout(int32(item.id))
|
||||
if exists {
|
||||
m.V1["icon-data"] = dbus.MakeVariant(iconBytes)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// SetIconFromFilePath sets the icon of a menu item from a file path.
|
||||
// iconFilePath should be the path to a .ico for windows and .ico/.jpg/.png for other platforms.
|
||||
func (item *MenuItem) SetIconFromFilePath(iconFilePath string) error {
|
||||
iconBytes, err := os.ReadFile(iconFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read icon file: %v", err)
|
||||
}
|
||||
item.SetIcon(iconBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyLayout makes full copy of layout
|
||||
func copyLayout(in *menuLayout, depth int32) *menuLayout {
|
||||
out := menuLayout{
|
||||
V0: in.V0,
|
||||
V1: make(map[string]dbus.Variant, len(in.V1)),
|
||||
}
|
||||
for k, v := range in.V1 {
|
||||
out.V1[k] = v
|
||||
}
|
||||
if depth != 0 {
|
||||
depth--
|
||||
out.V2 = make([]dbus.Variant, len(in.V2))
|
||||
for i, v := range in.V2 {
|
||||
out.V2[i] = dbus.MakeVariant(copyLayout(v.Value().(*menuLayout), depth))
|
||||
}
|
||||
} else {
|
||||
out.V2 = []dbus.Variant{}
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// GetLayout is com.canonical.dbusmenu.GetLayout method.
|
||||
func (t *tray) GetLayout(parentID int32, recursionDepth int32, propertyNames []string) (revision uint32, layout menuLayout, err *dbus.Error) {
|
||||
initialMenuBuilt.Wait()
|
||||
instance.menuLock.Lock()
|
||||
defer instance.menuLock.Unlock()
|
||||
if m, ok := findLayout(parentID); ok {
|
||||
// return copy of menu layout to prevent panic from cuncurrent access to layout
|
||||
return instance.menuVersion, *copyLayout(m, recursionDepth), nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetGroupProperties is com.canonical.dbusmenu.GetGroupProperties method.
|
||||
func (t *tray) GetGroupProperties(ids []int32, propertyNames []string) (properties []struct {
|
||||
V0 int32
|
||||
V1 map[string]dbus.Variant
|
||||
}, err *dbus.Error) {
|
||||
instance.menuLock.Lock()
|
||||
defer instance.menuLock.Unlock()
|
||||
for _, id := range ids {
|
||||
if m, ok := findLayout(id); ok {
|
||||
p := struct {
|
||||
V0 int32
|
||||
V1 map[string]dbus.Variant
|
||||
}{
|
||||
V0: m.V0,
|
||||
V1: make(map[string]dbus.Variant, len(m.V1)),
|
||||
}
|
||||
for k, v := range m.V1 {
|
||||
p.V1[k] = v
|
||||
}
|
||||
properties = append(properties, p)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetProperty is com.canonical.dbusmenu.GetProperty method.
|
||||
func (t *tray) GetProperty(id int32, name string) (value dbus.Variant, err *dbus.Error) {
|
||||
instance.menuLock.Lock()
|
||||
defer instance.menuLock.Unlock()
|
||||
if m, ok := findLayout(id); ok {
|
||||
if p, ok := m.V1[name]; ok {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Event is com.canonical.dbusmenu.Event method.
|
||||
func (t *tray) Event(id int32, eventID string, data dbus.Variant, timestamp uint32) (err *dbus.Error) {
|
||||
switch eventID {
|
||||
case "clicked":
|
||||
systrayMenuItemSelected(uint32(id))
|
||||
case "opened":
|
||||
t.menuLock.RLock()
|
||||
rootMenuID := t.menu.V0
|
||||
t.menuLock.RUnlock()
|
||||
|
||||
if id == rootMenuID {
|
||||
select {
|
||||
case TrayOpenedCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// EventGroup is com.canonical.dbusmenu.EventGroup method.
|
||||
func (t *tray) EventGroup(events []struct {
|
||||
V0 int32
|
||||
V1 string
|
||||
V2 dbus.Variant
|
||||
V3 uint32
|
||||
}) (idErrors []int32, err *dbus.Error) {
|
||||
for _, event := range events {
|
||||
if event.V1 == "clicked" {
|
||||
systrayMenuItemSelected(uint32(event.V0))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// AboutToShow is com.canonical.dbusmenu.AboutToShow method.
|
||||
func (t *tray) AboutToShow(id int32) (needUpdate bool, err *dbus.Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// AboutToShowGroup is com.canonical.dbusmenu.AboutToShowGroup method.
|
||||
func (t *tray) AboutToShowGroup(ids []int32) (updatesNeeded []int32, idErrors []int32, err *dbus.Error) {
|
||||
return
|
||||
}
|
||||
|
||||
func createMenuPropSpec() map[string]map[string]*prop.Prop {
|
||||
instance.menuLock.Lock()
|
||||
defer instance.menuLock.Unlock()
|
||||
return map[string]map[string]*prop.Prop{
|
||||
"com.canonical.dbusmenu": {
|
||||
"Version": {
|
||||
Value: instance.menuVersion,
|
||||
Writable: true,
|
||||
Emit: prop.EmitTrue,
|
||||
Callback: nil,
|
||||
},
|
||||
"TextDirection": {
|
||||
Value: "ltr",
|
||||
Writable: false,
|
||||
Emit: prop.EmitTrue,
|
||||
Callback: nil,
|
||||
},
|
||||
"Status": {
|
||||
Value: "normal",
|
||||
Writable: false,
|
||||
Emit: prop.EmitTrue,
|
||||
Callback: nil,
|
||||
},
|
||||
"IconThemePath": {
|
||||
Value: []string{},
|
||||
Writable: false,
|
||||
Emit: prop.EmitTrue,
|
||||
Callback: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// menuLayout is a named struct to map into generated bindings. It represents the layout of a menu item
|
||||
type menuLayout = struct {
|
||||
V0 int32 // the unique ID of this item
|
||||
V1 map[string]dbus.Variant // properties for this menu item layout
|
||||
V2 []dbus.Variant // child menu item layouts
|
||||
}
|
||||
|
||||
func addOrUpdateMenuItem(item *MenuItem) {
|
||||
var layout *menuLayout
|
||||
instance.menuLock.Lock()
|
||||
defer instance.menuLock.Unlock()
|
||||
m, exists := findLayout(int32(item.id))
|
||||
if exists {
|
||||
layout = m
|
||||
} else {
|
||||
layout = &menuLayout{
|
||||
V0: int32(item.id),
|
||||
V1: map[string]dbus.Variant{},
|
||||
V2: []dbus.Variant{},
|
||||
}
|
||||
|
||||
parent := instance.menu
|
||||
if item.parent != nil {
|
||||
m, ok := findLayout(int32(item.parent.id))
|
||||
if ok {
|
||||
parent = m
|
||||
parent.V1["children-display"] = dbus.MakeVariant("submenu")
|
||||
}
|
||||
}
|
||||
parent.V2 = append(parent.V2, dbus.MakeVariant(layout))
|
||||
}
|
||||
|
||||
applyItemToLayout(item, layout)
|
||||
if exists {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func addSeparator(id uint32, parent uint32) {
|
||||
menu, _ := findLayout(int32(parent))
|
||||
|
||||
instance.menuLock.Lock()
|
||||
defer instance.menuLock.Unlock()
|
||||
layout := &menuLayout{
|
||||
V0: int32(id),
|
||||
V1: map[string]dbus.Variant{
|
||||
"type": dbus.MakeVariant("separator"),
|
||||
},
|
||||
V2: []dbus.Variant{},
|
||||
}
|
||||
menu.V2 = append(menu.V2, dbus.MakeVariant(layout))
|
||||
refresh()
|
||||
}
|
||||
|
||||
func applyItemToLayout(in *MenuItem, out *menuLayout) {
|
||||
out.V1["enabled"] = dbus.MakeVariant(!in.disabled)
|
||||
out.V1["label"] = dbus.MakeVariant(in.title)
|
||||
|
||||
if in.isCheckable {
|
||||
out.V1["toggle-type"] = dbus.MakeVariant("checkmark")
|
||||
if in.checked {
|
||||
out.V1["toggle-state"] = dbus.MakeVariant(1)
|
||||
} else {
|
||||
out.V1["toggle-state"] = dbus.MakeVariant(0)
|
||||
}
|
||||
} else {
|
||||
out.V1["toggle-type"] = dbus.MakeVariant("")
|
||||
out.V1["toggle-state"] = dbus.MakeVariant(0)
|
||||
}
|
||||
}
|
||||
|
||||
func findLayout(id int32) (*menuLayout, bool) {
|
||||
if id == 0 {
|
||||
return instance.menu, true
|
||||
}
|
||||
return findSubLayout(id, instance.menu.V2)
|
||||
}
|
||||
|
||||
func findSubLayout(id int32, vals []dbus.Variant) (*menuLayout, bool) {
|
||||
for _, i := range vals {
|
||||
item := i.Value().(*menuLayout)
|
||||
if item.V0 == id {
|
||||
return item, true
|
||||
}
|
||||
|
||||
if len(item.V2) > 0 {
|
||||
child, ok := findSubLayout(id, item.V2)
|
||||
if ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func removeSubLayout(id int32, vals []dbus.Variant) ([]dbus.Variant, bool) {
|
||||
for idx, i := range vals {
|
||||
item := i.Value().(*menuLayout)
|
||||
if item.V0 == id {
|
||||
return append(vals[:idx], vals[idx+1:]...), true
|
||||
}
|
||||
|
||||
if len(item.V2) > 0 {
|
||||
if child, removed := removeSubLayout(id, item.V2); removed {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vals, false
|
||||
}
|
||||
|
||||
func removeMenuItem(item *MenuItem) {
|
||||
instance.menuLock.Lock()
|
||||
defer instance.menuLock.Unlock()
|
||||
|
||||
parent := instance.menu
|
||||
if item.parent != nil {
|
||||
m, ok := findLayout(int32(item.parent.id))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
parent = m
|
||||
}
|
||||
|
||||
if items, removed := removeSubLayout(int32(item.id), parent.V2); removed {
|
||||
parent.V2 = items
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func hideMenuItem(item *MenuItem) {
|
||||
instance.menuLock.Lock()
|
||||
defer instance.menuLock.Unlock()
|
||||
m, exists := findLayout(int32(item.id))
|
||||
if exists {
|
||||
m.V1["visible"] = dbus.MakeVariant(false)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func showMenuItem(item *MenuItem) {
|
||||
instance.menuLock.Lock()
|
||||
defer instance.menuLock.Unlock()
|
||||
m, exists := findLayout(int32(item.id))
|
||||
if exists {
|
||||
m.V1["visible"] = dbus.MakeVariant(true)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
instance.lock.Lock()
|
||||
defer instance.lock.Unlock()
|
||||
if instance.conn == nil || instance.menuProps == nil {
|
||||
return
|
||||
}
|
||||
instance.menuVersion++
|
||||
dbusErr := instance.menuProps.Set("com.canonical.dbusmenu", "Version",
|
||||
dbus.MakeVariant(instance.menuVersion))
|
||||
if dbusErr != nil {
|
||||
log.Printf("systray error: failed to update menu version: %v\n", dbusErr)
|
||||
return
|
||||
}
|
||||
err := menu.Emit(instance.conn, &menu.Dbusmenu_LayoutUpdatedSignal{
|
||||
Path: menuPath,
|
||||
Body: &menu.Dbusmenu_LayoutUpdatedSignalBody{
|
||||
Revision: instance.menuVersion,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("systray error: failed to emit layout updated signal: %v\n", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func resetMenu() {
|
||||
instance.menuLock.Lock()
|
||||
defer instance.menuLock.Unlock()
|
||||
instance.menu = &menuLayout{}
|
||||
instance.menuVersion++
|
||||
refresh()
|
||||
}
|
||||
44
vendor/fyne.io/systray/systray_notifier_unix.go
generated
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
package systray
|
||||
|
||||
import (
|
||||
"fyne.io/systray/internal/generated/notifier"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
type leftRightNotifierItem struct {
|
||||
}
|
||||
|
||||
func newLeftRightNotifierItem() notifier.StatusNotifierItemer {
|
||||
return &leftRightNotifierItem{}
|
||||
}
|
||||
|
||||
func (i *leftRightNotifierItem) Activate(_, _ int32) *dbus.Error {
|
||||
if f := tappedLeft; f == nil {
|
||||
return &dbus.ErrMsgUnknownMethod
|
||||
}
|
||||
|
||||
tappedLeft()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *leftRightNotifierItem) ContextMenu(_, _ int32) *dbus.Error {
|
||||
if f := tappedRight; f == nil {
|
||||
return &dbus.ErrMsgUnknownMethod
|
||||
}
|
||||
|
||||
tappedRight()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *leftRightNotifierItem) SecondaryActivate(_, _ int32) *dbus.Error {
|
||||
if f := tappedRight; f == nil {
|
||||
return &dbus.ErrMsgUnknownMethod
|
||||
}
|
||||
|
||||
tappedRight()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *leftRightNotifierItem) Scroll(_ int32, _ string) *dbus.Error {
|
||||
return &dbus.ErrMsgUnknownMethod
|
||||
}
|
||||
435
vendor/fyne.io/systray/systray_unix.go
generated
vendored
Normal file
@@ -0,0 +1,435 @@
|
||||
//go:build (linux || freebsd || openbsd || netbsd) && !android
|
||||
|
||||
//Note that you need to have github.com/knightpp/dbus-codegen-go installed from "custom" branch
|
||||
//go:generate dbus-codegen-go -prefix org.kde -package notifier -output internal/generated/notifier/status_notifier_item.go internal/StatusNotifierItem.xml
|
||||
//go:generate dbus-codegen-go -prefix com.canonical -package menu -output internal/generated/menu/dbus_menu.go internal/DbusMenu.xml
|
||||
|
||||
package systray
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/png" // used only here
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/godbus/dbus/v5/introspect"
|
||||
"github.com/godbus/dbus/v5/prop"
|
||||
|
||||
"fyne.io/systray/internal/generated/menu"
|
||||
"fyne.io/systray/internal/generated/notifier"
|
||||
)
|
||||
|
||||
const (
|
||||
path = "/StatusNotifierItem"
|
||||
menuPath = "/StatusNotifierItem/menu"
|
||||
)
|
||||
|
||||
var (
|
||||
// to signal quitting the internal main loop
|
||||
quitChan = make(chan struct{})
|
||||
|
||||
// instance is the current instance of our DBus tray server
|
||||
instance = &tray{menu: &menuLayout{}, menuVersion: 1}
|
||||
)
|
||||
|
||||
// SetTemplateIcon sets the systray icon as a template icon (on macOS), falling back
|
||||
// to a regular icon on other platforms.
|
||||
// templateIconBytes and iconBytes should be the content of .ico for windows and
|
||||
// .ico/.jpg/.png for other platforms.
|
||||
func SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) {
|
||||
// TODO handle the templateIconBytes?
|
||||
SetIcon(regularIconBytes)
|
||||
}
|
||||
|
||||
// SetIcon sets the systray icon.
|
||||
// iconBytes should be the content of .ico for windows and .ico/.jpg/.png
|
||||
// for other platforms.
|
||||
func SetIcon(iconBytes []byte) {
|
||||
instance.lock.Lock()
|
||||
instance.iconData = iconBytes
|
||||
props := instance.props
|
||||
conn := instance.conn
|
||||
defer instance.lock.Unlock()
|
||||
|
||||
if props == nil {
|
||||
return
|
||||
}
|
||||
|
||||
props.SetMust("org.kde.StatusNotifierItem", "IconPixmap",
|
||||
[]PX{convertToPixels(iconBytes)})
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := notifier.Emit(conn, ¬ifier.StatusNotifierItem_NewIconSignal{
|
||||
Path: path,
|
||||
Body: ¬ifier.StatusNotifierItem_NewIconSignalBody{},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("systray error: failed to emit new icon signal: %s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// SetIconFromFilePath sets the systray icon from a file path.
|
||||
// iconFilePath should be the path to a .ico for windows and .ico/.jpg/.png for other platforms.
|
||||
func SetIconFromFilePath(iconFilePath string) error {
|
||||
bytes, err := os.ReadFile(iconFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read icon file: %v", err)
|
||||
}
|
||||
SetIcon(bytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTitle sets the systray title, only available on Mac and Linux.
|
||||
func SetTitle(t string) {
|
||||
instance.lock.Lock()
|
||||
instance.title = t
|
||||
props := instance.props
|
||||
conn := instance.conn
|
||||
defer instance.lock.Unlock()
|
||||
|
||||
if props == nil {
|
||||
return
|
||||
}
|
||||
dbusErr := props.Set("org.kde.StatusNotifierItem", "Title",
|
||||
dbus.MakeVariant(t))
|
||||
if dbusErr != nil {
|
||||
log.Printf("systray error: failed to set Title prop: %s\n", dbusErr)
|
||||
return
|
||||
}
|
||||
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := notifier.Emit(conn, ¬ifier.StatusNotifierItem_NewTitleSignal{
|
||||
Path: path,
|
||||
Body: ¬ifier.StatusNotifierItem_NewTitleSignalBody{},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("systray error: failed to emit new title signal: %s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// SetTooltip sets the systray tooltip to display on mouse hover of the tray icon,
|
||||
// only available on Mac and Windows.
|
||||
func SetTooltip(tooltipTitle string) {
|
||||
instance.lock.Lock()
|
||||
instance.tooltipTitle = tooltipTitle
|
||||
props := instance.props
|
||||
defer instance.lock.Unlock()
|
||||
|
||||
if props == nil {
|
||||
return
|
||||
}
|
||||
dbusErr := props.Set("org.kde.StatusNotifierItem", "ToolTip",
|
||||
dbus.MakeVariant(tooltip{V2: tooltipTitle}))
|
||||
if dbusErr != nil {
|
||||
log.Printf("systray error: failed to set ToolTip prop: %s\n", dbusErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// SetTemplateIcon sets the icon of a menu item as a template icon (on macOS). On Windows and
|
||||
// Linux, it falls back to the regular icon bytes.
|
||||
// templateIconBytes and regularIconBytes should be the content of .ico for windows and
|
||||
// .ico/.jpg/.png for other platforms.
|
||||
func (item *MenuItem) SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) {
|
||||
item.SetIcon(regularIconBytes)
|
||||
}
|
||||
|
||||
// SetRemovalAllowed sets whether a user can remove the systray icon or not.
|
||||
// This is only supported on macOS.
|
||||
func SetRemovalAllowed(allowed bool) {
|
||||
}
|
||||
|
||||
func setInternalLoop(_ bool) {
|
||||
// nothing to action on Linux
|
||||
}
|
||||
|
||||
func registerSystray() {
|
||||
}
|
||||
|
||||
func nativeLoop() int {
|
||||
nativeStart()
|
||||
<-quitChan
|
||||
nativeEnd()
|
||||
return 0
|
||||
}
|
||||
|
||||
func nativeEnd() {
|
||||
runSystrayExit()
|
||||
instance.conn.Close()
|
||||
}
|
||||
|
||||
func quit() {
|
||||
close(quitChan)
|
||||
}
|
||||
|
||||
func nativeStart() {
|
||||
systrayReady()
|
||||
conn, err := dbus.SessionBus()
|
||||
if err != nil {
|
||||
log.Printf("systray error: failed to connect to DBus: %v\n", err)
|
||||
return
|
||||
}
|
||||
err = notifier.ExportStatusNotifierItem(conn, path, newLeftRightNotifierItem())
|
||||
if err != nil {
|
||||
log.Printf("systray error: failed to export status notifier item: %v\n", err)
|
||||
}
|
||||
err = menu.ExportDbusmenu(conn, menuPath, instance)
|
||||
if err != nil {
|
||||
log.Printf("systray error: failed to export status notifier menu: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("org.kde.StatusNotifierItem-%d-1", os.Getpid()) // register id 1 for this process
|
||||
_, err = conn.RequestName(name, dbus.NameFlagDoNotQueue)
|
||||
if err != nil {
|
||||
log.Printf("systray error: failed to request name: %s\n", err)
|
||||
// it's not critical error: continue
|
||||
}
|
||||
props, err := prop.Export(conn, path, instance.createPropSpec())
|
||||
if err != nil {
|
||||
log.Printf("systray error: failed to export notifier item properties to bus: %s\n", err)
|
||||
return
|
||||
}
|
||||
menuProps, err := prop.Export(conn, menuPath, createMenuPropSpec())
|
||||
if err != nil {
|
||||
log.Printf("systray error: failed to export notifier menu properties to bus: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
node := introspect.Node{
|
||||
Name: path,
|
||||
Interfaces: []introspect.Interface{
|
||||
introspect.IntrospectData,
|
||||
prop.IntrospectData,
|
||||
notifier.IntrospectDataStatusNotifierItem,
|
||||
},
|
||||
}
|
||||
err = conn.Export(introspect.NewIntrospectable(&node), path,
|
||||
"org.freedesktop.DBus.Introspectable")
|
||||
if err != nil {
|
||||
log.Printf("systray error: failed to export node introspection: %s\n", err)
|
||||
return
|
||||
}
|
||||
menuNode := introspect.Node{
|
||||
Name: menuPath,
|
||||
Interfaces: []introspect.Interface{
|
||||
introspect.IntrospectData,
|
||||
prop.IntrospectData,
|
||||
menu.IntrospectDataDbusmenu,
|
||||
},
|
||||
}
|
||||
err = conn.Export(introspect.NewIntrospectable(&menuNode), menuPath,
|
||||
"org.freedesktop.DBus.Introspectable")
|
||||
if err != nil {
|
||||
log.Printf("systray error: failed to export menu node introspection: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
instance.lock.Lock()
|
||||
instance.conn = conn
|
||||
instance.props = props
|
||||
instance.menuProps = menuProps
|
||||
instance.lock.Unlock()
|
||||
|
||||
go stayRegistered()
|
||||
}
|
||||
|
||||
func register() bool {
|
||||
obj := instance.conn.Object("org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher")
|
||||
call := obj.Call("org.kde.StatusNotifierWatcher.RegisterStatusNotifierItem", 0, path)
|
||||
if call.Err != nil {
|
||||
log.Printf("systray error: failed to register: %v\n", call.Err)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func stayRegistered() {
|
||||
register()
|
||||
|
||||
conn := instance.conn
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath("/org/freedesktop/DBus"),
|
||||
dbus.WithMatchInterface("org.freedesktop.DBus"),
|
||||
dbus.WithMatchSender("org.freedesktop.DBus"),
|
||||
dbus.WithMatchMember("NameOwnerChanged"),
|
||||
dbus.WithMatchArg(0, "org.kde.StatusNotifierWatcher"),
|
||||
); err != nil {
|
||||
log.Printf("systray error: failed to register signal matching: %v\n", err)
|
||||
// If we can't monitor signals, there is no point in
|
||||
// us being here. we're either registered or not (per
|
||||
// above) and will roll the dice from here...
|
||||
return
|
||||
}
|
||||
|
||||
sc := make(chan *dbus.Signal, 10)
|
||||
conn.Signal(sc)
|
||||
|
||||
for {
|
||||
select {
|
||||
case sig := <-sc:
|
||||
if sig == nil {
|
||||
return // We get a nil signal when closing the window.
|
||||
} else if len(sig.Body) < 3 {
|
||||
return // malformed signal?
|
||||
}
|
||||
|
||||
// sig.Body has the args, which are [name old_owner new_owner]
|
||||
if s, ok := sig.Body[2].(string); ok && s != "" {
|
||||
register()
|
||||
}
|
||||
case <-quitChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tray is a basic type that handles the dbus functionality
|
||||
type tray struct {
|
||||
// the DBus connection that we will use
|
||||
conn *dbus.Conn
|
||||
|
||||
// icon data for the main systray icon
|
||||
iconData []byte
|
||||
// title and tooltip state
|
||||
title, tooltipTitle string
|
||||
|
||||
lock sync.Mutex
|
||||
menu *menuLayout
|
||||
menuLock sync.RWMutex
|
||||
props, menuProps *prop.Properties
|
||||
menuVersion uint32
|
||||
}
|
||||
|
||||
func (t *tray) createPropSpec() map[string]map[string]*prop.Prop {
|
||||
t.lock.Lock()
|
||||
defer t.lock.Unlock()
|
||||
id := t.title
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("systray_%d", os.Getpid())
|
||||
}
|
||||
return map[string]map[string]*prop.Prop{
|
||||
"org.kde.StatusNotifierItem": {
|
||||
"Status": {
|
||||
Value: "Active", // Passive, Active or NeedsAttention
|
||||
Writable: false,
|
||||
Emit: prop.EmitTrue,
|
||||
Callback: nil,
|
||||
},
|
||||
"Title": {
|
||||
Value: t.title,
|
||||
Writable: true,
|
||||
Emit: prop.EmitTrue,
|
||||
Callback: nil,
|
||||
},
|
||||
"Id": {
|
||||
Value: id,
|
||||
Writable: false,
|
||||
Emit: prop.EmitTrue,
|
||||
Callback: nil,
|
||||
},
|
||||
"Category": {
|
||||
Value: "ApplicationStatus",
|
||||
Writable: false,
|
||||
Emit: prop.EmitTrue,
|
||||
Callback: nil,
|
||||
},
|
||||
"IconName": {
|
||||
Value: "",
|
||||
Writable: false,
|
||||
Emit: prop.EmitTrue,
|
||||
Callback: nil,
|
||||
},
|
||||
"IconPixmap": {
|
||||
Value: []PX{convertToPixels(t.iconData)},
|
||||
Writable: true,
|
||||
Emit: prop.EmitTrue,
|
||||
Callback: nil,
|
||||
},
|
||||
"IconThemePath": {
|
||||
Value: "",
|
||||
Writable: false,
|
||||
Emit: prop.EmitTrue,
|
||||
Callback: nil,
|
||||
},
|
||||
"ItemIsMenu": {
|
||||
Value: tappedLeft == nil && tappedRight == nil,
|
||||
Writable: false,
|
||||
Emit: prop.EmitTrue,
|
||||
Callback: nil,
|
||||
},
|
||||
"Menu": {
|
||||
Value: dbus.ObjectPath(menuPath),
|
||||
Writable: true,
|
||||
Emit: prop.EmitTrue,
|
||||
Callback: nil,
|
||||
},
|
||||
"ToolTip": {
|
||||
Value: tooltip{V2: t.tooltipTitle},
|
||||
Writable: true,
|
||||
Emit: prop.EmitTrue,
|
||||
Callback: nil,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// PX is picture pix map structure with width and high
|
||||
type PX struct {
|
||||
W, H int
|
||||
Pix []byte
|
||||
}
|
||||
|
||||
// tooltip is our data for a tooltip property.
|
||||
// Param names need to match the generated code...
|
||||
type tooltip = struct {
|
||||
V0 string // name
|
||||
V1 []PX // icons
|
||||
V2 string // title
|
||||
V3 string // description
|
||||
}
|
||||
|
||||
func convertToPixels(data []byte) PX {
|
||||
if len(data) == 0 {
|
||||
return PX{}
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
log.Printf("Failed to read icon format %v", err)
|
||||
return PX{}
|
||||
}
|
||||
|
||||
return PX{
|
||||
img.Bounds().Dx(), img.Bounds().Dy(),
|
||||
argbForImage(img),
|
||||
}
|
||||
}
|
||||
|
||||
func argbForImage(img image.Image) []byte {
|
||||
w, h := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
data := make([]byte, w*h*4)
|
||||
i := 0
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
r, g, b, a := img.At(x, y).RGBA()
|
||||
data[i] = byte(a)
|
||||
data[i+1] = byte(r)
|
||||
data[i+2] = byte(g)
|
||||
data[i+3] = byte(b)
|
||||
i += 4
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
328
src/systray/systray_windows.go → vendor/fyne.io/systray/systray_windows.go
generated
vendored
@@ -1,17 +1,19 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package systray
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
@@ -24,6 +26,7 @@ var (
|
||||
g32 = windows.NewLazySystemDLL("Gdi32.dll")
|
||||
pCreateCompatibleBitmap = g32.NewProc("CreateCompatibleBitmap")
|
||||
pCreateCompatibleDC = g32.NewProc("CreateCompatibleDC")
|
||||
pCreateDIBSection = g32.NewProc("CreateDIBSection")
|
||||
pDeleteDC = g32.NewProc("DeleteDC")
|
||||
pSelectObject = g32.NewProc("SelectObject")
|
||||
|
||||
@@ -39,12 +42,13 @@ var (
|
||||
pCreateWindowEx = u32.NewProc("CreateWindowExW")
|
||||
pDefWindowProc = u32.NewProc("DefWindowProcW")
|
||||
pDeleteMenu = u32.NewProc("DeleteMenu")
|
||||
pDestroyMenu = u32.NewProc("DestroyMenu")
|
||||
pRemoveMenu = u32.NewProc("RemoveMenu")
|
||||
pDestroyWindow = u32.NewProc("DestroyWindow")
|
||||
pDispatchMessage = u32.NewProc("DispatchMessageW")
|
||||
pDrawIconEx = u32.NewProc("DrawIconEx")
|
||||
pGetCursorPos = u32.NewProc("GetCursorPos")
|
||||
pGetDC = u32.NewProc("GetDC")
|
||||
pGetMenuItemID = u32.NewProc("GetMenuItemID")
|
||||
pGetMessage = u32.NewProc("GetMessageW")
|
||||
pGetSystemMetrics = u32.NewProc("GetSystemMetrics")
|
||||
pInsertMenuItem = u32.NewProc("InsertMenuItemW")
|
||||
@@ -64,6 +68,9 @@ var (
|
||||
pTranslateMessage = u32.NewProc("TranslateMessage")
|
||||
pUnregisterClass = u32.NewProc("UnregisterClassW")
|
||||
pUpdateWindow = u32.NewProc("UpdateWindow")
|
||||
|
||||
// ErrTrayNotReadyYet is returned by functions when they are called before the tray has been initialized.
|
||||
ErrTrayNotReadyYet = errors.New("tray not ready yet")
|
||||
)
|
||||
|
||||
// Contains window class information.
|
||||
@@ -175,6 +182,30 @@ type point struct {
|
||||
X, Y int32
|
||||
}
|
||||
|
||||
// The BITMAPINFO structure defines the dimensions and color information for a DIB.
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapinfo
|
||||
type bitmapInfo struct {
|
||||
BmiHeader bitmapInfoHeader
|
||||
BmiColors windows.Handle
|
||||
}
|
||||
|
||||
// The BITMAPINFOHEADER structure contains information about the dimensions and color format of a device-independent bitmap (DIB).
|
||||
// https://learn.microsoft.com/en-us/previous-versions/dd183376(v=vs.85)
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapinfoheader
|
||||
type bitmapInfoHeader struct {
|
||||
BiSize uint32
|
||||
BiWidth int32
|
||||
BiHeight int32
|
||||
BiPlanes uint16
|
||||
BiBitCount uint16
|
||||
BiCompression uint32
|
||||
BiSizeImage uint32
|
||||
BiXPelsPerMeter int32
|
||||
BiYPelsPerMeter int32
|
||||
BiClrUsed uint32
|
||||
BiClrImportant uint32
|
||||
}
|
||||
|
||||
// Contains information about loaded resources
|
||||
type winTray struct {
|
||||
instance,
|
||||
@@ -205,11 +236,22 @@ type winTray struct {
|
||||
|
||||
wmSystrayMessage,
|
||||
wmTaskbarCreated uint32
|
||||
|
||||
initialized atomic.Bool
|
||||
}
|
||||
|
||||
// isReady checks if the tray as already been initialized. It is not goroutine safe with in regard to the initialization function, but prevents a panic when functions are called too early.
|
||||
func (t *winTray) isReady() bool {
|
||||
return t.initialized.Load()
|
||||
}
|
||||
|
||||
// Loads an image from file and shows it in tray.
|
||||
// Shell_NotifyIcon: https://msdn.microsoft.com/en-us/library/windows/desktop/bb762159(v=vs.85).aspx
|
||||
func (t *winTray) setIcon(src string) error {
|
||||
if !wt.isReady() {
|
||||
return ErrTrayNotReadyYet
|
||||
}
|
||||
|
||||
const NIF_ICON = 0x00000002
|
||||
|
||||
h, err := t.loadIconFrom(src)
|
||||
@@ -229,6 +271,10 @@ func (t *winTray) setIcon(src string) error {
|
||||
// Sets tooltip on icon.
|
||||
// Shell_NotifyIcon: https://msdn.microsoft.com/en-us/library/windows/desktop/bb762159(v=vs.85).aspx
|
||||
func (t *winTray) setTooltip(src string) error {
|
||||
if !wt.isReady() {
|
||||
return ErrTrayNotReadyYet
|
||||
}
|
||||
|
||||
const NIF_TIP = 0x00000004
|
||||
b, err := windows.UTF16FromString(src)
|
||||
if err != nil {
|
||||
@@ -244,7 +290,7 @@ func (t *winTray) setTooltip(src string) error {
|
||||
return t.nid.modify()
|
||||
}
|
||||
|
||||
var wt winTray
|
||||
var wt = winTray{}
|
||||
|
||||
// WindowProc callback function that processes messages sent to a window.
|
||||
// https://msdn.microsoft.com/en-us/library/windows/desktop/ms633573(v=vs.85).aspx
|
||||
@@ -256,11 +302,8 @@ func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam ui
|
||||
WM_ENDSESSION = 0x0016
|
||||
WM_CLOSE = 0x0010
|
||||
WM_DESTROY = 0x0002
|
||||
WM_CREATE = 0x0001
|
||||
)
|
||||
switch message {
|
||||
case WM_CREATE:
|
||||
systrayReady()
|
||||
case WM_COMMAND:
|
||||
menuItemId := int32(wParam)
|
||||
// https://docs.microsoft.com/en-us/windows/win32/menurc/wm-command#menus
|
||||
@@ -280,11 +323,13 @@ func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam ui
|
||||
t.nid.delete()
|
||||
}
|
||||
t.muNID.Unlock()
|
||||
systrayExit()
|
||||
runSystrayExit()
|
||||
case t.wmSystrayMessage:
|
||||
switch lParam {
|
||||
case WM_RBUTTONUP, WM_LBUTTONUP:
|
||||
t.showMenu()
|
||||
case WM_LBUTTONUP:
|
||||
systrayLeftClick()
|
||||
case WM_RBUTTONUP:
|
||||
systrayRightClick()
|
||||
}
|
||||
case t.wmTaskbarCreated: // on explorer.exe restarts
|
||||
t.muNID.Lock()
|
||||
@@ -494,7 +539,16 @@ func (t *winTray) convertToSubMenu(menuItemId uint32) (windows.Handle, error) {
|
||||
return menu, nil
|
||||
}
|
||||
|
||||
// SetRemovalAllowed sets whether a user can remove the systray icon or not.
|
||||
// This is only supported on macOS.
|
||||
func SetRemovalAllowed(allowed bool) {
|
||||
}
|
||||
|
||||
func (t *winTray) addOrUpdateMenuItem(menuItemId uint32, parentId uint32, title string, disabled, checked bool) error {
|
||||
if !wt.isReady() {
|
||||
return ErrTrayNotReadyYet
|
||||
}
|
||||
|
||||
// https://msdn.microsoft.com/en-us/library/windows/desktop/ms647578(v=vs.85).aspx
|
||||
const (
|
||||
MIIM_FTYPE = 0x00000100
|
||||
@@ -559,6 +613,14 @@ func (t *winTray) addOrUpdateMenuItem(menuItemId uint32, parentId uint32, title
|
||||
}
|
||||
|
||||
if res == 0 {
|
||||
// Menu item does not already exist, create it
|
||||
t.muMenus.RLock()
|
||||
submenu, exists := t.menus[menuItemId]
|
||||
t.muMenus.RUnlock()
|
||||
if exists {
|
||||
mi.Mask |= MIIM_SUBMENU
|
||||
mi.SubMenu = submenu
|
||||
}
|
||||
t.addToVisibleItems(parentId, menuItemId)
|
||||
position := t.getVisibleItemIndex(parentId, menuItemId)
|
||||
res, _, err = pInsertMenuItem.Call(
|
||||
@@ -580,6 +642,10 @@ func (t *winTray) addOrUpdateMenuItem(menuItemId uint32, parentId uint32, title
|
||||
}
|
||||
|
||||
func (t *winTray) addSeparatorMenuItem(menuItemId, parentId uint32) error {
|
||||
if !wt.isReady() {
|
||||
return ErrTrayNotReadyYet
|
||||
}
|
||||
|
||||
// https://msdn.microsoft.com/en-us/library/windows/desktop/ms647578(v=vs.85).aspx
|
||||
const (
|
||||
MIIM_FTYPE = 0x00000100
|
||||
@@ -614,8 +680,11 @@ func (t *winTray) addSeparatorMenuItem(menuItemId, parentId uint32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *winTray) hideMenuItem(menuItemId, parentId uint32) error {
|
||||
// https://msdn.microsoft.com/en-us/library/windows/desktop/ms647629(v=vs.85).aspx
|
||||
func (t *winTray) removeMenuItem(menuItemId, parentId uint32) error {
|
||||
if !wt.isReady() {
|
||||
return ErrTrayNotReadyYet
|
||||
}
|
||||
|
||||
const MF_BYCOMMAND = 0x00000000
|
||||
const ERROR_SUCCESS syscall.Errno = 0
|
||||
|
||||
@@ -635,7 +704,35 @@ func (t *winTray) hideMenuItem(menuItemId, parentId uint32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *winTray) hideMenuItem(menuItemId, parentId uint32) error {
|
||||
if !wt.isReady() {
|
||||
return ErrTrayNotReadyYet
|
||||
}
|
||||
|
||||
const MF_BYCOMMAND = 0x00000000
|
||||
const ERROR_SUCCESS syscall.Errno = 0
|
||||
|
||||
t.muMenus.RLock()
|
||||
menu := uintptr(t.menus[parentId])
|
||||
t.muMenus.RUnlock()
|
||||
res, _, err := pRemoveMenu.Call(
|
||||
menu,
|
||||
uintptr(menuItemId),
|
||||
MF_BYCOMMAND,
|
||||
)
|
||||
if res == 0 && err.(syscall.Errno) != ERROR_SUCCESS {
|
||||
return err
|
||||
}
|
||||
t.delFromVisibleItems(parentId, menuItemId)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *winTray) showMenu() error {
|
||||
if !wt.isReady() {
|
||||
return ErrTrayNotReadyYet
|
||||
}
|
||||
|
||||
const (
|
||||
TPM_BOTTOMALIGN = 0x0020
|
||||
TPM_LEFTALIGN = 0x0000
|
||||
@@ -669,7 +766,7 @@ func (t *winTray) delFromVisibleItems(parent, val uint32) {
|
||||
visibleItems := t.visibleItems[parent]
|
||||
for i, itemval := range visibleItems {
|
||||
if val == itemval {
|
||||
visibleItems = append(visibleItems[:i], visibleItems[i+1:]...)
|
||||
t.visibleItems[parent] = append(visibleItems[:i], visibleItems[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -701,6 +798,10 @@ func (t *winTray) getVisibleItemIndex(parent, val uint32) int {
|
||||
// Loads an image from file to be shown in tray or menu item.
|
||||
// LoadImage: https://msdn.microsoft.com/en-us/library/windows/desktop/ms648045(v=vs.85).aspx
|
||||
func (t *winTray) loadIconFrom(src string) (windows.Handle, error) {
|
||||
if !wt.isReady() {
|
||||
return 0, ErrTrayNotReadyYet
|
||||
}
|
||||
|
||||
const IMAGE_ICON = 1 // Loads an icon
|
||||
const LR_LOADFROMFILE = 0x00000010 // Loads the stand-alone image from the file
|
||||
const LR_DEFAULTSIZE = 0x00000040 // Loads default-size icon for windows(SM_CXICON x SM_CYICON) if cx, cy are set to zero
|
||||
@@ -733,7 +834,7 @@ func (t *winTray) loadIconFrom(src string) (windows.Handle, error) {
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (t *winTray) iconToBitmap(hIcon windows.Handle) (windows.Handle, error) {
|
||||
func iconToBitmap(hIcon windows.Handle) (windows.Handle, error) {
|
||||
const SM_CXSMICON = 49
|
||||
const SM_CYSMICON = 50
|
||||
const DI_NORMAL = 0x3
|
||||
@@ -749,10 +850,7 @@ func (t *winTray) iconToBitmap(hIcon windows.Handle) (windows.Handle, error) {
|
||||
defer pDeleteDC.Call(hMemDC)
|
||||
cx, _, _ := pGetSystemMetrics.Call(SM_CXSMICON)
|
||||
cy, _, _ := pGetSystemMetrics.Call(SM_CYSMICON)
|
||||
hMemBmp, _, err := pCreateCompatibleBitmap.Call(hDC, cx, cy)
|
||||
if hMemBmp == 0 {
|
||||
return 0, err
|
||||
}
|
||||
hMemBmp, err := create32BitHBitmap(hMemDC, int32(cx), int32(cy))
|
||||
hOriginalBmp, _, _ := pSelectObject.Call(hMemDC, hMemBmp)
|
||||
defer pSelectObject.Call(hMemDC, hOriginalBmp)
|
||||
res, _, err := pDrawIconEx.Call(hMemDC, 0, 0, uintptr(hIcon), cx, cy, 0, uintptr(0), DI_NORMAL)
|
||||
@@ -762,49 +860,94 @@ func (t *winTray) iconToBitmap(hIcon windows.Handle) (windows.Handle, error) {
|
||||
return windows.Handle(hMemBmp), nil
|
||||
}
|
||||
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-createdibsection
|
||||
func create32BitHBitmap(hDC uintptr, cx, cy int32) (uintptr, error) {
|
||||
const BI_RGB uint32 = 0
|
||||
const DIB_RGB_COLORS = 0
|
||||
bmi := bitmapInfo{
|
||||
BmiHeader: bitmapInfoHeader{
|
||||
BiPlanes: 1,
|
||||
BiCompression: BI_RGB,
|
||||
BiWidth: cx,
|
||||
BiHeight: cy,
|
||||
BiBitCount: 32,
|
||||
},
|
||||
}
|
||||
bmi.BmiHeader.BiSize = uint32(unsafe.Sizeof(bmi.BmiHeader))
|
||||
var bits uintptr
|
||||
hBitmap, _, err := pCreateDIBSection.Call(
|
||||
hDC,
|
||||
uintptr(unsafe.Pointer(&bmi)),
|
||||
DIB_RGB_COLORS,
|
||||
uintptr(unsafe.Pointer(&bits)),
|
||||
uintptr(0),
|
||||
0,
|
||||
)
|
||||
if hBitmap == 0 {
|
||||
return 0, err
|
||||
}
|
||||
return hBitmap, nil
|
||||
}
|
||||
|
||||
func registerSystray() {
|
||||
if err := wt.initInstance(); err != nil {
|
||||
log.Printf("Unable to init instance: %v", err)
|
||||
log.Printf("systray error: unable to init instance: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := wt.createMenu(); err != nil {
|
||||
log.Printf("Unable to create menu: %v", err)
|
||||
log.Printf("systray error: unable to create menu: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
wt.initialized.Store(true)
|
||||
systrayReady()
|
||||
}
|
||||
|
||||
func nativeLoop() {
|
||||
// Main message pump.
|
||||
m := &struct {
|
||||
WindowHandle windows.Handle
|
||||
Message uint32
|
||||
Wparam uintptr
|
||||
Lparam uintptr
|
||||
Time uint32
|
||||
Pt point
|
||||
}{}
|
||||
for {
|
||||
ret, _, err := pGetMessage.Call(uintptr(unsafe.Pointer(m)), 0, 0, 0)
|
||||
var m = &struct {
|
||||
WindowHandle windows.Handle
|
||||
Message uint32
|
||||
Wparam uintptr
|
||||
Lparam uintptr
|
||||
Time uint32
|
||||
Pt point
|
||||
}{}
|
||||
|
||||
// If the function retrieves a message other than WM_QUIT, the return value is nonzero.
|
||||
// If the function retrieves the WM_QUIT message, the return value is zero.
|
||||
// If there is an error, the return value is -1
|
||||
// https://msdn.microsoft.com/en-us/library/windows/desktop/ms644936(v=vs.85).aspx
|
||||
switch int32(ret) {
|
||||
case -1:
|
||||
log.Printf("Error at message loop: %v", err)
|
||||
return
|
||||
case 0:
|
||||
return
|
||||
default:
|
||||
pTranslateMessage.Call(uintptr(unsafe.Pointer(m)))
|
||||
pDispatchMessage.Call(uintptr(unsafe.Pointer(m)))
|
||||
}
|
||||
func nativeLoop() {
|
||||
for doNativeTick() {
|
||||
}
|
||||
}
|
||||
|
||||
func nativeEnd() {
|
||||
}
|
||||
|
||||
func nativeStart() {
|
||||
go func() {
|
||||
for doNativeTick() {
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func doNativeTick() bool {
|
||||
ret, _, err := pGetMessage.Call(uintptr(unsafe.Pointer(m)), 0, 0, 0)
|
||||
|
||||
// If the function retrieves a message other than WM_QUIT, the return value is nonzero.
|
||||
// If the function retrieves the WM_QUIT message, the return value is zero.
|
||||
// If there is an error, the return value is -1
|
||||
// https://msdn.microsoft.com/en-us/library/windows/desktop/ms644936(v=vs.85).aspx
|
||||
switch int32(ret) {
|
||||
case -1:
|
||||
log.Printf("systray error: message loop failure: %s\n", err)
|
||||
return false
|
||||
case 0:
|
||||
return false
|
||||
default:
|
||||
pTranslateMessage.Call(uintptr(unsafe.Pointer(m)))
|
||||
pDispatchMessage.Call(uintptr(unsafe.Pointer(m)))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func quit() {
|
||||
const WM_CLOSE = 0x0010
|
||||
|
||||
@@ -814,6 +957,16 @@ func quit() {
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
wt.muNID.Lock()
|
||||
if wt.nid != nil {
|
||||
wt.nid.delete()
|
||||
}
|
||||
wt.muNID.Unlock()
|
||||
runSystrayExit()
|
||||
}
|
||||
|
||||
func setInternalLoop(bool) {
|
||||
}
|
||||
|
||||
func iconBytesToFilePath(iconBytes []byte) (string, error) {
|
||||
@@ -835,15 +988,25 @@ func iconBytesToFilePath(iconBytes []byte) (string, error) {
|
||||
func SetIcon(iconBytes []byte) {
|
||||
iconFilePath, err := iconBytesToFilePath(iconBytes)
|
||||
if err != nil {
|
||||
log.Printf("Unable to write icon data to temp file: %v", err)
|
||||
log.Printf("systray error: unable to write icon data to temp file: %s\n", err)
|
||||
return
|
||||
}
|
||||
if err := wt.setIcon(iconFilePath); err != nil {
|
||||
log.Printf("Unable to set icon: %v", err)
|
||||
log.Printf("systray error: unable to set icon: %s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// SetIconFromFilePath sets the systray icon from a file path.
|
||||
// iconFilePath should be the path to a .ico for windows and .ico/.jpg/.png for other platforms.
|
||||
func SetIconFromFilePath(iconFilePath string) error {
|
||||
err := wt.setIcon(iconFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set icon: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTemplateIcon sets the systray icon as a template icon (on macOS), falling back
|
||||
// to a regular icon on other platforms.
|
||||
// templateIconBytes and iconBytes should be the content of .ico for windows and
|
||||
@@ -869,20 +1032,28 @@ func (item *MenuItem) parentId() uint32 {
|
||||
func (item *MenuItem) SetIcon(iconBytes []byte) {
|
||||
iconFilePath, err := iconBytesToFilePath(iconBytes)
|
||||
if err != nil {
|
||||
log.Printf("Unable to write icon data to temp file: %v", err)
|
||||
log.Printf("systray error: unable to write icon data to temp file: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = item.SetIconFromFilePath(iconFilePath)
|
||||
if err != nil {
|
||||
log.Printf("systray error: %s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// SetIconFromFilePath sets the icon of a menu item from a file path.
|
||||
// iconFilePath should be the path to a .ico for windows and .ico/.jpg/.png for other platforms.
|
||||
func (item *MenuItem) SetIconFromFilePath(iconFilePath string) error {
|
||||
h, err := wt.loadIconFrom(iconFilePath)
|
||||
if err != nil {
|
||||
log.Printf("Unable to load icon from temp file: %v", err)
|
||||
return
|
||||
return fmt.Errorf("unable to load icon from file: %s", err)
|
||||
}
|
||||
|
||||
h, err = wt.iconToBitmap(h)
|
||||
h, err = iconToBitmap(h)
|
||||
if err != nil {
|
||||
log.Printf("Unable to convert icon to bitmap: %v", err)
|
||||
return
|
||||
return fmt.Errorf("unable to convert icon to bitmap: %s", err)
|
||||
}
|
||||
wt.muMenuItemIcons.Lock()
|
||||
wt.menuItemIcons[uint32(item.id)] = h
|
||||
@@ -890,16 +1061,16 @@ func (item *MenuItem) SetIcon(iconBytes []byte) {
|
||||
|
||||
err = wt.addOrUpdateMenuItem(uint32(item.id), item.parentId(), item.title, item.disabled, item.checked)
|
||||
if err != nil {
|
||||
log.Printf("Unable to addOrUpdateMenuItem: %v", err)
|
||||
return
|
||||
return fmt.Errorf("unable to addOrUpdateMenuItem: %s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTooltip sets the systray tooltip to display on mouse hover of the tray icon,
|
||||
// only available on Mac and Windows.
|
||||
func SetTooltip(tooltip string) {
|
||||
if err := wt.setTooltip(tooltip); err != nil {
|
||||
log.Printf("Unable to set tooltip: %v", err)
|
||||
log.Printf("systray error: unable to set tooltip: %s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -907,7 +1078,7 @@ func SetTooltip(tooltip string) {
|
||||
func addOrUpdateMenuItem(item *MenuItem) {
|
||||
err := wt.addOrUpdateMenuItem(uint32(item.id), item.parentId(), item.title, item.disabled, item.checked)
|
||||
if err != nil {
|
||||
log.Printf("Unable to addOrUpdateMenuItem: %v", err)
|
||||
log.Printf("systray error: unable to addOrUpdateMenuItem: %s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -920,10 +1091,10 @@ func (item *MenuItem) SetTemplateIcon(templateIconBytes []byte, regularIconBytes
|
||||
item.SetIcon(regularIconBytes)
|
||||
}
|
||||
|
||||
func addSeparator(id uint32) {
|
||||
err := wt.addSeparatorMenuItem(id, 0)
|
||||
func addSeparator(id uint32, parent uint32) {
|
||||
err := wt.addSeparatorMenuItem(id, parent)
|
||||
if err != nil {
|
||||
log.Printf("Unable to addSeparator: %v", err)
|
||||
log.Printf("systray error: unable to addSeparator: %s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -931,7 +1102,15 @@ func addSeparator(id uint32) {
|
||||
func hideMenuItem(item *MenuItem) {
|
||||
err := wt.hideMenuItem(uint32(item.id), item.parentId())
|
||||
if err != nil {
|
||||
log.Printf("Unable to hideMenuItem: %v", err)
|
||||
log.Printf("systray error: unable to hideMenuItem: %s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func removeMenuItem(item *MenuItem) {
|
||||
err := wt.removeMenuItem(uint32(item.id), item.parentId())
|
||||
if err != nil {
|
||||
log.Printf("systray error: unable to removeMenuItem: %s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -939,3 +1118,30 @@ func hideMenuItem(item *MenuItem) {
|
||||
func showMenuItem(item *MenuItem) {
|
||||
addOrUpdateMenuItem(item)
|
||||
}
|
||||
|
||||
func resetMenu() {
|
||||
_, _, _ = pDestroyMenu.Call(uintptr(wt.menus[0]))
|
||||
wt.visibleItems = make(map[uint32][]uint32)
|
||||
wt.menus = make(map[uint32]windows.Handle)
|
||||
wt.menuOf = make(map[uint32]windows.Handle)
|
||||
wt.menuItemIcons = make(map[uint32]windows.Handle)
|
||||
wt.createMenu()
|
||||
}
|
||||
|
||||
func systrayLeftClick() {
|
||||
if fn := tappedLeft; fn != nil {
|
||||
fn()
|
||||
return
|
||||
}
|
||||
|
||||
wt.showMenu()
|
||||
}
|
||||
|
||||
func systrayRightClick() {
|
||||
if fn := tappedRight; fn != nil {
|
||||
fn()
|
||||
return
|
||||
}
|
||||
|
||||
wt.showMenu()
|
||||
}
|
||||
50
vendor/github.com/godbus/dbus/v5/CONTRIBUTING.md
generated
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# How to Contribute
|
||||
|
||||
## Getting Started
|
||||
|
||||
- Fork the repository on GitHub
|
||||
- Read the [README](README.markdown) for build and test instructions
|
||||
- Play with the project, submit bugs, submit patches!
|
||||
|
||||
## Contribution Flow
|
||||
|
||||
This is a rough outline of what a contributor's workflow looks like:
|
||||
|
||||
- Create a topic branch from where you want to base your work (usually master).
|
||||
- Make commits of logical units.
|
||||
- Make sure your commit messages are in the proper format (see below).
|
||||
- Push your changes to a topic branch in your fork of the repository.
|
||||
- Make sure the tests pass, and add any new tests as appropriate.
|
||||
- Submit a pull request to the original repository.
|
||||
|
||||
Thanks for your contributions!
|
||||
|
||||
### Format of the Commit Message
|
||||
|
||||
We follow a rough convention for commit messages that is designed to answer two
|
||||
questions: what changed and why. The subject line should feature the what and
|
||||
the body of the commit should describe the why.
|
||||
|
||||
```
|
||||
scripts: add the test-cluster command
|
||||
|
||||
this uses tmux to setup a test cluster that you can easily kill and
|
||||
start for debugging.
|
||||
|
||||
Fixes #38
|
||||
```
|
||||
|
||||
The format can be described more formally as follows:
|
||||
|
||||
```
|
||||
<subsystem>: <what changed>
|
||||
<BLANK LINE>
|
||||
<why this change was made>
|
||||
<BLANK LINE>
|
||||
<footer>
|
||||
```
|
||||
|
||||
The first line is the subject and should be no longer than 70 characters, the
|
||||
second line is always blank, and other lines should be wrapped at 80 characters.
|
||||
This allows the message to be easier to read on GitHub as well as in various
|
||||
git tools.
|
||||
25
vendor/github.com/godbus/dbus/v5/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
Copyright (c) 2013, Georg Reinke (<guelfey at gmail dot com>), Google
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
|
||||
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
3
vendor/github.com/godbus/dbus/v5/MAINTAINERS
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
Brandon Philips <brandon@ifup.org> (@philips)
|
||||
Brian Waldon <brian@waldon.cc> (@bcwaldon)
|
||||
John Southworth <jsouthwo@brocade.com> (@jsouthworth)
|
||||
46
vendor/github.com/godbus/dbus/v5/README.md
generated
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||

|
||||
|
||||
dbus
|
||||
----
|
||||
|
||||
dbus is a simple library that implements native Go client bindings for the
|
||||
D-Bus message bus system.
|
||||
|
||||
### Features
|
||||
|
||||
* Complete native implementation of the D-Bus message protocol
|
||||
* Go-like API (channels for signals / asynchronous method calls, Goroutine-safe connections)
|
||||
* Subpackages that help with the introspection / property interfaces
|
||||
|
||||
### Installation
|
||||
|
||||
This packages requires Go 1.12 or later. It can be installed by running the command below:
|
||||
|
||||
```
|
||||
go get github.com/godbus/dbus/v5
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
The complete package documentation and some simple examples are available at
|
||||
[godoc.org](http://godoc.org/github.com/godbus/dbus). Also, the
|
||||
[_examples](https://github.com/godbus/dbus/tree/master/_examples) directory
|
||||
gives a short overview over the basic usage.
|
||||
|
||||
#### Projects using godbus
|
||||
- [fyne](https://github.com/fyne-io/fyne) a cross platform GUI in Go inspired by Material Design.
|
||||
- [fynedesk](https://github.com/fyne-io/fynedesk) a full desktop environment for Linux/Unix using Fyne.
|
||||
- [go-bluetooth](https://github.com/muka/go-bluetooth) provides a bluetooth client over bluez dbus API.
|
||||
- [iwd](https://github.com/shibumi/iwd) go bindings for the internet wireless daemon "iwd".
|
||||
- [notify](https://github.com/esiqveland/notify) provides desktop notifications over dbus into a library.
|
||||
- [playerbm](https://github.com/altdesktop/playerbm) a bookmark utility for media players.
|
||||
|
||||
Please note that the API is considered unstable for now and may change without
|
||||
further notice.
|
||||
|
||||
### License
|
||||
|
||||
go.dbus is available under the Simplified BSD License; see LICENSE for the full
|
||||
text.
|
||||
|
||||
Nearly all of the credit for this library goes to github.com/guelfey/go.dbus.
|
||||
257
vendor/github.com/godbus/dbus/v5/auth.go
generated
vendored
Normal file
@@ -0,0 +1,257 @@
|
||||
package dbus
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// AuthStatus represents the Status of an authentication mechanism.
|
||||
type AuthStatus byte
|
||||
|
||||
const (
|
||||
// AuthOk signals that authentication is finished; the next command
|
||||
// from the server should be an OK.
|
||||
AuthOk AuthStatus = iota
|
||||
|
||||
// AuthContinue signals that additional data is needed; the next command
|
||||
// from the server should be a DATA.
|
||||
AuthContinue
|
||||
|
||||
// AuthError signals an error; the server sent invalid data or some
|
||||
// other unexpected thing happened and the current authentication
|
||||
// process should be aborted.
|
||||
AuthError
|
||||
)
|
||||
|
||||
type authState byte
|
||||
|
||||
const (
|
||||
waitingForData authState = iota
|
||||
waitingForOk
|
||||
waitingForReject
|
||||
)
|
||||
|
||||
// Auth defines the behaviour of an authentication mechanism.
|
||||
type Auth interface {
|
||||
// Return the name of the mechanism, the argument to the first AUTH command
|
||||
// and the next status.
|
||||
FirstData() (name, resp []byte, status AuthStatus)
|
||||
|
||||
// Process the given DATA command, and return the argument to the DATA
|
||||
// command and the next status. If len(resp) == 0, no DATA command is sent.
|
||||
HandleData(data []byte) (resp []byte, status AuthStatus)
|
||||
}
|
||||
|
||||
// Auth authenticates the connection, trying the given list of authentication
|
||||
// mechanisms (in that order). If nil is passed, the EXTERNAL and
|
||||
// DBUS_COOKIE_SHA1 mechanisms are tried for the current user. For private
|
||||
// connections, this method must be called before sending any messages to the
|
||||
// bus. Auth must not be called on shared connections.
|
||||
func (conn *Conn) Auth(methods []Auth) error {
|
||||
if methods == nil {
|
||||
uid := strconv.Itoa(os.Geteuid())
|
||||
methods = []Auth{AuthExternal(uid), AuthCookieSha1(uid, getHomeDir())}
|
||||
}
|
||||
in := bufio.NewReader(conn.transport)
|
||||
err := conn.transport.SendNullByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = authWriteLine(conn.transport, []byte("AUTH"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s, err := authReadLine(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(s) < 2 || !bytes.Equal(s[0], []byte("REJECTED")) {
|
||||
return errors.New("dbus: authentication protocol error")
|
||||
}
|
||||
s = s[1:]
|
||||
for _, v := range s {
|
||||
for _, m := range methods {
|
||||
if name, _, status := m.FirstData(); bytes.Equal(v, name) {
|
||||
var ok bool
|
||||
err = authWriteLine(conn.transport, []byte("AUTH"), v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch status {
|
||||
case AuthOk:
|
||||
err, ok = conn.tryAuth(m, waitingForOk, in)
|
||||
case AuthContinue:
|
||||
err, ok = conn.tryAuth(m, waitingForData, in)
|
||||
default:
|
||||
panic("dbus: invalid authentication status")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
if conn.transport.SupportsUnixFDs() {
|
||||
err = authWriteLine(conn, []byte("NEGOTIATE_UNIX_FD"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
line, err := authReadLine(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case bytes.Equal(line[0], []byte("AGREE_UNIX_FD")):
|
||||
conn.EnableUnixFDs()
|
||||
conn.unixFD = true
|
||||
case bytes.Equal(line[0], []byte("ERROR")):
|
||||
default:
|
||||
return errors.New("dbus: authentication protocol error")
|
||||
}
|
||||
}
|
||||
err = authWriteLine(conn.transport, []byte("BEGIN"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go conn.inWorker()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.New("dbus: authentication failed")
|
||||
}
|
||||
|
||||
// tryAuth tries to authenticate with m as the mechanism, using state as the
|
||||
// initial authState and in for reading input. It returns (nil, true) on
|
||||
// success, (nil, false) on a REJECTED and (someErr, false) if some other
|
||||
// error occurred.
|
||||
func (conn *Conn) tryAuth(m Auth, state authState, in *bufio.Reader) (error, bool) {
|
||||
for {
|
||||
s, err := authReadLine(in)
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
switch {
|
||||
case state == waitingForData && string(s[0]) == "DATA":
|
||||
if len(s) != 2 {
|
||||
err = authWriteLine(conn.transport, []byte("ERROR"))
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
continue
|
||||
}
|
||||
data, status := m.HandleData(s[1])
|
||||
switch status {
|
||||
case AuthOk, AuthContinue:
|
||||
if len(data) != 0 {
|
||||
err = authWriteLine(conn.transport, []byte("DATA"), data)
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
}
|
||||
if status == AuthOk {
|
||||
state = waitingForOk
|
||||
}
|
||||
case AuthError:
|
||||
err = authWriteLine(conn.transport, []byte("ERROR"))
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
}
|
||||
case state == waitingForData && string(s[0]) == "REJECTED":
|
||||
return nil, false
|
||||
case state == waitingForData && string(s[0]) == "ERROR":
|
||||
err = authWriteLine(conn.transport, []byte("CANCEL"))
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
state = waitingForReject
|
||||
case state == waitingForData && string(s[0]) == "OK":
|
||||
if len(s) != 2 {
|
||||
err = authWriteLine(conn.transport, []byte("CANCEL"))
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
state = waitingForReject
|
||||
} else {
|
||||
conn.uuid = string(s[1])
|
||||
return nil, true
|
||||
}
|
||||
case state == waitingForData:
|
||||
err = authWriteLine(conn.transport, []byte("ERROR"))
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
case state == waitingForOk && string(s[0]) == "OK":
|
||||
if len(s) != 2 {
|
||||
err = authWriteLine(conn.transport, []byte("CANCEL"))
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
state = waitingForReject
|
||||
} else {
|
||||
conn.uuid = string(s[1])
|
||||
return nil, true
|
||||
}
|
||||
case state == waitingForOk && string(s[0]) == "DATA":
|
||||
err = authWriteLine(conn.transport, []byte("DATA"))
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
case state == waitingForOk && string(s[0]) == "REJECTED":
|
||||
return nil, false
|
||||
case state == waitingForOk && string(s[0]) == "ERROR":
|
||||
err = authWriteLine(conn.transport, []byte("CANCEL"))
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
state = waitingForReject
|
||||
case state == waitingForOk:
|
||||
err = authWriteLine(conn.transport, []byte("ERROR"))
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
case state == waitingForReject && string(s[0]) == "REJECTED":
|
||||
return nil, false
|
||||
case state == waitingForReject:
|
||||
return errors.New("dbus: authentication protocol error"), false
|
||||
default:
|
||||
panic("dbus: invalid auth state")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// authReadLine reads a line and separates it into its fields.
|
||||
func authReadLine(in *bufio.Reader) ([][]byte, error) {
|
||||
data, err := in.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data = bytes.TrimSuffix(data, []byte("\r\n"))
|
||||
return bytes.Split(data, []byte{' '}), nil
|
||||
}
|
||||
|
||||
// authWriteLine writes the given line in the authentication protocol format
|
||||
// (elements of data separated by a " " and terminated by "\r\n").
|
||||
func authWriteLine(out io.Writer, data ...[]byte) error {
|
||||
buf := make([]byte, 0)
|
||||
for i, v := range data {
|
||||
buf = append(buf, v...)
|
||||
if i != len(data)-1 {
|
||||
buf = append(buf, ' ')
|
||||
}
|
||||
}
|
||||
buf = append(buf, '\r')
|
||||
buf = append(buf, '\n')
|
||||
n, err := out.Write(buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != len(buf) {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
16
vendor/github.com/godbus/dbus/v5/auth_anonymous.go
generated
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
package dbus
|
||||
|
||||
// AuthAnonymous returns an Auth that uses the ANONYMOUS mechanism.
|
||||
func AuthAnonymous() Auth {
|
||||
return &authAnonymous{}
|
||||
}
|
||||
|
||||
type authAnonymous struct{}
|
||||
|
||||
func (a *authAnonymous) FirstData() (name, resp []byte, status AuthStatus) {
|
||||
return []byte("ANONYMOUS"), nil, AuthOk
|
||||
}
|
||||
|
||||
func (a *authAnonymous) HandleData(data []byte) (resp []byte, status AuthStatus) {
|
||||
return nil, AuthError
|
||||
}
|
||||
26
vendor/github.com/godbus/dbus/v5/auth_external.go
generated
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
package dbus
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// AuthExternal returns an Auth that authenticates as the given user with the
|
||||
// EXTERNAL mechanism.
|
||||
func AuthExternal(user string) Auth {
|
||||
return authExternal{user}
|
||||
}
|
||||
|
||||
// AuthExternal implements the EXTERNAL authentication mechanism.
|
||||
type authExternal struct {
|
||||
user string
|
||||
}
|
||||
|
||||
func (a authExternal) FirstData() ([]byte, []byte, AuthStatus) {
|
||||
b := make([]byte, 2*len(a.user))
|
||||
hex.Encode(b, []byte(a.user))
|
||||
return []byte("EXTERNAL"), b, AuthOk
|
||||
}
|
||||
|
||||
func (a authExternal) HandleData(b []byte) ([]byte, AuthStatus) {
|
||||
return nil, AuthError
|
||||
}
|
||||
102
vendor/github.com/godbus/dbus/v5/auth_sha1.go
generated
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
package dbus
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
)
|
||||
|
||||
// AuthCookieSha1 returns an Auth that authenticates as the given user with the
|
||||
// DBUS_COOKIE_SHA1 mechanism. The home parameter should specify the home
|
||||
// directory of the user.
|
||||
func AuthCookieSha1(user, home string) Auth {
|
||||
return authCookieSha1{user, home}
|
||||
}
|
||||
|
||||
type authCookieSha1 struct {
|
||||
user, home string
|
||||
}
|
||||
|
||||
func (a authCookieSha1) FirstData() ([]byte, []byte, AuthStatus) {
|
||||
b := make([]byte, 2*len(a.user))
|
||||
hex.Encode(b, []byte(a.user))
|
||||
return []byte("DBUS_COOKIE_SHA1"), b, AuthContinue
|
||||
}
|
||||
|
||||
func (a authCookieSha1) HandleData(data []byte) ([]byte, AuthStatus) {
|
||||
challenge := make([]byte, len(data)/2)
|
||||
_, err := hex.Decode(challenge, data)
|
||||
if err != nil {
|
||||
return nil, AuthError
|
||||
}
|
||||
b := bytes.Split(challenge, []byte{' '})
|
||||
if len(b) != 3 {
|
||||
return nil, AuthError
|
||||
}
|
||||
context := b[0]
|
||||
id := b[1]
|
||||
svchallenge := b[2]
|
||||
cookie := a.getCookie(context, id)
|
||||
if cookie == nil {
|
||||
return nil, AuthError
|
||||
}
|
||||
clchallenge := a.generateChallenge()
|
||||
if clchallenge == nil {
|
||||
return nil, AuthError
|
||||
}
|
||||
hash := sha1.New()
|
||||
hash.Write(bytes.Join([][]byte{svchallenge, clchallenge, cookie}, []byte{':'}))
|
||||
hexhash := make([]byte, 2*hash.Size())
|
||||
hex.Encode(hexhash, hash.Sum(nil))
|
||||
data = append(clchallenge, ' ')
|
||||
data = append(data, hexhash...)
|
||||
resp := make([]byte, 2*len(data))
|
||||
hex.Encode(resp, data)
|
||||
return resp, AuthOk
|
||||
}
|
||||
|
||||
// getCookie searches for the cookie identified by id in context and returns
|
||||
// the cookie content or nil. (Since HandleData can't return a specific error,
|
||||
// but only whether an error occurred, this function also doesn't bother to
|
||||
// return an error.)
|
||||
func (a authCookieSha1) getCookie(context, id []byte) []byte {
|
||||
file, err := os.Open(a.home + "/.dbus-keyrings/" + string(context))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer file.Close()
|
||||
rd := bufio.NewReader(file)
|
||||
for {
|
||||
line, err := rd.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
line = line[:len(line)-1]
|
||||
b := bytes.Split(line, []byte{' '})
|
||||
if len(b) != 3 {
|
||||
return nil
|
||||
}
|
||||
if bytes.Equal(b[0], id) {
|
||||
return b[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generateChallenge returns a random, hex-encoded challenge, or nil on error
|
||||
// (see above).
|
||||
func (a authCookieSha1) generateChallenge() []byte {
|
||||
b := make([]byte, 16)
|
||||
n, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if n != 16 {
|
||||
return nil
|
||||
}
|
||||
enc := make([]byte, 32)
|
||||
hex.Encode(enc, b)
|
||||
return enc
|
||||
}
|
||||
69
vendor/github.com/godbus/dbus/v5/call.go
generated
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
package dbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var errSignature = errors.New("dbus: mismatched signature")
|
||||
|
||||
// Call represents a pending or completed method call.
|
||||
type Call struct {
|
||||
Destination string
|
||||
Path ObjectPath
|
||||
Method string
|
||||
Args []interface{}
|
||||
|
||||
// Strobes when the call is complete.
|
||||
Done chan *Call
|
||||
|
||||
// After completion, the error status. If this is non-nil, it may be an
|
||||
// error message from the peer (with Error as its type) or some other error.
|
||||
Err error
|
||||
|
||||
// Holds the response once the call is done.
|
||||
Body []interface{}
|
||||
|
||||
// ResponseSequence stores the sequence number of the DBus message containing
|
||||
// the call response (or error). This can be compared to the sequence number
|
||||
// of other call responses and signals on this connection to determine their
|
||||
// relative ordering on the underlying DBus connection.
|
||||
// For errors, ResponseSequence is populated only if the error came from a
|
||||
// DBusMessage that was received or if there was an error receiving. In case of
|
||||
// failure to make the call, ResponseSequence will be NoSequence.
|
||||
ResponseSequence Sequence
|
||||
|
||||
// tracks context and canceler
|
||||
ctx context.Context
|
||||
ctxCanceler context.CancelFunc
|
||||
}
|
||||
|
||||
func (c *Call) Context() context.Context {
|
||||
if c.ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
return c.ctx
|
||||
}
|
||||
|
||||
func (c *Call) ContextCancel() {
|
||||
if c.ctxCanceler != nil {
|
||||
c.ctxCanceler()
|
||||
}
|
||||
}
|
||||
|
||||
// Store stores the body of the reply into the provided pointers. It returns
|
||||
// an error if the signatures of the body and retvalues don't match, or if
|
||||
// the error status is not nil.
|
||||
func (c *Call) Store(retvalues ...interface{}) error {
|
||||
if c.Err != nil {
|
||||
return c.Err
|
||||
}
|
||||
|
||||
return Store(c.Body, retvalues...)
|
||||
}
|
||||
|
||||
func (c *Call) done() {
|
||||
c.Done <- c
|
||||
c.ContextCancel()
|
||||
}
|
||||
996
vendor/github.com/godbus/dbus/v5/conn.go
generated
vendored
Normal file
@@ -0,0 +1,996 @@
|
||||
package dbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
systemBus *Conn
|
||||
systemBusLck sync.Mutex
|
||||
sessionBus *Conn
|
||||
sessionBusLck sync.Mutex
|
||||
)
|
||||
|
||||
// ErrClosed is the error returned by calls on a closed connection.
|
||||
var ErrClosed = errors.New("dbus: connection closed by user")
|
||||
|
||||
// Conn represents a connection to a message bus (usually, the system or
|
||||
// session bus).
|
||||
//
|
||||
// Connections are either shared or private. Shared connections
|
||||
// are shared between calls to the functions that return them. As a result,
|
||||
// the methods Close, Auth and Hello must not be called on them.
|
||||
//
|
||||
// Multiple goroutines may invoke methods on a connection simultaneously.
|
||||
type Conn struct {
|
||||
transport
|
||||
|
||||
ctx context.Context
|
||||
cancelCtx context.CancelFunc
|
||||
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
|
||||
busObj BusObject
|
||||
unixFD bool
|
||||
uuid string
|
||||
|
||||
handler Handler
|
||||
signalHandler SignalHandler
|
||||
serialGen SerialGenerator
|
||||
inInt Interceptor
|
||||
outInt Interceptor
|
||||
auth []Auth
|
||||
|
||||
names *nameTracker
|
||||
calls *callTracker
|
||||
outHandler *outputHandler
|
||||
|
||||
eavesdropped chan<- *Message
|
||||
eavesdroppedLck sync.Mutex
|
||||
}
|
||||
|
||||
// SessionBus returns a shared connection to the session bus, connecting to it
|
||||
// if not already done.
|
||||
func SessionBus() (conn *Conn, err error) {
|
||||
sessionBusLck.Lock()
|
||||
defer sessionBusLck.Unlock()
|
||||
if sessionBus != nil &&
|
||||
sessionBus.Connected() {
|
||||
return sessionBus, nil
|
||||
}
|
||||
defer func() {
|
||||
if conn != nil {
|
||||
sessionBus = conn
|
||||
}
|
||||
}()
|
||||
conn, err = ConnectSessionBus()
|
||||
return
|
||||
}
|
||||
|
||||
func getSessionBusAddress(autolaunch bool) (string, error) {
|
||||
if address := os.Getenv("DBUS_SESSION_BUS_ADDRESS"); address != "" && address != "autolaunch:" {
|
||||
return address, nil
|
||||
|
||||
} else if address := tryDiscoverDbusSessionBusAddress(); address != "" {
|
||||
os.Setenv("DBUS_SESSION_BUS_ADDRESS", address)
|
||||
return address, nil
|
||||
}
|
||||
if !autolaunch {
|
||||
return "", errors.New("dbus: couldn't determine address of session bus")
|
||||
}
|
||||
return getSessionBusPlatformAddress()
|
||||
}
|
||||
|
||||
// SessionBusPrivate returns a new private connection to the session bus.
|
||||
func SessionBusPrivate(opts ...ConnOption) (*Conn, error) {
|
||||
address, err := getSessionBusAddress(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return Dial(address, opts...)
|
||||
}
|
||||
|
||||
// SessionBusPrivate returns a new private connection to the session bus. If
|
||||
// the session bus is not already open, do not attempt to launch it.
|
||||
func SessionBusPrivateNoAutoStartup(opts ...ConnOption) (*Conn, error) {
|
||||
address, err := getSessionBusAddress(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return Dial(address, opts...)
|
||||
}
|
||||
|
||||
// SessionBusPrivate returns a new private connection to the session bus.
|
||||
//
|
||||
// Deprecated: use SessionBusPrivate with options instead.
|
||||
func SessionBusPrivateHandler(handler Handler, signalHandler SignalHandler) (*Conn, error) {
|
||||
return SessionBusPrivate(WithHandler(handler), WithSignalHandler(signalHandler))
|
||||
}
|
||||
|
||||
// SystemBus returns a shared connection to the system bus, connecting to it if
|
||||
// not already done.
|
||||
func SystemBus() (conn *Conn, err error) {
|
||||
systemBusLck.Lock()
|
||||
defer systemBusLck.Unlock()
|
||||
if systemBus != nil &&
|
||||
systemBus.Connected() {
|
||||
return systemBus, nil
|
||||
}
|
||||
defer func() {
|
||||
if conn != nil {
|
||||
systemBus = conn
|
||||
}
|
||||
}()
|
||||
conn, err = ConnectSystemBus()
|
||||
return
|
||||
}
|
||||
|
||||
// ConnectSessionBus connects to the session bus.
|
||||
func ConnectSessionBus(opts ...ConnOption) (*Conn, error) {
|
||||
address, err := getSessionBusAddress(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Connect(address, opts...)
|
||||
}
|
||||
|
||||
// ConnectSystemBus connects to the system bus.
|
||||
func ConnectSystemBus(opts ...ConnOption) (*Conn, error) {
|
||||
return Connect(getSystemBusPlatformAddress(), opts...)
|
||||
}
|
||||
|
||||
// Connect connects to the given address.
|
||||
//
|
||||
// Returned connection is ready to use and doesn't require calling
|
||||
// Auth and Hello methods to make it usable.
|
||||
func Connect(address string, opts ...ConnOption) (*Conn, error) {
|
||||
conn, err := Dial(address, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = conn.Auth(conn.auth); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err = conn.Hello(); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// SystemBusPrivate returns a new private connection to the system bus.
|
||||
// Note: this connection is not ready to use. One must perform Auth and Hello
|
||||
// on the connection before it is usable.
|
||||
func SystemBusPrivate(opts ...ConnOption) (*Conn, error) {
|
||||
return Dial(getSystemBusPlatformAddress(), opts...)
|
||||
}
|
||||
|
||||
// SystemBusPrivateHandler returns a new private connection to the system bus, using the provided handlers.
|
||||
//
|
||||
// Deprecated: use SystemBusPrivate with options instead.
|
||||
func SystemBusPrivateHandler(handler Handler, signalHandler SignalHandler) (*Conn, error) {
|
||||
return SystemBusPrivate(WithHandler(handler), WithSignalHandler(signalHandler))
|
||||
}
|
||||
|
||||
// Dial establishes a new private connection to the message bus specified by address.
|
||||
func Dial(address string, opts ...ConnOption) (*Conn, error) {
|
||||
tr, err := getTransport(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newConn(tr, opts...)
|
||||
}
|
||||
|
||||
// DialHandler establishes a new private connection to the message bus specified by address, using the supplied handlers.
|
||||
//
|
||||
// Deprecated: use Dial with options instead.
|
||||
func DialHandler(address string, handler Handler, signalHandler SignalHandler) (*Conn, error) {
|
||||
return Dial(address, WithHandler(handler), WithSignalHandler(signalHandler))
|
||||
}
|
||||
|
||||
// ConnOption is a connection option.
|
||||
type ConnOption func(conn *Conn) error
|
||||
|
||||
// WithHandler overrides the default handler.
|
||||
func WithHandler(handler Handler) ConnOption {
|
||||
return func(conn *Conn) error {
|
||||
conn.handler = handler
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithSignalHandler overrides the default signal handler.
|
||||
func WithSignalHandler(handler SignalHandler) ConnOption {
|
||||
return func(conn *Conn) error {
|
||||
conn.signalHandler = handler
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithSerialGenerator overrides the default signals generator.
|
||||
func WithSerialGenerator(gen SerialGenerator) ConnOption {
|
||||
return func(conn *Conn) error {
|
||||
conn.serialGen = gen
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuth sets authentication methods for the auth conversation.
|
||||
func WithAuth(methods ...Auth) ConnOption {
|
||||
return func(conn *Conn) error {
|
||||
conn.auth = methods
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Interceptor intercepts incoming and outgoing messages.
|
||||
type Interceptor func(msg *Message)
|
||||
|
||||
// WithIncomingInterceptor sets the given interceptor for incoming messages.
|
||||
func WithIncomingInterceptor(interceptor Interceptor) ConnOption {
|
||||
return func(conn *Conn) error {
|
||||
conn.inInt = interceptor
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithOutgoingInterceptor sets the given interceptor for outgoing messages.
|
||||
func WithOutgoingInterceptor(interceptor Interceptor) ConnOption {
|
||||
return func(conn *Conn) error {
|
||||
conn.outInt = interceptor
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithContext overrides the default context for the connection.
|
||||
func WithContext(ctx context.Context) ConnOption {
|
||||
return func(conn *Conn) error {
|
||||
conn.ctx = ctx
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewConn creates a new private *Conn from an already established connection.
|
||||
func NewConn(conn io.ReadWriteCloser, opts ...ConnOption) (*Conn, error) {
|
||||
return newConn(genericTransport{conn}, opts...)
|
||||
}
|
||||
|
||||
// NewConnHandler creates a new private *Conn from an already established connection, using the supplied handlers.
|
||||
//
|
||||
// Deprecated: use NewConn with options instead.
|
||||
func NewConnHandler(conn io.ReadWriteCloser, handler Handler, signalHandler SignalHandler) (*Conn, error) {
|
||||
return NewConn(genericTransport{conn}, WithHandler(handler), WithSignalHandler(signalHandler))
|
||||
}
|
||||
|
||||
// newConn creates a new *Conn from a transport.
|
||||
func newConn(tr transport, opts ...ConnOption) (*Conn, error) {
|
||||
conn := new(Conn)
|
||||
conn.transport = tr
|
||||
for _, opt := range opts {
|
||||
if err := opt(conn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if conn.ctx == nil {
|
||||
conn.ctx = context.Background()
|
||||
}
|
||||
conn.ctx, conn.cancelCtx = context.WithCancel(conn.ctx)
|
||||
|
||||
conn.calls = newCallTracker()
|
||||
if conn.handler == nil {
|
||||
conn.handler = NewDefaultHandler()
|
||||
}
|
||||
if conn.signalHandler == nil {
|
||||
conn.signalHandler = NewDefaultSignalHandler()
|
||||
}
|
||||
if conn.serialGen == nil {
|
||||
conn.serialGen = newSerialGenerator()
|
||||
}
|
||||
conn.outHandler = &outputHandler{conn: conn}
|
||||
conn.names = newNameTracker()
|
||||
conn.busObj = conn.Object("org.freedesktop.DBus", "/org/freedesktop/DBus")
|
||||
|
||||
go func() {
|
||||
<-conn.ctx.Done()
|
||||
conn.Close()
|
||||
}()
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// BusObject returns the object owned by the bus daemon which handles
|
||||
// administrative requests.
|
||||
func (conn *Conn) BusObject() BusObject {
|
||||
return conn.busObj
|
||||
}
|
||||
|
||||
// Close closes the connection. Any blocked operations will return with errors
|
||||
// and the channels passed to Eavesdrop and Signal are closed. This method must
|
||||
// not be called on shared connections.
|
||||
func (conn *Conn) Close() error {
|
||||
conn.closeOnce.Do(func() {
|
||||
conn.outHandler.close()
|
||||
if term, ok := conn.signalHandler.(Terminator); ok {
|
||||
term.Terminate()
|
||||
}
|
||||
|
||||
if term, ok := conn.handler.(Terminator); ok {
|
||||
term.Terminate()
|
||||
}
|
||||
|
||||
conn.eavesdroppedLck.Lock()
|
||||
if conn.eavesdropped != nil {
|
||||
close(conn.eavesdropped)
|
||||
}
|
||||
conn.eavesdroppedLck.Unlock()
|
||||
|
||||
conn.cancelCtx()
|
||||
|
||||
conn.closeErr = conn.transport.Close()
|
||||
})
|
||||
return conn.closeErr
|
||||
}
|
||||
|
||||
// Context returns the context associated with the connection. The
|
||||
// context will be cancelled when the connection is closed.
|
||||
func (conn *Conn) Context() context.Context {
|
||||
return conn.ctx
|
||||
}
|
||||
|
||||
// Connected returns whether conn is connected
|
||||
func (conn *Conn) Connected() bool {
|
||||
return conn.ctx.Err() == nil
|
||||
}
|
||||
|
||||
// Eavesdrop causes conn to send all incoming messages to the given channel
|
||||
// without further processing. Method replies, errors and signals will not be
|
||||
// sent to the appropriate channels and method calls will not be handled. If nil
|
||||
// is passed, the normal behaviour is restored.
|
||||
//
|
||||
// The caller has to make sure that ch is sufficiently buffered;
|
||||
// if a message arrives when a write to ch is not possible, the message is
|
||||
// discarded.
|
||||
func (conn *Conn) Eavesdrop(ch chan<- *Message) {
|
||||
conn.eavesdroppedLck.Lock()
|
||||
conn.eavesdropped = ch
|
||||
conn.eavesdroppedLck.Unlock()
|
||||
}
|
||||
|
||||
// getSerial returns an unused serial.
|
||||
func (conn *Conn) getSerial() uint32 {
|
||||
return conn.serialGen.GetSerial()
|
||||
}
|
||||
|
||||
// Hello sends the initial org.freedesktop.DBus.Hello call. This method must be
|
||||
// called after authentication, but before sending any other messages to the
|
||||
// bus. Hello must not be called for shared connections.
|
||||
func (conn *Conn) Hello() error {
|
||||
var s string
|
||||
err := conn.busObj.Call("org.freedesktop.DBus.Hello", 0).Store(&s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn.names.acquireUniqueConnectionName(s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// inWorker runs in an own goroutine, reading incoming messages from the
|
||||
// transport and dispatching them appropriately.
|
||||
func (conn *Conn) inWorker() {
|
||||
sequenceGen := newSequenceGenerator()
|
||||
for {
|
||||
msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if _, ok := err.(InvalidMessageError); !ok {
|
||||
// Some read error occurred (usually EOF); we can't really do
|
||||
// anything but to shut down all stuff and returns errors to all
|
||||
// pending replies.
|
||||
conn.Close()
|
||||
conn.calls.finalizeAllWithError(sequenceGen, err)
|
||||
return
|
||||
}
|
||||
// invalid messages are ignored
|
||||
continue
|
||||
}
|
||||
conn.eavesdroppedLck.Lock()
|
||||
if conn.eavesdropped != nil {
|
||||
select {
|
||||
case conn.eavesdropped <- msg:
|
||||
default:
|
||||
}
|
||||
conn.eavesdroppedLck.Unlock()
|
||||
continue
|
||||
}
|
||||
conn.eavesdroppedLck.Unlock()
|
||||
dest, _ := msg.Headers[FieldDestination].value.(string)
|
||||
found := dest == "" ||
|
||||
!conn.names.uniqueNameIsKnown() ||
|
||||
conn.names.isKnownName(dest)
|
||||
if !found {
|
||||
// Eavesdropped a message, but no channel for it is registered.
|
||||
// Ignore it.
|
||||
continue
|
||||
}
|
||||
|
||||
if conn.inInt != nil {
|
||||
conn.inInt(msg)
|
||||
}
|
||||
sequence := sequenceGen.next()
|
||||
switch msg.Type {
|
||||
case TypeError:
|
||||
conn.serialGen.RetireSerial(conn.calls.handleDBusError(sequence, msg))
|
||||
case TypeMethodReply:
|
||||
conn.serialGen.RetireSerial(conn.calls.handleReply(sequence, msg))
|
||||
case TypeSignal:
|
||||
conn.handleSignal(sequence, msg)
|
||||
case TypeMethodCall:
|
||||
go conn.handleCall(msg)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (conn *Conn) handleSignal(sequence Sequence, msg *Message) {
|
||||
iface := msg.Headers[FieldInterface].value.(string)
|
||||
member := msg.Headers[FieldMember].value.(string)
|
||||
// as per http://dbus.freedesktop.org/doc/dbus-specification.html ,
|
||||
// sender is optional for signals.
|
||||
sender, _ := msg.Headers[FieldSender].value.(string)
|
||||
if iface == "org.freedesktop.DBus" && sender == "org.freedesktop.DBus" {
|
||||
if member == "NameLost" {
|
||||
// If we lost the name on the bus, remove it from our
|
||||
// tracking list.
|
||||
name, ok := msg.Body[0].(string)
|
||||
if !ok {
|
||||
panic("Unable to read the lost name")
|
||||
}
|
||||
conn.names.loseName(name)
|
||||
} else if member == "NameAcquired" {
|
||||
// If we acquired the name on the bus, add it to our
|
||||
// tracking list.
|
||||
name, ok := msg.Body[0].(string)
|
||||
if !ok {
|
||||
panic("Unable to read the acquired name")
|
||||
}
|
||||
conn.names.acquireName(name)
|
||||
}
|
||||
}
|
||||
signal := &Signal{
|
||||
Sender: sender,
|
||||
Path: msg.Headers[FieldPath].value.(ObjectPath),
|
||||
Name: iface + "." + member,
|
||||
Body: msg.Body,
|
||||
Sequence: sequence,
|
||||
}
|
||||
conn.signalHandler.DeliverSignal(iface, member, signal)
|
||||
}
|
||||
|
||||
// Names returns the list of all names that are currently owned by this
|
||||
// connection. The slice is always at least one element long, the first element
|
||||
// being the unique name of the connection.
|
||||
func (conn *Conn) Names() []string {
|
||||
return conn.names.listKnownNames()
|
||||
}
|
||||
|
||||
// Object returns the object identified by the given destination name and path.
|
||||
func (conn *Conn) Object(dest string, path ObjectPath) BusObject {
|
||||
return &Object{conn, dest, path}
|
||||
}
|
||||
|
||||
func (conn *Conn) sendMessageAndIfClosed(msg *Message, ifClosed func()) {
|
||||
if msg.serial == 0 {
|
||||
msg.serial = conn.getSerial()
|
||||
}
|
||||
if conn.outInt != nil {
|
||||
conn.outInt(msg)
|
||||
}
|
||||
err := conn.outHandler.sendAndIfClosed(msg, ifClosed)
|
||||
if err != nil {
|
||||
conn.handleSendError(msg, err)
|
||||
} else if msg.Type != TypeMethodCall {
|
||||
conn.serialGen.RetireSerial(msg.serial)
|
||||
}
|
||||
}
|
||||
|
||||
func (conn *Conn) handleSendError(msg *Message, err error) {
|
||||
if msg.Type == TypeMethodCall {
|
||||
conn.calls.handleSendError(msg, err)
|
||||
} else if msg.Type == TypeMethodReply {
|
||||
if _, ok := err.(FormatError); ok {
|
||||
conn.sendError(err, msg.Headers[FieldDestination].value.(string), msg.Headers[FieldReplySerial].value.(uint32))
|
||||
}
|
||||
}
|
||||
conn.serialGen.RetireSerial(msg.serial)
|
||||
}
|
||||
|
||||
// Send sends the given message to the message bus. You usually don't need to
|
||||
// use this; use the higher-level equivalents (Call / Go, Emit and Export)
|
||||
// instead. If msg is a method call and NoReplyExpected is not set, a non-nil
|
||||
// call is returned and the same value is sent to ch (which must be buffered)
|
||||
// once the call is complete. Otherwise, ch is ignored and a Call structure is
|
||||
// returned of which only the Err member is valid.
|
||||
func (conn *Conn) Send(msg *Message, ch chan *Call) *Call {
|
||||
return conn.send(context.Background(), msg, ch)
|
||||
}
|
||||
|
||||
// SendWithContext acts like Send but takes a context
|
||||
func (conn *Conn) SendWithContext(ctx context.Context, msg *Message, ch chan *Call) *Call {
|
||||
return conn.send(ctx, msg, ch)
|
||||
}
|
||||
|
||||
func (conn *Conn) send(ctx context.Context, msg *Message, ch chan *Call) *Call {
|
||||
if ctx == nil {
|
||||
panic("nil context")
|
||||
}
|
||||
if ch == nil {
|
||||
ch = make(chan *Call, 1)
|
||||
} else if cap(ch) == 0 {
|
||||
panic("dbus: unbuffered channel passed to (*Conn).Send")
|
||||
}
|
||||
|
||||
var call *Call
|
||||
ctx, canceler := context.WithCancel(ctx)
|
||||
msg.serial = conn.getSerial()
|
||||
if msg.Type == TypeMethodCall && msg.Flags&FlagNoReplyExpected == 0 {
|
||||
call = new(Call)
|
||||
call.Destination, _ = msg.Headers[FieldDestination].value.(string)
|
||||
call.Path, _ = msg.Headers[FieldPath].value.(ObjectPath)
|
||||
iface, _ := msg.Headers[FieldInterface].value.(string)
|
||||
member, _ := msg.Headers[FieldMember].value.(string)
|
||||
call.Method = iface + "." + member
|
||||
call.Args = msg.Body
|
||||
call.Done = ch
|
||||
call.ctx = ctx
|
||||
call.ctxCanceler = canceler
|
||||
conn.calls.track(msg.serial, call)
|
||||
if ctx.Err() != nil {
|
||||
// short path: don't even send the message if context already cancelled
|
||||
conn.calls.handleSendError(msg, ctx.Err())
|
||||
return call
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
conn.calls.handleSendError(msg, ctx.Err())
|
||||
}()
|
||||
conn.sendMessageAndIfClosed(msg, func() {
|
||||
conn.calls.handleSendError(msg, ErrClosed)
|
||||
canceler()
|
||||
})
|
||||
} else {
|
||||
canceler()
|
||||
call = &Call{Err: nil, Done: ch}
|
||||
ch <- call
|
||||
conn.sendMessageAndIfClosed(msg, func() {
|
||||
call = &Call{Err: ErrClosed}
|
||||
})
|
||||
}
|
||||
return call
|
||||
}
|
||||
|
||||
// sendError creates an error message corresponding to the parameters and sends
|
||||
// it to conn.out.
|
||||
func (conn *Conn) sendError(err error, dest string, serial uint32) {
|
||||
var e *Error
|
||||
switch em := err.(type) {
|
||||
case Error:
|
||||
e = &em
|
||||
case *Error:
|
||||
e = em
|
||||
case DBusError:
|
||||
name, body := em.DBusError()
|
||||
e = NewError(name, body)
|
||||
default:
|
||||
e = MakeFailedError(err)
|
||||
}
|
||||
msg := new(Message)
|
||||
msg.Type = TypeError
|
||||
msg.Headers = make(map[HeaderField]Variant)
|
||||
if dest != "" {
|
||||
msg.Headers[FieldDestination] = MakeVariant(dest)
|
||||
}
|
||||
msg.Headers[FieldErrorName] = MakeVariant(e.Name)
|
||||
msg.Headers[FieldReplySerial] = MakeVariant(serial)
|
||||
msg.Body = e.Body
|
||||
if len(e.Body) > 0 {
|
||||
msg.Headers[FieldSignature] = MakeVariant(SignatureOf(e.Body...))
|
||||
}
|
||||
conn.sendMessageAndIfClosed(msg, nil)
|
||||
}
|
||||
|
||||
// sendReply creates a method reply message corresponding to the parameters and
|
||||
// sends it to conn.out.
|
||||
func (conn *Conn) sendReply(dest string, serial uint32, values ...interface{}) {
|
||||
msg := new(Message)
|
||||
msg.Type = TypeMethodReply
|
||||
msg.Headers = make(map[HeaderField]Variant)
|
||||
if dest != "" {
|
||||
msg.Headers[FieldDestination] = MakeVariant(dest)
|
||||
}
|
||||
msg.Headers[FieldReplySerial] = MakeVariant(serial)
|
||||
msg.Body = values
|
||||
if len(values) > 0 {
|
||||
msg.Headers[FieldSignature] = MakeVariant(SignatureOf(values...))
|
||||
}
|
||||
conn.sendMessageAndIfClosed(msg, nil)
|
||||
}
|
||||
|
||||
// AddMatchSignal registers the given match rule to receive broadcast
|
||||
// signals based on their contents.
|
||||
func (conn *Conn) AddMatchSignal(options ...MatchOption) error {
|
||||
return conn.AddMatchSignalContext(context.Background(), options...)
|
||||
}
|
||||
|
||||
// AddMatchSignalContext acts like AddMatchSignal but takes a context.
|
||||
func (conn *Conn) AddMatchSignalContext(ctx context.Context, options ...MatchOption) error {
|
||||
options = append([]MatchOption{withMatchType("signal")}, options...)
|
||||
return conn.busObj.CallWithContext(
|
||||
ctx,
|
||||
"org.freedesktop.DBus.AddMatch", 0,
|
||||
formatMatchOptions(options),
|
||||
).Store()
|
||||
}
|
||||
|
||||
// RemoveMatchSignal removes the first rule that matches previously registered with AddMatchSignal.
|
||||
func (conn *Conn) RemoveMatchSignal(options ...MatchOption) error {
|
||||
return conn.RemoveMatchSignalContext(context.Background(), options...)
|
||||
}
|
||||
|
||||
// RemoveMatchSignalContext acts like RemoveMatchSignal but takes a context.
|
||||
func (conn *Conn) RemoveMatchSignalContext(ctx context.Context, options ...MatchOption) error {
|
||||
options = append([]MatchOption{withMatchType("signal")}, options...)
|
||||
return conn.busObj.CallWithContext(
|
||||
ctx,
|
||||
"org.freedesktop.DBus.RemoveMatch", 0,
|
||||
formatMatchOptions(options),
|
||||
).Store()
|
||||
}
|
||||
|
||||
// Signal registers the given channel to be passed all received signal messages.
|
||||
//
|
||||
// Multiple of these channels can be registered at the same time. The channel is
|
||||
// closed if the Conn is closed; it should not be closed by the caller before
|
||||
// RemoveSignal was called on it.
|
||||
//
|
||||
// These channels are "overwritten" by Eavesdrop; i.e., if there currently is a
|
||||
// channel for eavesdropped messages, this channel receives all signals, and
|
||||
// none of the channels passed to Signal will receive any signals.
|
||||
//
|
||||
// Panics if the signal handler is not a `SignalRegistrar`.
|
||||
func (conn *Conn) Signal(ch chan<- *Signal) {
|
||||
handler, ok := conn.signalHandler.(SignalRegistrar)
|
||||
if !ok {
|
||||
panic("cannot use this method with a non SignalRegistrar handler")
|
||||
}
|
||||
handler.AddSignal(ch)
|
||||
}
|
||||
|
||||
// RemoveSignal removes the given channel from the list of the registered channels.
|
||||
//
|
||||
// Panics if the signal handler is not a `SignalRegistrar`.
|
||||
func (conn *Conn) RemoveSignal(ch chan<- *Signal) {
|
||||
handler, ok := conn.signalHandler.(SignalRegistrar)
|
||||
if !ok {
|
||||
panic("cannot use this method with a non SignalRegistrar handler")
|
||||
}
|
||||
handler.RemoveSignal(ch)
|
||||
}
|
||||
|
||||
// SupportsUnixFDs returns whether the underlying transport supports passing of
|
||||
// unix file descriptors. If this is false, method calls containing unix file
|
||||
// descriptors will return an error and emitted signals containing them will
|
||||
// not be sent.
|
||||
func (conn *Conn) SupportsUnixFDs() bool {
|
||||
return conn.unixFD
|
||||
}
|
||||
|
||||
// Error represents a D-Bus message of type Error.
|
||||
type Error struct {
|
||||
Name string
|
||||
Body []interface{}
|
||||
}
|
||||
|
||||
func NewError(name string, body []interface{}) *Error {
|
||||
return &Error{name, body}
|
||||
}
|
||||
|
||||
func (e Error) Error() string {
|
||||
if len(e.Body) >= 1 {
|
||||
s, ok := e.Body[0].(string)
|
||||
if ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return e.Name
|
||||
}
|
||||
|
||||
// Signal represents a D-Bus message of type Signal. The name member is given in
|
||||
// "interface.member" notation, e.g. org.freedesktop.D-Bus.NameLost.
|
||||
type Signal struct {
|
||||
Sender string
|
||||
Path ObjectPath
|
||||
Name string
|
||||
Body []interface{}
|
||||
Sequence Sequence
|
||||
}
|
||||
|
||||
// transport is a D-Bus transport.
|
||||
type transport interface {
|
||||
// Read and Write raw data (for example, for the authentication protocol).
|
||||
io.ReadWriteCloser
|
||||
|
||||
// Send the initial null byte used for the EXTERNAL mechanism.
|
||||
SendNullByte() error
|
||||
|
||||
// Returns whether this transport supports passing Unix FDs.
|
||||
SupportsUnixFDs() bool
|
||||
|
||||
// Signal the transport that Unix FD passing is enabled for this connection.
|
||||
EnableUnixFDs()
|
||||
|
||||
// Read / send a message, handling things like Unix FDs.
|
||||
ReadMessage() (*Message, error)
|
||||
SendMessage(*Message) error
|
||||
}
|
||||
|
||||
var (
|
||||
transports = make(map[string]func(string) (transport, error))
|
||||
)
|
||||
|
||||
func getTransport(address string) (transport, error) {
|
||||
var err error
|
||||
var t transport
|
||||
|
||||
addresses := strings.Split(address, ";")
|
||||
for _, v := range addresses {
|
||||
i := strings.IndexRune(v, ':')
|
||||
if i == -1 {
|
||||
err = errors.New("dbus: invalid bus address (no transport)")
|
||||
continue
|
||||
}
|
||||
f := transports[v[:i]]
|
||||
if f == nil {
|
||||
err = errors.New("dbus: invalid bus address (invalid or unsupported transport)")
|
||||
continue
|
||||
}
|
||||
t, err = f(v[i+1:])
|
||||
if err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// getKey gets a key from a the list of keys. Returns "" on error / not found...
|
||||
func getKey(s, key string) string {
|
||||
for _, keyEqualsValue := range strings.Split(s, ",") {
|
||||
keyValue := strings.SplitN(keyEqualsValue, "=", 2)
|
||||
if len(keyValue) == 2 && keyValue[0] == key {
|
||||
val, err := UnescapeBusAddressValue(keyValue[1])
|
||||
if err != nil {
|
||||
// No way to return an error.
|
||||
return ""
|
||||
}
|
||||
return val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type outputHandler struct {
|
||||
conn *Conn
|
||||
sendLck sync.Mutex
|
||||
closed struct {
|
||||
isClosed bool
|
||||
lck sync.RWMutex
|
||||
}
|
||||
}
|
||||
|
||||
func (h *outputHandler) sendAndIfClosed(msg *Message, ifClosed func()) error {
|
||||
h.closed.lck.RLock()
|
||||
defer h.closed.lck.RUnlock()
|
||||
if h.closed.isClosed {
|
||||
if ifClosed != nil {
|
||||
ifClosed()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
h.sendLck.Lock()
|
||||
defer h.sendLck.Unlock()
|
||||
return h.conn.SendMessage(msg)
|
||||
}
|
||||
|
||||
func (h *outputHandler) close() {
|
||||
h.closed.lck.Lock()
|
||||
defer h.closed.lck.Unlock()
|
||||
h.closed.isClosed = true
|
||||
}
|
||||
|
||||
type serialGenerator struct {
|
||||
lck sync.Mutex
|
||||
nextSerial uint32
|
||||
serialUsed map[uint32]bool
|
||||
}
|
||||
|
||||
func newSerialGenerator() *serialGenerator {
|
||||
return &serialGenerator{
|
||||
serialUsed: map[uint32]bool{0: true},
|
||||
nextSerial: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (gen *serialGenerator) GetSerial() uint32 {
|
||||
gen.lck.Lock()
|
||||
defer gen.lck.Unlock()
|
||||
n := gen.nextSerial
|
||||
for gen.serialUsed[n] {
|
||||
n++
|
||||
}
|
||||
gen.serialUsed[n] = true
|
||||
gen.nextSerial = n + 1
|
||||
return n
|
||||
}
|
||||
|
||||
func (gen *serialGenerator) RetireSerial(serial uint32) {
|
||||
gen.lck.Lock()
|
||||
defer gen.lck.Unlock()
|
||||
delete(gen.serialUsed, serial)
|
||||
}
|
||||
|
||||
type nameTracker struct {
|
||||
lck sync.RWMutex
|
||||
unique string
|
||||
names map[string]struct{}
|
||||
}
|
||||
|
||||
func newNameTracker() *nameTracker {
|
||||
return &nameTracker{names: map[string]struct{}{}}
|
||||
}
|
||||
func (tracker *nameTracker) acquireUniqueConnectionName(name string) {
|
||||
tracker.lck.Lock()
|
||||
defer tracker.lck.Unlock()
|
||||
tracker.unique = name
|
||||
}
|
||||
func (tracker *nameTracker) acquireName(name string) {
|
||||
tracker.lck.Lock()
|
||||
defer tracker.lck.Unlock()
|
||||
tracker.names[name] = struct{}{}
|
||||
}
|
||||
func (tracker *nameTracker) loseName(name string) {
|
||||
tracker.lck.Lock()
|
||||
defer tracker.lck.Unlock()
|
||||
delete(tracker.names, name)
|
||||
}
|
||||
|
||||
func (tracker *nameTracker) uniqueNameIsKnown() bool {
|
||||
tracker.lck.RLock()
|
||||
defer tracker.lck.RUnlock()
|
||||
return tracker.unique != ""
|
||||
}
|
||||
func (tracker *nameTracker) isKnownName(name string) bool {
|
||||
tracker.lck.RLock()
|
||||
defer tracker.lck.RUnlock()
|
||||
_, ok := tracker.names[name]
|
||||
return ok || name == tracker.unique
|
||||
}
|
||||
func (tracker *nameTracker) listKnownNames() []string {
|
||||
tracker.lck.RLock()
|
||||
defer tracker.lck.RUnlock()
|
||||
out := make([]string, 0, len(tracker.names)+1)
|
||||
out = append(out, tracker.unique)
|
||||
for k := range tracker.names {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type callTracker struct {
|
||||
calls map[uint32]*Call
|
||||
lck sync.RWMutex
|
||||
}
|
||||
|
||||
func newCallTracker() *callTracker {
|
||||
return &callTracker{calls: map[uint32]*Call{}}
|
||||
}
|
||||
|
||||
func (tracker *callTracker) track(sn uint32, call *Call) {
|
||||
tracker.lck.Lock()
|
||||
tracker.calls[sn] = call
|
||||
tracker.lck.Unlock()
|
||||
}
|
||||
|
||||
func (tracker *callTracker) handleReply(sequence Sequence, msg *Message) uint32 {
|
||||
serial := msg.Headers[FieldReplySerial].value.(uint32)
|
||||
tracker.lck.RLock()
|
||||
_, ok := tracker.calls[serial]
|
||||
tracker.lck.RUnlock()
|
||||
if ok {
|
||||
tracker.finalizeWithBody(serial, sequence, msg.Body)
|
||||
}
|
||||
return serial
|
||||
}
|
||||
|
||||
func (tracker *callTracker) handleDBusError(sequence Sequence, msg *Message) uint32 {
|
||||
serial := msg.Headers[FieldReplySerial].value.(uint32)
|
||||
tracker.lck.RLock()
|
||||
_, ok := tracker.calls[serial]
|
||||
tracker.lck.RUnlock()
|
||||
if ok {
|
||||
name, _ := msg.Headers[FieldErrorName].value.(string)
|
||||
tracker.finalizeWithError(serial, sequence, Error{name, msg.Body})
|
||||
}
|
||||
return serial
|
||||
}
|
||||
|
||||
func (tracker *callTracker) handleSendError(msg *Message, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
tracker.lck.RLock()
|
||||
_, ok := tracker.calls[msg.serial]
|
||||
tracker.lck.RUnlock()
|
||||
if ok {
|
||||
tracker.finalizeWithError(msg.serial, NoSequence, err)
|
||||
}
|
||||
}
|
||||
|
||||
// finalize was the only func that did not strobe Done
|
||||
func (tracker *callTracker) finalize(sn uint32) {
|
||||
tracker.lck.Lock()
|
||||
defer tracker.lck.Unlock()
|
||||
c, ok := tracker.calls[sn]
|
||||
if ok {
|
||||
delete(tracker.calls, sn)
|
||||
c.ContextCancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (tracker *callTracker) finalizeWithBody(sn uint32, sequence Sequence, body []interface{}) {
|
||||
tracker.lck.Lock()
|
||||
c, ok := tracker.calls[sn]
|
||||
if ok {
|
||||
delete(tracker.calls, sn)
|
||||
}
|
||||
tracker.lck.Unlock()
|
||||
if ok {
|
||||
c.Body = body
|
||||
c.ResponseSequence = sequence
|
||||
c.done()
|
||||
}
|
||||
}
|
||||
|
||||
func (tracker *callTracker) finalizeWithError(sn uint32, sequence Sequence, err error) {
|
||||
tracker.lck.Lock()
|
||||
c, ok := tracker.calls[sn]
|
||||
if ok {
|
||||
delete(tracker.calls, sn)
|
||||
}
|
||||
tracker.lck.Unlock()
|
||||
if ok {
|
||||
c.Err = err
|
||||
c.ResponseSequence = sequence
|
||||
c.done()
|
||||
}
|
||||
}
|
||||
|
||||
func (tracker *callTracker) finalizeAllWithError(sequenceGen *sequenceGenerator, err error) {
|
||||
tracker.lck.Lock()
|
||||
closedCalls := make([]*Call, 0, len(tracker.calls))
|
||||
for sn := range tracker.calls {
|
||||
closedCalls = append(closedCalls, tracker.calls[sn])
|
||||
}
|
||||
tracker.calls = map[uint32]*Call{}
|
||||
tracker.lck.Unlock()
|
||||
for _, call := range closedCalls {
|
||||
call.Err = err
|
||||
call.ResponseSequence = sequenceGen.next()
|
||||
call.done()
|
||||
}
|
||||
}
|
||||
37
vendor/github.com/godbus/dbus/v5/conn_darwin.go
generated
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
package dbus
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
const defaultSystemBusAddress = "unix:path=/opt/local/var/run/dbus/system_bus_socket"
|
||||
|
||||
func getSessionBusPlatformAddress() (string, error) {
|
||||
cmd := exec.Command("launchctl", "getenv", "DBUS_LAUNCHD_SESSION_BUS_SOCKET")
|
||||
b, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(b) == 0 {
|
||||
return "", errors.New("dbus: couldn't determine address of session bus")
|
||||
}
|
||||
|
||||
return "unix:path=" + string(b[:len(b)-1]), nil
|
||||
}
|
||||
|
||||
func getSystemBusPlatformAddress() string {
|
||||
address := os.Getenv("DBUS_LAUNCHD_SESSION_BUS_SOCKET")
|
||||
if address != "" {
|
||||
return fmt.Sprintf("unix:path=%s", address)
|
||||
}
|
||||
return defaultSystemBusAddress
|
||||
}
|
||||
|
||||
func tryDiscoverDbusSessionBusAddress() string {
|
||||
return ""
|
||||
}
|
||||
90
vendor/github.com/godbus/dbus/v5/conn_other.go
generated
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
// +build !darwin
|
||||
|
||||
package dbus
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var execCommand = exec.Command
|
||||
|
||||
func getSessionBusPlatformAddress() (string, error) {
|
||||
cmd := execCommand("dbus-launch")
|
||||
b, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
i := bytes.IndexByte(b, '=')
|
||||
j := bytes.IndexByte(b, '\n')
|
||||
|
||||
if i == -1 || j == -1 || i > j {
|
||||
return "", errors.New("dbus: couldn't determine address of session bus")
|
||||
}
|
||||
|
||||
env, addr := string(b[0:i]), string(b[i+1:j])
|
||||
os.Setenv(env, addr)
|
||||
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
// tryDiscoverDbusSessionBusAddress tries to discover an existing dbus session
|
||||
// and return the value of its DBUS_SESSION_BUS_ADDRESS.
|
||||
// It tries different techniques employed by different operating systems,
|
||||
// returning the first valid address it finds, or an empty string.
|
||||
//
|
||||
// * /run/user/<uid>/bus if this exists, it *is* the bus socket. present on
|
||||
// Ubuntu 18.04
|
||||
// * /run/user/<uid>/dbus-session: if this exists, it can be parsed for the bus
|
||||
// address. present on Ubuntu 16.04
|
||||
//
|
||||
// See https://dbus.freedesktop.org/doc/dbus-launch.1.html
|
||||
func tryDiscoverDbusSessionBusAddress() string {
|
||||
if runtimeDirectory, err := getRuntimeDirectory(); err == nil {
|
||||
|
||||
if runUserBusFile := path.Join(runtimeDirectory, "bus"); fileExists(runUserBusFile) {
|
||||
// if /run/user/<uid>/bus exists, that file itself
|
||||
// *is* the unix socket, so return its path
|
||||
return fmt.Sprintf("unix:path=%s", EscapeBusAddressValue(runUserBusFile))
|
||||
}
|
||||
if runUserSessionDbusFile := path.Join(runtimeDirectory, "dbus-session"); fileExists(runUserSessionDbusFile) {
|
||||
// if /run/user/<uid>/dbus-session exists, it's a
|
||||
// text file // containing the address of the socket, e.g.:
|
||||
// DBUS_SESSION_BUS_ADDRESS=unix:abstract=/tmp/dbus-E1c73yNqrG
|
||||
|
||||
if f, err := ioutil.ReadFile(runUserSessionDbusFile); err == nil {
|
||||
fileContent := string(f)
|
||||
|
||||
prefix := "DBUS_SESSION_BUS_ADDRESS="
|
||||
|
||||
if strings.HasPrefix(fileContent, prefix) {
|
||||
address := strings.TrimRight(strings.TrimPrefix(fileContent, prefix), "\n\r")
|
||||
return address
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getRuntimeDirectory() (string, error) {
|
||||
if currentUser, err := user.Current(); err != nil {
|
||||
return "", err
|
||||
} else {
|
||||
return fmt.Sprintf("/run/user/%s", currentUser.Uid), nil
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(filename string) bool {
|
||||
_, err := os.Stat(filename)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
17
vendor/github.com/godbus/dbus/v5/conn_unix.go
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
//+build !windows,!solaris,!darwin
|
||||
|
||||
package dbus
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
const defaultSystemBusAddress = "unix:path=/var/run/dbus/system_bus_socket"
|
||||
|
||||
func getSystemBusPlatformAddress() string {
|
||||
address := os.Getenv("DBUS_SYSTEM_BUS_ADDRESS")
|
||||
if address != "" {
|
||||
return address
|
||||
}
|
||||
return defaultSystemBusAddress
|
||||
}
|
||||
15
vendor/github.com/godbus/dbus/v5/conn_windows.go
generated
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
//+build windows
|
||||
|
||||
package dbus
|
||||
|
||||
import "os"
|
||||
|
||||
const defaultSystemBusAddress = "tcp:host=127.0.0.1,port=12434"
|
||||
|
||||
func getSystemBusPlatformAddress() string {
|
||||
address := os.Getenv("DBUS_SYSTEM_BUS_ADDRESS")
|
||||
if address != "" {
|
||||
return address
|
||||
}
|
||||
return defaultSystemBusAddress
|
||||
}
|
||||
430
vendor/github.com/godbus/dbus/v5/dbus.go
generated
vendored
Normal file
@@ -0,0 +1,430 @@
|
||||
package dbus
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
byteType = reflect.TypeOf(byte(0))
|
||||
boolType = reflect.TypeOf(false)
|
||||
uint8Type = reflect.TypeOf(uint8(0))
|
||||
int16Type = reflect.TypeOf(int16(0))
|
||||
uint16Type = reflect.TypeOf(uint16(0))
|
||||
intType = reflect.TypeOf(int(0))
|
||||
uintType = reflect.TypeOf(uint(0))
|
||||
int32Type = reflect.TypeOf(int32(0))
|
||||
uint32Type = reflect.TypeOf(uint32(0))
|
||||
int64Type = reflect.TypeOf(int64(0))
|
||||
uint64Type = reflect.TypeOf(uint64(0))
|
||||
float64Type = reflect.TypeOf(float64(0))
|
||||
stringType = reflect.TypeOf("")
|
||||
signatureType = reflect.TypeOf(Signature{""})
|
||||
objectPathType = reflect.TypeOf(ObjectPath(""))
|
||||
variantType = reflect.TypeOf(Variant{Signature{""}, nil})
|
||||
interfacesType = reflect.TypeOf([]interface{}{})
|
||||
interfaceType = reflect.TypeOf((*interface{})(nil)).Elem()
|
||||
unixFDType = reflect.TypeOf(UnixFD(0))
|
||||
unixFDIndexType = reflect.TypeOf(UnixFDIndex(0))
|
||||
errType = reflect.TypeOf((*error)(nil)).Elem()
|
||||
)
|
||||
|
||||
// An InvalidTypeError signals that a value which cannot be represented in the
|
||||
// D-Bus wire format was passed to a function.
|
||||
type InvalidTypeError struct {
|
||||
Type reflect.Type
|
||||
}
|
||||
|
||||
func (e InvalidTypeError) Error() string {
|
||||
return "dbus: invalid type " + e.Type.String()
|
||||
}
|
||||
|
||||
// Store copies the values contained in src to dest, which must be a slice of
|
||||
// pointers. It converts slices of interfaces from src to corresponding structs
|
||||
// in dest. An error is returned if the lengths of src and dest or the types of
|
||||
// their elements don't match.
|
||||
func Store(src []interface{}, dest ...interface{}) error {
|
||||
if len(src) != len(dest) {
|
||||
return errors.New("dbus.Store: length mismatch")
|
||||
}
|
||||
|
||||
for i := range src {
|
||||
if err := storeInterfaces(src[i], dest[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func storeInterfaces(src, dest interface{}) error {
|
||||
return store(reflect.ValueOf(dest), reflect.ValueOf(src))
|
||||
}
|
||||
|
||||
func store(dest, src reflect.Value) error {
|
||||
if dest.Kind() == reflect.Ptr {
|
||||
if dest.IsNil() {
|
||||
dest.Set(reflect.New(dest.Type().Elem()))
|
||||
}
|
||||
return store(dest.Elem(), src)
|
||||
}
|
||||
switch src.Kind() {
|
||||
case reflect.Slice:
|
||||
return storeSlice(dest, src)
|
||||
case reflect.Map:
|
||||
return storeMap(dest, src)
|
||||
default:
|
||||
return storeBase(dest, src)
|
||||
}
|
||||
}
|
||||
|
||||
func storeBase(dest, src reflect.Value) error {
|
||||
return setDest(dest, src)
|
||||
}
|
||||
|
||||
func setDest(dest, src reflect.Value) error {
|
||||
if !isVariant(src.Type()) && isVariant(dest.Type()) {
|
||||
//special conversion for dbus.Variant
|
||||
dest.Set(reflect.ValueOf(MakeVariant(src.Interface())))
|
||||
return nil
|
||||
}
|
||||
if isVariant(src.Type()) && !isVariant(dest.Type()) {
|
||||
src = getVariantValue(src)
|
||||
return store(dest, src)
|
||||
}
|
||||
if !src.Type().ConvertibleTo(dest.Type()) {
|
||||
return fmt.Errorf(
|
||||
"dbus.Store: type mismatch: cannot convert %s to %s",
|
||||
src.Type(), dest.Type())
|
||||
}
|
||||
dest.Set(src.Convert(dest.Type()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func kindsAreCompatible(dest, src reflect.Type) bool {
|
||||
switch {
|
||||
case isVariant(dest):
|
||||
return true
|
||||
case dest.Kind() == reflect.Interface:
|
||||
return true
|
||||
default:
|
||||
return dest.Kind() == src.Kind()
|
||||
}
|
||||
}
|
||||
|
||||
func isConvertibleTo(dest, src reflect.Type) bool {
|
||||
switch {
|
||||
case isVariant(dest):
|
||||
return true
|
||||
case dest.Kind() == reflect.Interface:
|
||||
return true
|
||||
case dest.Kind() == reflect.Slice:
|
||||
return src.Kind() == reflect.Slice &&
|
||||
isConvertibleTo(dest.Elem(), src.Elem())
|
||||
case dest.Kind() == reflect.Ptr:
|
||||
dest = dest.Elem()
|
||||
return isConvertibleTo(dest, src)
|
||||
case dest.Kind() == reflect.Struct:
|
||||
return src == interfacesType || dest.Kind() == src.Kind()
|
||||
default:
|
||||
return src.ConvertibleTo(dest)
|
||||
}
|
||||
}
|
||||
|
||||
func storeMap(dest, src reflect.Value) error {
|
||||
switch {
|
||||
case !kindsAreCompatible(dest.Type(), src.Type()):
|
||||
return fmt.Errorf(
|
||||
"dbus.Store: type mismatch: "+
|
||||
"map: cannot store a value of %s into %s",
|
||||
src.Type(), dest.Type())
|
||||
case isVariant(dest.Type()):
|
||||
return storeMapIntoVariant(dest, src)
|
||||
case dest.Kind() == reflect.Interface:
|
||||
return storeMapIntoInterface(dest, src)
|
||||
case isConvertibleTo(dest.Type().Key(), src.Type().Key()) &&
|
||||
isConvertibleTo(dest.Type().Elem(), src.Type().Elem()):
|
||||
return storeMapIntoMap(dest, src)
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
"dbus.Store: type mismatch: "+
|
||||
"map: cannot convert a value of %s into %s",
|
||||
src.Type(), dest.Type())
|
||||
}
|
||||
}
|
||||
|
||||
func storeMapIntoVariant(dest, src reflect.Value) error {
|
||||
dv := reflect.MakeMap(src.Type())
|
||||
err := store(dv, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storeBase(dest, dv)
|
||||
}
|
||||
|
||||
func storeMapIntoInterface(dest, src reflect.Value) error {
|
||||
var dv reflect.Value
|
||||
if isVariant(src.Type().Elem()) {
|
||||
//Convert variants to interface{} recursively when converting
|
||||
//to interface{}
|
||||
dv = reflect.MakeMap(
|
||||
reflect.MapOf(src.Type().Key(), interfaceType))
|
||||
} else {
|
||||
dv = reflect.MakeMap(src.Type())
|
||||
}
|
||||
err := store(dv, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storeBase(dest, dv)
|
||||
}
|
||||
|
||||
func storeMapIntoMap(dest, src reflect.Value) error {
|
||||
if dest.IsNil() {
|
||||
dest.Set(reflect.MakeMap(dest.Type()))
|
||||
}
|
||||
keys := src.MapKeys()
|
||||
for _, key := range keys {
|
||||
dkey := key.Convert(dest.Type().Key())
|
||||
dval := reflect.New(dest.Type().Elem()).Elem()
|
||||
err := store(dval, getVariantValue(src.MapIndex(key)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dest.SetMapIndex(dkey, dval)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func storeSlice(dest, src reflect.Value) error {
|
||||
switch {
|
||||
case src.Type() == interfacesType && dest.Kind() == reflect.Struct:
|
||||
//The decoder always decodes structs as slices of interface{}
|
||||
return storeStruct(dest, src)
|
||||
case !kindsAreCompatible(dest.Type(), src.Type()):
|
||||
return fmt.Errorf(
|
||||
"dbus.Store: type mismatch: "+
|
||||
"slice: cannot store a value of %s into %s",
|
||||
src.Type(), dest.Type())
|
||||
case isVariant(dest.Type()):
|
||||
return storeSliceIntoVariant(dest, src)
|
||||
case dest.Kind() == reflect.Interface:
|
||||
return storeSliceIntoInterface(dest, src)
|
||||
case isConvertibleTo(dest.Type().Elem(), src.Type().Elem()):
|
||||
return storeSliceIntoSlice(dest, src)
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
"dbus.Store: type mismatch: "+
|
||||
"slice: cannot convert a value of %s into %s",
|
||||
src.Type(), dest.Type())
|
||||
}
|
||||
}
|
||||
|
||||
func storeStruct(dest, src reflect.Value) error {
|
||||
if isVariant(dest.Type()) {
|
||||
return storeBase(dest, src)
|
||||
}
|
||||
dval := make([]interface{}, 0, dest.NumField())
|
||||
dtype := dest.Type()
|
||||
for i := 0; i < dest.NumField(); i++ {
|
||||
field := dest.Field(i)
|
||||
ftype := dtype.Field(i)
|
||||
if ftype.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
if ftype.Tag.Get("dbus") == "-" {
|
||||
continue
|
||||
}
|
||||
dval = append(dval, field.Addr().Interface())
|
||||
}
|
||||
if src.Len() != len(dval) {
|
||||
return fmt.Errorf(
|
||||
"dbus.Store: type mismatch: "+
|
||||
"destination struct does not have "+
|
||||
"enough fields need: %d have: %d",
|
||||
src.Len(), len(dval))
|
||||
}
|
||||
return Store(src.Interface().([]interface{}), dval...)
|
||||
}
|
||||
|
||||
func storeSliceIntoVariant(dest, src reflect.Value) error {
|
||||
dv := reflect.MakeSlice(src.Type(), src.Len(), src.Cap())
|
||||
err := store(dv, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storeBase(dest, dv)
|
||||
}
|
||||
|
||||
func storeSliceIntoInterface(dest, src reflect.Value) error {
|
||||
var dv reflect.Value
|
||||
if isVariant(src.Type().Elem()) {
|
||||
//Convert variants to interface{} recursively when converting
|
||||
//to interface{}
|
||||
dv = reflect.MakeSlice(reflect.SliceOf(interfaceType),
|
||||
src.Len(), src.Cap())
|
||||
} else {
|
||||
dv = reflect.MakeSlice(src.Type(), src.Len(), src.Cap())
|
||||
}
|
||||
err := store(dv, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storeBase(dest, dv)
|
||||
}
|
||||
|
||||
func storeSliceIntoSlice(dest, src reflect.Value) error {
|
||||
if dest.IsNil() || dest.Len() < src.Len() {
|
||||
dest.Set(reflect.MakeSlice(dest.Type(), src.Len(), src.Cap()))
|
||||
} else if dest.Len() > src.Len() {
|
||||
dest.Set(dest.Slice(0, src.Len()))
|
||||
}
|
||||
for i := 0; i < src.Len(); i++ {
|
||||
err := store(dest.Index(i), getVariantValue(src.Index(i)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getVariantValue(in reflect.Value) reflect.Value {
|
||||
if isVariant(in.Type()) {
|
||||
return reflect.ValueOf(in.Interface().(Variant).Value())
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func isVariant(t reflect.Type) bool {
|
||||
return t == variantType
|
||||
}
|
||||
|
||||
// An ObjectPath is an object path as defined by the D-Bus spec.
|
||||
type ObjectPath string
|
||||
|
||||
// IsValid returns whether the object path is valid.
|
||||
func (o ObjectPath) IsValid() bool {
|
||||
s := string(o)
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
}
|
||||
if s[0] != '/' {
|
||||
return false
|
||||
}
|
||||
if s[len(s)-1] == '/' && len(s) != 1 {
|
||||
return false
|
||||
}
|
||||
// probably not used, but technically possible
|
||||
if s == "/" {
|
||||
return true
|
||||
}
|
||||
split := strings.Split(s[1:], "/")
|
||||
for _, v := range split {
|
||||
if len(v) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, c := range v {
|
||||
if !isMemberChar(c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// A UnixFD is a Unix file descriptor sent over the wire. See the package-level
|
||||
// documentation for more information about Unix file descriptor passsing.
|
||||
type UnixFD int32
|
||||
|
||||
// A UnixFDIndex is the representation of a Unix file descriptor in a message.
|
||||
type UnixFDIndex uint32
|
||||
|
||||
// alignment returns the alignment of values of type t.
|
||||
func alignment(t reflect.Type) int {
|
||||
switch t {
|
||||
case variantType:
|
||||
return 1
|
||||
case objectPathType:
|
||||
return 4
|
||||
case signatureType:
|
||||
return 1
|
||||
case interfacesType:
|
||||
return 4
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Uint8:
|
||||
return 1
|
||||
case reflect.Uint16, reflect.Int16:
|
||||
return 2
|
||||
case reflect.Uint, reflect.Int, reflect.Uint32, reflect.Int32, reflect.String, reflect.Array, reflect.Slice, reflect.Map:
|
||||
return 4
|
||||
case reflect.Uint64, reflect.Int64, reflect.Float64, reflect.Struct:
|
||||
return 8
|
||||
case reflect.Ptr:
|
||||
return alignment(t.Elem())
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// isKeyType returns whether t is a valid type for a D-Bus dict.
|
||||
func isKeyType(t reflect.Type) bool {
|
||||
switch t.Kind() {
|
||||
case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
|
||||
reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float64,
|
||||
reflect.String, reflect.Uint, reflect.Int:
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isValidInterface returns whether s is a valid name for an interface.
|
||||
func isValidInterface(s string) bool {
|
||||
if len(s) == 0 || len(s) > 255 || s[0] == '.' {
|
||||
return false
|
||||
}
|
||||
elem := strings.Split(s, ".")
|
||||
if len(elem) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, v := range elem {
|
||||
if len(v) == 0 {
|
||||
return false
|
||||
}
|
||||
if v[0] >= '0' && v[0] <= '9' {
|
||||
return false
|
||||
}
|
||||
for _, c := range v {
|
||||
if !isMemberChar(c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isValidMember returns whether s is a valid name for a member.
|
||||
func isValidMember(s string) bool {
|
||||
if len(s) == 0 || len(s) > 255 {
|
||||
return false
|
||||
}
|
||||
i := strings.Index(s, ".")
|
||||
if i != -1 {
|
||||
return false
|
||||
}
|
||||
if s[0] >= '0' && s[0] <= '9' {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !isMemberChar(c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isMemberChar(c rune) bool {
|
||||
return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= 'a' && c <= 'z') || c == '_'
|
||||
}
|
||||