-
Notifications
You must be signed in to change notification settings - Fork 562
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
overlord,usersession: initial notifications of pending refreshes #9446
Changes from 5 commits
2de348f
0e7e301
f0b40bc
e9eb7f1
6066d68
1be2b1a
6c5ae0c
65d8eeb
db810ed
99295dd
2a36c49
670b01a
b6be078
24dbf7c
bf12216
9981312
59d3475
c9e348d
12434b8
b2fdb1f
48c80b4
17eb9e7
f680c1e
31dec23
c487d86
3d43e83
4e68782
268557e
20f559a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,8 +20,11 @@ | |
package snapstate | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"os" | ||
"path/filepath" | ||
"strings" | ||
"time" | ||
|
||
"github.com/snapcore/snapd/httputil" | ||
|
@@ -35,6 +38,7 @@ import ( | |
"github.com/snapcore/snapd/strutil" | ||
"github.com/snapcore/snapd/timeutil" | ||
"github.com/snapcore/snapd/timings" | ||
userclient "github.com/snapcore/snapd/usersession/client" | ||
) | ||
|
||
// the default refresh pattern | ||
|
@@ -489,21 +493,46 @@ func getTime(st *state.State, timeKey string) (time.Time, error) { | |
return t1, nil | ||
} | ||
|
||
func populateRefreshHints(refreshInfo *userclient.PendingSnapRefreshInfo, snapInfo *snap.Info, err *BusySnapError) { | ||
for _, appName := range err.AppNames() { | ||
if app, ok := snapInfo.Apps[appName]; ok { | ||
path := app.DesktopFile() | ||
if _, err := os.Stat(path); err == nil { | ||
refreshInfo.BusyAppName = appName | ||
refreshInfo.BusyAppDesktopEntry = strings.SplitN(filepath.Base(path), ".", 2)[0] | ||
} | ||
} | ||
} | ||
} | ||
|
||
// inhibitRefresh returns an error if refresh is inhibited by running apps. | ||
// | ||
// Internally the snap state is updated to remember when the inhibition first | ||
// took place. Apps can inhibit refreshes for up to "maxInhibition", beyond | ||
// that period the refresh will go ahead despite application activity. | ||
func inhibitRefresh(st *state.State, snapst *SnapState, info *snap.Info, checker func(*snap.Info) error) error { | ||
if err := checker(info); err != nil { | ||
refreshInfo := &userclient.PendingSnapRefreshInfo{ | ||
InstanceName: info.InstanceName(), | ||
} | ||
if err, ok := err.(*BusySnapError); ok { | ||
populateRefreshHints(refreshInfo, info, err) | ||
} | ||
|
||
days := int(maxInhibition.Truncate(time.Hour).Hours() / 24) | ||
now := time.Now() | ||
client := userclient.New() | ||
if snapst.RefreshInhibitedTime == nil { | ||
// Store the instant when the snap was first inhibited. | ||
// This is reset to nil on successful refresh. | ||
snapst.RefreshInhibitedTime = &now | ||
Set(st, info.InstanceName(), snapst) | ||
if _, ok := err.(*BusySnapError); ok { | ||
refreshInfo.TimeRemaining = (maxInhibition - now.Sub(*snapst.RefreshInhibitedTime)).Truncate(time.Second) | ||
if err := client.PendingRefreshNotification(context.TODO(), refreshInfo); err != nil { | ||
zyga marked this conversation as resolved.
Show resolved
Hide resolved
|
||
logger.Noticef("Cannot send notification about pending refresh: %v", err) | ||
} | ||
// XXX: remove the warning or send it only if no notification was delivered? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I think we should remove the warning. Fwiw, I think the warning here is really a bit of a misfeature because we cannot make it go away once the app was refreshed. But it's fine to keep the XXX and do that in a followup. Especially this first warning that just warns for the first time seems a bit unneeded. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree but I'd like to remove them in a follow up. |
||
st.Warnf(i18n.NG( | ||
"snap %q is currently in use. Its refresh will be postponed for up to %d day to wait for the snap to no longer be in use.", | ||
"snap %q is currently in use. Its refresh will be postponed for up to %d days to wait for the snap to no longer be in use.", days), | ||
|
@@ -515,9 +544,20 @@ func inhibitRefresh(st *state.State, snapst *SnapState, info *snap.Info, checker | |
if now.Sub(*snapst.RefreshInhibitedTime) < maxInhibition { | ||
// If we are still in the allowed window then just return | ||
// the error but don't change the snap state again. | ||
refreshInfo.TimeRemaining = (maxInhibition - now.Sub(*snapst.RefreshInhibitedTime)).Truncate(time.Second) | ||
if err := client.PendingRefreshNotification(context.TODO(), refreshInfo); err != nil { | ||
logger.Noticef("Cannot send notification about pending refresh: %v", err) | ||
} | ||
// TODO: as time left shrinks, send additional notifications with | ||
// increasing frequency, allowing the user to understand the | ||
// urgency. | ||
return err | ||
} | ||
if _, ok := err.(*BusySnapError); ok { | ||
if err := client.PendingRefreshNotification(context.TODO(), refreshInfo); err != nil { | ||
logger.Noticef("Cannot send notification about forced refresh: %v", err) | ||
} | ||
// XXX: remove the warning or send it only if no notification was delivered? | ||
st.Warnf(i18n.NG( | ||
"snap %q has been running for the maximum allowable %d day since its refresh was postponed. It will now be refreshed.", | ||
"snap %q has been running for the maximum allowable %d days since its refresh was postponed. It will now be refreshed.", days), | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -21,12 +21,18 @@ package agent | |
|
||
import ( | ||
"encoding/json" | ||
"fmt" | ||
"mime" | ||
"net/http" | ||
"strings" | ||
"sync" | ||
"time" | ||
|
||
"github.com/mvo5/goconfigparser" | ||
|
||
"github.com/snapcore/snapd/dbusutil" | ||
"github.com/snapcore/snapd/desktop/notification" | ||
"github.com/snapcore/snapd/i18n" | ||
"github.com/snapcore/snapd/systemd" | ||
"github.com/snapcore/snapd/timeout" | ||
) | ||
|
@@ -35,6 +41,7 @@ var restApi = []*Command{ | |
rootCmd, | ||
sessionInfoCmd, | ||
serviceControlCmd, | ||
pendingRefreshNotificationCmd, | ||
} | ||
|
||
var ( | ||
|
@@ -52,6 +59,11 @@ var ( | |
Path: "/v1/service-control", | ||
POST: postServiceControl, | ||
} | ||
|
||
pendingRefreshNotificationCmd = &Command{ | ||
Path: "/v1/notifications/pending-refresh", | ||
POST: postPendingRefreshNotification, | ||
} | ||
) | ||
|
||
func sessionInfo(c *Command, r *http.Request) Response { | ||
|
@@ -197,3 +209,110 @@ func postServiceControl(c *Command, r *http.Request) Response { | |
sysd := systemd.New(systemd.UserMode, dummyReporter{}) | ||
return impl(&inst, sysd) | ||
} | ||
|
||
func postPendingRefreshNotification(c *Command, r *http.Request) Response { | ||
zyga marked this conversation as resolved.
Show resolved
Hide resolved
|
||
contentType := r.Header.Get("Content-Type") | ||
mediaType, params, err := mime.ParseMediaType(contentType) | ||
if err != nil { | ||
return BadRequest("cannot parse content type: %v", err) | ||
} | ||
|
||
if mediaType != "application/json" { | ||
return BadRequest("unknown content type: %s", contentType) | ||
} | ||
|
||
charset := strings.ToUpper(params["charset"]) | ||
if charset != "" && charset != "UTF-8" { | ||
return BadRequest("unknown charset in content type: %s", contentType) | ||
} | ||
|
||
decoder := json.NewDecoder(r.Body) | ||
|
||
// pendingSnapRefreshInfo holds information about pending snap refresh provided by snapd. | ||
type pendingSnapRefreshInfo struct { | ||
InstanceName string `json:"instance-name"` | ||
TimeRemaining time.Duration `json:"time-remaining,omitempty"` | ||
BusyAppName string `json:"busy-app-name,omitempty"` | ||
BusyAppDesktopEntry string `json:"busy-app-desktop-entry,omitempty"` | ||
} | ||
var refreshInfo pendingSnapRefreshInfo | ||
if err := decoder.Decode(&refreshInfo); err != nil { | ||
return BadRequest("cannot decode request body into pending snap refresh info: %v", err) | ||
} | ||
|
||
conn, err := dbusutil.SessionBus() | ||
zyga marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if err != nil { | ||
return SyncResponse(&resp{ | ||
Type: ResponseTypeError, | ||
Status: 500, | ||
Result: &errorResult{ | ||
Message: fmt.Sprintf("cannot connect to the session bus: %v", err), | ||
}, | ||
}) | ||
} | ||
defer conn.Close() | ||
zyga marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// TODO: support desktop-specific notification APIs if they provide a better | ||
// experience. For example, the GNOME notification API. | ||
notifySrv := notification.New(conn) | ||
|
||
// TODO: this message needs to be crafted better as it's the only thing guaranteed to be delivered. | ||
summary := fmt.Sprintf(i18n.G("Pending update of %q snap"), refreshInfo.InstanceName) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Given we have the desktop entry of the busy application, perhaps it would be worth including the name from that too? That potentially gives us a localised name. As it is unverified information from the client though, we probably still want to include the snape name. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've experimented with some of that from the side of snapd, where we know the app name that is busy. It tends to create repetitive patterns (chrome chrome ...) and I didn't like the result. Desktop file names are sometimes equally weird so I'd postpone that until we can give it a try on a larger sample size. |
||
var urgencyLevel notification.Urgency | ||
var body string | ||
var icon string | ||
var hints []notification.Hint | ||
if daysLeft := int(refreshInfo.TimeRemaining.Truncate(time.Hour).Hours() / 24); daysLeft > 0 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would suggest forming the parenthesised part of the message independently, then construct the body using We've got similar formatting code in |
||
urgencyLevel = notification.LowUrgency | ||
body = fmt.Sprintf(i18n.NG( | ||
"Close the app to avoid disruptions (%d day left)", | ||
"Close the app to avoid disruptions (%d days left)", daysLeft), daysLeft) | ||
} else if hoursLeft := int(refreshInfo.TimeRemaining.Truncate(time.Minute).Minutes() / 60); hoursLeft > 0 { | ||
urgencyLevel = notification.NormalUrgency | ||
body = fmt.Sprintf(i18n.NG( | ||
"Close the app to avoid disruptions (%d hour left)", | ||
"Close the app to avoid disruptions (%d hours left)", hoursLeft), hoursLeft) | ||
} else if minutesLeft := int(refreshInfo.TimeRemaining.Truncate(time.Minute).Minutes()); minutesLeft > 0 { | ||
urgencyLevel = notification.CriticalUrgency | ||
body = fmt.Sprintf(i18n.NG( | ||
"Close the app to avoid disruptions (%d minute left)", | ||
"Close the app to avoid disruptions (%d minutes left)", minutesLeft), minutesLeft) | ||
} else { | ||
summary = fmt.Sprintf(i18n.G("Snap %q is refreshing now!"), refreshInfo.InstanceName) | ||
urgencyLevel = notification.CriticalUrgency | ||
} | ||
hints = append(hints, notification.WithUrgency(urgencyLevel)) | ||
if refreshInfo.BusyAppDesktopEntry != "" { | ||
hints = append(hints, notification.WithDesktopEntry(refreshInfo.BusyAppDesktopEntry)) | ||
zyga marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// Extract the icon manually | ||
parser := goconfigparser.New() | ||
if err := parser.ReadFile(fmt.Sprintf("/var/lib/snapd/desktop/applications/%s.desktop", refreshInfo.BusyAppDesktopEntry)); err == nil { | ||
icon, _ = parser.Get("Desktop Entry", "Icon") | ||
} | ||
} | ||
msg := ¬ification.Message{ | ||
AppName: refreshInfo.BusyAppName, | ||
Summary: summary, | ||
Icon: icon, | ||
Body: body, | ||
Hints: hints, | ||
} | ||
|
||
// TODO: if snap store is installed and actions are supported, add an action | ||
// to open the snap store page for the given snap. | ||
// | ||
// XXX: how are instances supported in the snap store, are they? | ||
|
||
// TODO: silently ignore error returned when the notification server does not exist. | ||
// TODO: track returned notification ID and respond to actions, if supported. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are we planning to do actual interaction here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Given that this was literally designed last week I couldn't say. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we want to have interactivity for FDO notifications, the idle tracking code will need updating to keep the session agent alive until the notification is dismissed. That's more of a TODO item than something that needs to be done in this PR though. |
||
if _, err := notifySrv.SendNotification(msg); err != nil { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could also track the returned notification ID for as long as userd is alive, and use it to update existing notification, if one exists. This would ensure that the roster of persistent notifications only shows the current, up-to-date entry for each snap, not a separate entry per notification. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is one of the nicer parts of the the new GNOME notification spec: it has client provided notification IDs, so we don't need to track server IDs to update/replace/withdraw old notifications. |
||
return SyncResponse(&resp{ | ||
Type: ResponseTypeError, | ||
Status: 500, | ||
Result: &errorResult{ | ||
Message: fmt.Sprintf("cannot send notification message: %v", err), | ||
}, | ||
}) | ||
} | ||
return SyncResponse(nil) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this robust enough because DesktopFile will always end in ".desktop" and we don't allow "." in the names of desktop files?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The desktop file is generated by us so yes. I think this is sensible.