From 5dd18a73c19a9486a785fc833c1c0ac2d847b649 Mon Sep 17 00:00:00 2001 From: Ryan McNally Date: Sat, 17 Aug 2024 15:29:04 +0100 Subject: [PATCH] Added systray icon --- .../main/java/dev/flowty/bowlby/app/Main.java | 27 ++- .../dev/flowty/bowlby/app/cfg/Parameters.java | 17 ++ .../bowlby/app/github/GithubApiClient.java | 23 ++- .../bowlby/app/srv/LatestArtifactHandler.java | 3 + .../dev/flowty/bowlby/app/srv/Server.java | 25 ++- .../bowlby/app/srv/ServerListeners.java | 60 ++++++ .../java/dev/flowty/bowlby/app/ui/Gui.java | 177 ++++++++++++++++++ .../java/dev/flowty/bowlby/app/MainTest.java | 5 +- .../java/dev/flowty/bowlby/it/End2EndIT.java | 2 +- 9 files changed, 324 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/dev/flowty/bowlby/app/srv/ServerListeners.java create mode 100644 app/src/main/java/dev/flowty/bowlby/app/ui/Gui.java diff --git a/app/src/main/java/dev/flowty/bowlby/app/Main.java b/app/src/main/java/dev/flowty/bowlby/app/Main.java index 223c575..9c950e9 100644 --- a/app/src/main/java/dev/flowty/bowlby/app/Main.java +++ b/app/src/main/java/dev/flowty/bowlby/app/Main.java @@ -1,11 +1,14 @@ package dev.flowty.bowlby.app; -import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.function.Consumer; import dev.flowty.bowlby.app.cfg.Parameters; import dev.flowty.bowlby.app.github.Artifacts; import dev.flowty.bowlby.app.github.GithubApiClient; import dev.flowty.bowlby.app.srv.Server; +import dev.flowty.bowlby.app.ui.Gui; /** * Application entrypoint @@ -29,6 +32,7 @@ public static void main( String... args ) { } private final Server server; + private final Gui gui; /** * @param parameters Configuration object @@ -47,6 +51,7 @@ public Main( Parameters parameters ) { ghClient, artifacts, parameters.latestArtifactCacheDuration() ); + gui = new Gui( this, parameters.iconBehaviour() ); } /** @@ -54,13 +59,28 @@ public Main( Parameters parameters ) { */ public void start() { server.start(); + gui.start(); } /** * @return The address that the server is listening on */ - public InetSocketAddress address() { - return server.address(); + public URI uri() { + try { + return new URI( "http:/" + server.address() ); + } + catch( URISyntaxException e ) { + throw new IllegalStateException( "Bad address", e ); + } + } + + /** + * Adds an activity listener + * + * @param listener the object to be appraised of request-handling activity + */ + public void withListener( Consumer listener ) { + server.withListener( listener ); } /** @@ -68,5 +88,6 @@ public InetSocketAddress address() { */ public void stop() { server.stop(); + gui.stop(); } } diff --git a/app/src/main/java/dev/flowty/bowlby/app/cfg/Parameters.java b/app/src/main/java/dev/flowty/bowlby/app/cfg/Parameters.java index 85d7312..6a91bc5 100644 --- a/app/src/main/java/dev/flowty/bowlby/app/cfg/Parameters.java +++ b/app/src/main/java/dev/flowty/bowlby/app/cfg/Parameters.java @@ -12,6 +12,7 @@ import java.util.stream.Stream; import dev.flowty.bowlby.app.github.Entity.Repository; +import dev.flowty.bowlby.app.ui.Gui.IconBehaviour; import picocli.CommandLine; import picocli.CommandLine.Command; import picocli.CommandLine.Option; @@ -79,6 +80,15 @@ public class Parameters { .ofNullable( System.getenv( "BOWLBY_ARTIFACT_VALIDITY" ) ) .orElse( "P3D" ); + @Option(names = { "-i", "--icon" }, + description = """ + Controls the system tray icon. Choose from NONE, STATIC or DYNAMIC. + The dynamic icon will give a visible indication of request-handling activity + Overrides environment variable 'BOWLBY_ICON'""") + private String iconBehaviour = Optional + .ofNullable( System.getenv( "BOWLBY_ICON" ) ) + .orElse( "DYNAMIC" ); + private static final Pattern REPO_RGX = Pattern.compile( "(\\w+)/(\\w+)" ); private final Set repos; @@ -159,4 +169,11 @@ public Duration latestArtifactCacheDuration() { public Duration artifactCacheDuration() { return Duration.parse( artifactValidity ); } + + /** + * @return The desired icon behaviour + */ + public IconBehaviour iconBehaviour() { + return IconBehaviour.from( iconBehaviour ); + } } diff --git a/app/src/main/java/dev/flowty/bowlby/app/github/GithubApiClient.java b/app/src/main/java/dev/flowty/bowlby/app/github/GithubApiClient.java index b3c6900..27ec9a3 100644 --- a/app/src/main/java/dev/flowty/bowlby/app/github/GithubApiClient.java +++ b/app/src/main/java/dev/flowty/bowlby/app/github/GithubApiClient.java @@ -106,7 +106,7 @@ public Path getArtifact( Artifact artifact, Path destination ) { Optional dlUri = redirect.headers().firstValue( "location" ); if( redirect.statusCode() != 302 && dlUri.isEmpty() ) { - LOG.warn( "Failed to get download URL {}/{}", + LOG.error( "Failed to get download URL {}/{}", redirect.statusCode(), redirect.body() ); } else { @@ -156,6 +156,13 @@ public Run getLatestRun( Workflow workflow, Branch branch ) { .build(), ListWorkflowRunResponse.HANDLER ); + if( response.statusCode() != 200 ) { + LOG.error( "Unexpected response status {}. Run with {} to see the full response.", + response.statusCode(), + "-Dorg.slf4j.simpleLogger.defaultLogLevel=trace" ); + return null; + } + return Optional.ofNullable( response ) .map( HttpResponse::body ) .map( b -> b.runs ) @@ -191,6 +198,13 @@ public Set getArtifacts( Run run ) { .build(), ListWorkflowRunArtifactsResponse.HANDLER ); + if( response.statusCode() != 200 ) { + LOG.error( "Unexpected response status {}. Run with {} to see the full response.", + response.statusCode(), + "-Dorg.slf4j.simpleLogger.defaultLogLevel=trace" ); + return null; + } + return Optional.ofNullable( response ) .map( HttpResponse::body ) .map( body -> body.artifacts ) @@ -228,6 +242,13 @@ public Branch getDefaultBranch( Repository repo ) { .build(), GetRepoResponse.HANDLER ); + if( response.statusCode() != 200 ) { + LOG.error( "Unexpected response status {}. Run with {} to see the full response.", + response.statusCode(), + "-Dorg.slf4j.simpleLogger.defaultLogLevel=trace" ); + return null; + } + return new Branch( repo, response.body().defaultBranch ); } catch( IOException | InterruptedException | URISyntaxException e ) { diff --git a/app/src/main/java/dev/flowty/bowlby/app/srv/LatestArtifactHandler.java b/app/src/main/java/dev/flowty/bowlby/app/srv/LatestArtifactHandler.java index 77df276..10d82b4 100644 --- a/app/src/main/java/dev/flowty/bowlby/app/srv/LatestArtifactHandler.java +++ b/app/src/main/java/dev/flowty/bowlby/app/srv/LatestArtifactHandler.java @@ -150,6 +150,9 @@ private LatestArtifact getLatest( Workflow workflow ) { } else { Branch defaultBranch = client.getDefaultBranch( workflow.repo() ); + if( defaultBranch == null ) { + return null; + } Run latest = client.getLatestRun( workflow, defaultBranch ); if( latest == null ) { return null; diff --git a/app/src/main/java/dev/flowty/bowlby/app/srv/Server.java b/app/src/main/java/dev/flowty/bowlby/app/srv/Server.java index b8b893b..8e1169d 100644 --- a/app/src/main/java/dev/flowty/bowlby/app/srv/Server.java +++ b/app/src/main/java/dev/flowty/bowlby/app/srv/Server.java @@ -6,6 +6,7 @@ import java.time.Duration; import java.util.Set; import java.util.concurrent.Executors; +import java.util.function.Consumer; import org.slf4j.Logger; @@ -21,6 +22,7 @@ public class Server { private static final Logger LOG = org.slf4j.LoggerFactory.getLogger( Server.class ); private final HttpServer server; + private final ServerListeners listeners = new ServerListeners(); /** * Creates a new server @@ -38,12 +40,14 @@ public Server( int port, Set repos, GithubApiClient ghClient, Artifa try { server = HttpServer.create( new InetSocketAddress( port ), 0 ); server.setExecutor( Executors.newCachedThreadPool() ); - server.createContext( "/favicon.ico", new ResourceHandler( - "/favicon.ico", "image/vnd.microsoft.icon" ) ); - server.createContext( "/artifacts", new ArtifactHandler( repos, artifacts ) ); - server.createContext( "/latest", - new LatestArtifactHandler( repos, ghClient, latestArtifactCacheDuration ) ); - server.createContext( "/", new LinkHandler() ); + server.createContext( "/favicon.ico", listeners.wrap( + new ResourceHandler( "/favicon.ico", "image/vnd.microsoft.icon" ) ) ); + server.createContext( "/artifacts", listeners.wrap( + new ArtifactHandler( repos, artifacts ) ) ); + server.createContext( "/latest", listeners.wrap( + new LatestArtifactHandler( repos, ghClient, latestArtifactCacheDuration ) ) ); + server.createContext( "/", listeners.wrap( + new LinkHandler() ) ); } catch( IOException ioe ) { throw new UncheckedIOException( "Failed to create server", ioe ); @@ -58,6 +62,15 @@ public void start() { LOG.info( "started at http:/{}", server.getAddress() ); } + /** + * Adds an activity listener + * + * @param listener the object to be appraised of request-handling activity + */ + public void withListener( Consumer listener ) { + listeners.with( listener ); + } + /** * @return The address that the server is listening on */ diff --git a/app/src/main/java/dev/flowty/bowlby/app/srv/ServerListeners.java b/app/src/main/java/dev/flowty/bowlby/app/srv/ServerListeners.java new file mode 100644 index 0000000..1cdd4e8 --- /dev/null +++ b/app/src/main/java/dev/flowty/bowlby/app/srv/ServerListeners.java @@ -0,0 +1,60 @@ +package dev.flowty.bowlby.app.srv; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import com.sun.net.httpserver.HttpHandler; + +/** + * Mechanism for behaviours that are interested in server activity + */ +public class ServerListeners { + + private final List> listeners = new ArrayList<>(); + + /** + * Wraps a handler in activity-notification behaviour + * + * @param handler The handler + * @return a handler that will notify listeners on behaviour + */ + public HttpHandler wrap( HttpHandler handler ) { + return exchange -> { + try( Notifying n = notifying() ) { + handler.handle( exchange ); + } + }; + } + + /** + * @param listener will be supplied with true when request handling + * begins starts, and false when it ends + * @return this + */ + public ServerListeners with( Consumer listener ) { + listeners.add( listener ); + return this; + } + + /** + * @return an {@link AutoCloseable} that handles listener notification + */ + private Notifying notifying() { + return new Notifying(); + } + + /** + * Handles listener notification + */ + private class Notifying implements AutoCloseable { + private Notifying() { + listeners.forEach( l -> l.accept( true ) ); + } + + @Override + public void close() { + listeners.forEach( l -> l.accept( false ) ); + } + } +} diff --git a/app/src/main/java/dev/flowty/bowlby/app/ui/Gui.java b/app/src/main/java/dev/flowty/bowlby/app/ui/Gui.java new file mode 100644 index 0000000..75c4cf1 --- /dev/null +++ b/app/src/main/java/dev/flowty/bowlby/app/ui/Gui.java @@ -0,0 +1,177 @@ +package dev.flowty.bowlby.app.ui; + +import java.awt.AWTException; +import java.awt.Color; +import java.awt.Desktop; +import java.awt.Desktop.Action; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.MenuItem; +import java.awt.PopupMenu; +import java.awt.SystemTray; +import java.awt.TrayIcon; +import java.awt.image.BufferedImage; +import java.io.IOException; + +import javax.imageio.ImageIO; +import javax.swing.SwingUtilities; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import dev.flowty.bowlby.app.Main; + +/** + * Drops an icon into the systray for local users + */ +public class Gui { + private static final Logger LOG = LoggerFactory.getLogger( Gui.class ); + + private TrayIcon icon; + private Image quiescent; + private Image provoked; + + private final Main app; + + /** + * Determines system tray icon behaviour + */ + public enum IconBehaviour { + /** + * No icon + */ + NONE, + /** + * A static icon + */ + STATIC, + /** + * An icon that indicates request handling behaviour + */ + DYNAMIC; + + /** + * @param s A string + * @return the matching behaviour + */ + public static IconBehaviour from( String s ) { + for( IconBehaviour ics : values() ) { + if( s.equalsIgnoreCase( ics.name() ) ) { + return ics; + } + } + return DYNAMIC; + } + } + + /** + * @param app The application instance + * @param iconBehaviour Controls icon behaviour + */ + public Gui( Main app, IconBehaviour iconBehaviour ) { + this.app = app; + if( iconBehaviour != IconBehaviour.NONE && SystemTray.isSupported() ) { + try { + quiescent = ImageIO.read( Gui.class.getResource( "/bowlby.png" ) ); + } + catch( IOException e ) { + throw new IllegalStateException( "Failed to load icon", e ); + } + provoked = provoked( quiescent ); + PopupMenu menu = new PopupMenu(); + menu.add( openItem() ); + menu.addSeparator(); + menu.add( quitItem() ); + + icon = new TrayIcon( provoked, "bowlby", menu ); + icon.setImageAutoSize( true ); + + quiescent = quiescent.getScaledInstance( + icon.getImage().getWidth( null ), + icon.getImage().getHeight( null ), + Image.SCALE_SMOOTH ); + provoked = provoked.getScaledInstance( + quiescent.getWidth( null ), + quiescent.getHeight( null ), + Image.SCALE_SMOOTH ); + + icon.setImage( quiescent ); + + if( iconBehaviour == IconBehaviour.DYNAMIC ) { + app.withListener( active -> icon.setImage( active ? provoked : quiescent ) ); + } + } + } + + private static Image provoked( Image quiescent ) { + BufferedImage bi = new BufferedImage( quiescent.getWidth( null ), quiescent.getHeight( null ), + BufferedImage.TYPE_INT_RGB ); + Graphics2D g = bi.createGraphics(); + g.drawImage( quiescent, 0, 0, null ); + g.setColor( Color.white ); + g.fillOval( 30, 80, 50, 50 ); + g.fillOval( 150, 80, 50, 50 ); + g.setColor( Color.black ); + g.fillOval( 48, 98, 14, 14 ); + g.fillOval( 168, 98, 14, 14 ); + + return bi; + } + + private MenuItem openItem() { + MenuItem item = new MenuItem( "Open" ); + item.addActionListener( e -> { + if( Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported( Action.BROWSE ) ) { + try { + Desktop.getDesktop().browse( app.uri() ); + } + catch( IOException ioe ) { + LOG.warn( "Failed to browse {}", app.uri(), ioe ); + } + } + else { + try { + new ProcessBuilder( "xdg-open", app.uri().toString() ).start(); + } + catch( IOException ioe ) { + LOG.warn( "Failed to invoke `xdg-open {}`", app.uri(), ioe ); + } + } + } ); + return item; + } + + private MenuItem quitItem() { + MenuItem item = new MenuItem( "Quit" ); + item.addActionListener( e -> app.stop() ); + return item; + } + + /** + * Adds the icon the system tray + */ + public void start() { + if( icon != null && SystemTray.isSupported() ) { + SwingUtilities.invokeLater( () -> { + try { + SystemTray.getSystemTray().add( icon ); + } + catch( AWTException e ) { + LOG.warn( "Failed to add tray icon", e ); + } + } ); + } + } + + /** + * Removes the icon from the system tray + */ + public void stop() { + if( icon != null && SystemTray.isSupported() ) { + SwingUtilities.invokeLater( () -> { + SystemTray.getSystemTray().remove( icon ); + } ); + } + } + +} diff --git a/app/src/test/java/dev/flowty/bowlby/app/MainTest.java b/app/src/test/java/dev/flowty/bowlby/app/MainTest.java index 5ace77a..c4b69a2 100644 --- a/app/src/test/java/dev/flowty/bowlby/app/MainTest.java +++ b/app/src/test/java/dev/flowty/bowlby/app/MainTest.java @@ -7,7 +7,6 @@ import java.io.File; import java.io.IOException; -import java.net.URI; import java.net.URISyntaxException; import java.net.http.HttpClient; import java.net.http.HttpResponse; @@ -45,7 +44,6 @@ class MainTest { private static Main app; - private static URI target; private static final Consequests consequests = new Consequests(); private static final MockHost artifacts = new MockHost( Actors.ARTIFACTS, consequests ); private static final MockHost github = new MockHost( Actors.GITHUB, consequests, @@ -85,7 +83,6 @@ static void start() throws URISyntaxException, IOException { "-g", "http:/" + github.address(), "-t", "_auth_token_" ) ); app.start(); - target = new URI( "http:/" + app.address() ); } /** @@ -105,7 +102,7 @@ Stream tests() { github.seedResponses( asrt ); HttpResponse response = HTTP.send( - HttpFlow.sendable( target, request ), + HttpFlow.sendable( app.uri(), request ), BodyHandlers.ofString() ); asrt.actual() diff --git a/test/src/test/java/dev/flowty/bowlby/it/End2EndIT.java b/test/src/test/java/dev/flowty/bowlby/it/End2EndIT.java index eac277f..7089b28 100644 --- a/test/src/test/java/dev/flowty/bowlby/it/End2EndIT.java +++ b/test/src/test/java/dev/flowty/bowlby/it/End2EndIT.java @@ -64,7 +64,7 @@ Stream tests() { .behaviour( asrt -> { WebSequence request = (WebSequence) asrt.expected().request().child(); if( request.get( "bowlby_url" ) != null ) { - request.set( "bowlby_url", "http:/" + app.address() ); + request.set( "bowlby_url", app.uri().toString() ); } WebDriver driver = driver();