Commit 40ecc0ce authored by Charlie Fenton's avatar Charlie Fenton

mac: Fixes for screensaver under OS 10.15 Catalina

 * Screensavers can't launch setuid / sergid executables like gfx_switcher
 * Screensavers can't launch executables downloaded from Internet unless vetted by user vis GateKeeper
 * Apple's ScreenSaverEngine doesn't always call stopAnimation before exiting
 * Apple's ScreenSaverEngine always passes true for isPreview argument
 * OpenGL apps built under Xcode 11 & Catalina use window doubled window dimensions on Retina displays (2 pixels per point)
 * The CGWindowList method we have used to display project graphics apps which have not been updated no longer works
* Screensaver output files are put in an obscure sandboxed directory
parent e7a545e7
// This file is part of BOINC.
// http://boinc.berkeley.edu
// Copyright (C) 2017 University of California
// Copyright (C) 2019 University of California
//
// BOINC is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License
......@@ -46,8 +46,10 @@ static int win=0;
static int checkparentcounter=0;
#ifdef __APPLE__
static bool need_show = false;
// Set to true to draw both directly to window and also to offscreen buffer
bool debugSharedOffscreenBuffer = false;
#endif
bool fullscreen;
......@@ -119,7 +121,7 @@ static void maybe_render() {
if (throttled_app_render(new_width, new_height, dtime())) {
#ifdef __APPLE__
if (UseSharedOffscreenBuffer()) {
if (UseSharedOffscreenBuffer() && !debugSharedOffscreenBuffer) {
return; // Don't waste cycles drawing to hidden window on screen
}
#endif
......
// Berkeley Open Infrastructure for Network Computing
// http://boinc.berkeley.edu
// Copyright (C) 2017 University of California
// Copyright (C) 2019 University of California
//
// This is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
......@@ -285,7 +285,6 @@ kern_return_t _MGSDisplayFrame(mach_port_t server_port, int32_t frame_index, uin
{
if(clientPortNames[i])
{
// print_to_log_file("BOINCSCR: about to call _MGCDisplayFrame with iosurface_port %d, IOSurfaceGetID %d and frameIndex %d", (int)iosurface_port, IOSurfaceGetID(ioSurfaceBuffers[index]), (int)index);
_MGCDisplayFrame(clientPortNames[i], index, iosurface_port);
}
}
......@@ -293,24 +292,31 @@ kern_return_t _MGSDisplayFrame(mach_port_t server_port, int32_t frame_index, uin
@end
// OpenGL apps built under Xcode 11 under Catalina apparently use window
// dimensions based on the number of backing store pixels. That is, they
// double the window dimensiona for Retina displays (which have two pixels
// per point.) But OpenGL apps built under earlier versions of Xcode don't.
// Catalina assumes OpenGL apps work as built under Xcode 11, so it displays
// older builds at half width and height, unless we compensate in our code.
// This code is part of my attempt to ensure that BOINC graphics apps built on
// all versions of Xcode work proprly on different versions of OS X. See also
// [BOINC_Saver_ModuleView initWithFrame:] in clientscr/Mac_Saver_ModuleCiew.m
//
void MacPassOffscreenBufferToScreenSaver() {
NSOpenGLContext * myContext = [ NSOpenGLContext currentContext ];
NSView *myView = [ myContext view ];
GLsizei w = myView.bounds.size.width;
GLsizei h = myView.bounds.size.height;
int viewportRect[4];
GLsizei w, h;
GLuint name, namef;
glGetIntegerv(GL_VIEWPORT, (GLint*)viewportRect);
w = viewportRect[2];
h = viewportRect[3];
if (!myserverController) {
myserverController = [[[ServerController alloc] init] retain];
}
if (!ioSurfaceBuffers[0]) {
NSOpenGLContext * myContext = [ NSOpenGLContext currentContext ];
NSView *myView = [ myContext view ];
GLsizei w = myView.bounds.size.width;
GLsizei h = myView.bounds.size.height;
// Set up all of our iosurface buffers
for(int i = 0; i < NUM_IOSURFACE_BUFFERS; i++) {
ioSurfaceBuffers[i] = IOSurfaceCreate((CFDictionaryRef)@{
......
// This file is part of BOINC.
// http://boinc.berkeley.edu
// Copyright (C) 2017 University of California
// Copyright (C) 2019 University of California
//
// BOINC is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License
......@@ -30,6 +30,7 @@ extern void MacPassOffscreenBufferToScreenSaver(void);
extern void BringAppToFront(void);
extern void HideThisApp(void);
extern bool UseSharedOffscreenBuffer(void);
extern bool debugSharedOffscreenBuffer;
extern void print_to_log_file(const char *format, ...);
......
// This file is part of BOINC.
// http://boinc.berkeley.edu
// Copyright (C) 2008 University of California
// Copyright (C) 2019 University of California
//
// BOINC is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License
......@@ -173,6 +173,8 @@ struct ACTIVE_TASK {
double finish_file_time;
// time when we saw finish file in slot dir.
// Used to kill apps that hang after writing finished file
int graphics_pid;
// PID of running graphics app (Mac)
void set_task_state(int, const char*);
inline int task_state() {
......@@ -302,6 +304,7 @@ public:
active_tasks_v active_tasks;
ACTIVE_TASK* lookup_pid(int);
ACTIVE_TASK* lookup_result(RESULT*);
ACTIVE_TASK* lookup_slot(int);
void init();
bool poll();
void suspend_all(int reason);
......
// This file is part of BOINC.
// http://boinc.berkeley.edu
// Copyright (C) 2008 University of California
// Copyright (C) 2019 University of California
//
// BOINC is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License
......@@ -93,7 +93,10 @@ Commands:\n\
--read_cc_config\n\
--read_global_prefs_override\n\
--run_benchmarks\n\
--set_gpu_mode mode duration set GPU run mode for given duration\n\
--run_graphics_app id op run, test or stop graphics app\n\
op = run | runfullscreen | stop | test\n\
id = slot # for run or runfullscreen, process ID for stop or test\n\
--set_gpu_mode mode duration set GPU run mode for given duration\n\
mode = always | auto | never\n\
--set_host_info product_name\n\
--set_network_mode mode duration set network mode for given duration\n\
......@@ -542,6 +545,18 @@ int main(int argc, char** argv) {
retval = rpc.acct_mgr_rpc("", "", "");
} else if (!strcmp(cmd, "--run_benchmarks")) {
retval = rpc.run_benchmarks();
} else if (!strcmp(cmd, "--run_graphics_app")) {
int slot = 0;
if (!strcmp(argv[3], "test") || (!strcmp(argv[3], "stop"))) {
i = atoi(argv[2]);
} else {
slot = atoi(argv[2]);
i = 0;
}
retval = rpc.run_graphics_app(slot, i, argv[3]);
if (strcmp(argv[3], "stop") & !retval) {
printf("pid: %d\n", i);
}
} else if (!strcmp(cmd, "--get_project_config")) {
char* gpc_url = next_arg(argc, argv,i);
retval = rpc.get_project_config(string(gpc_url));
......
// This file is part of BOINC.
// http://boinc.berkeley.edu
// Copyright (C) 2018 University of California
// Copyright (C) 2019 University of California
//
// BOINC is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License
......@@ -22,6 +22,7 @@
#ifdef __APPLE__
#include <Carbon/Carbon.h>
#include <libproc.h>
#include "sandbox.h"
#endif
#ifdef _WIN32
......@@ -1332,6 +1333,201 @@ static void handle_get_daily_xfer_history(GUI_RPC_CONN& grc) {
daily_xfer_history.write_xml(grc.mfout);
}
// start, stop or get status of a graphics app on behalf of the screensaver.
// (needed for Mac OS X 10.5+)
//
// <slot>n</slot> { <run/> | <runfullscreen/> }
// <graphics_pid>p</graphics_pid> { <stop/> | <test/> }
//
// n is the slot number:
// if slot = -1, start the default screensaver
// p is the process id to stop or test
// test returns 0 for the pid if it has exited, else returns the child's pid
//
// As of 3 November 2019, only the "stop" verb is being used, because the client
// can't launch or test gfx apps outside the user session within which the client
// is running. So if another user is logged in and runs the screensaver, that
// sure would not see the graphics. But I'm leaving code for all the verbs for
// now as a possible starting point for future development.
//
// The stop verb still works because it calls kill_via_switcher(), which calls
// kill(pid, SIGKILL) after setting user and group to pbionc_project. That does
// work across different login sessions.
//
static void handle_run_graphics_app(GUI_RPC_CONN& grc) {
#ifndef __APPLE__
grc.mfout.printf("<error>run_graphics_app RPC is currently available only on Mac OS</error>\n");
#else
static int boincscr_pid = 0;
bool run = false;
bool runfullscreen = false;
bool stop = false;
bool test = false;
int slot = -2, retval;
int status;
pid_t p;
char* argv[5];
int argc;
int thePID = 0;
while (!grc.xp.get_tag()) {
if (grc.xp.match_tag("/run_graphics_app")) break;
if (grc.xp.parse_int("slot", slot)) continue;
if (grc.xp.parse_bool("run", run)) continue;
if (grc.xp.parse_bool("runfullscreen", runfullscreen)) continue;
if (grc.xp.parse_bool("stop", stop)) continue;
if (grc.xp.parse_bool("test", test)) continue;
if (grc.xp.parse_int("graphics_pid", thePID)) continue;
}
if (stop || test) {
if (thePID < 1) {
grc.mfout.printf("<error>missing or invalid process id</error>\n");
return;
}
} else if (run || runfullscreen) {
if (slot < -1) {
grc.mfout.printf("<error>missing or invalid slot</error>\n");
return;
}
} else {
grc.mfout.printf("<error>missing or invalid operation</error>\n");
return;
}
if (test) {
// returns 0 for the pid if it has exited, else returns the child's pid
p = waitpid(thePID, &status, WNOHANG);
if (p != 0) thePID = 0;
grc.mfout.printf(
"<graphics_pid>%d</graphics_pid>\n",
thePID
);
return;
}
if (stop) {
if (g_use_sandbox && (thePID != boincscr_pid )) {
retval = kill_via_switcher(thePID);
} else {
retval = kill_program(thePID);
}
if (retval) {
grc.mfout.printf("<error>attempt to kill graphics app failed</error>\n");
return;
}
if (thePID == boincscr_pid) boincscr_pid = 0;
grc.mfout.printf("<success/>\n");
return;
}
// start boincscr
//
if (slot == -1) {
char path[MAXPATHLEN];
#ifdef __APPLE__
safe_strcpy(path, "./boincscr");
#else
if (get_real_executable_path(path, sizeof(path))) {
grc.mfout.printf("<error>can't get client path</error>\n");
return;
}
char *p = strrchr(path, '/');
if (!p) {
grc.mfout.printf("<error>no / in client path</error>\n");
return;
}
safe_strcpy(p, "/boincscr");
#endif
argv[0] = (char*)"boincscr";
if (runfullscreen) {
argv[1] = (char*)"--fullscreen";
argc = 2;
} else {
argv[1] = 0;
argc = 1;
}
argv[2] = 0;
retval = run_program(NULL, path, argc, argv, 0, boincscr_pid);
if (retval) {
grc.mfout.printf("<error>couldn't run boincscr</error>\n");
return;
}
grc.mfout.printf(
"<graphics_pid>%d</graphics_pid>\n",
boincscr_pid
);
return;
} // end if (slot == -1)
// start a graphics app
//
ACTIVE_TASK* atp = gstate.active_tasks.lookup_slot(slot);
if (!atp) {
grc.mfout.printf("<error>no job in slot</error>\n");
return;
}
if (atp->scheduler_state != CPU_SCHED_SCHEDULED) {
grc.mfout.printf("<error>job not running</error>\n");
return;
}
if (!strlen(atp->app_version->graphics_exec_path)) {
grc.mfout.printf("<error>job has no graphics app</error>\n");
return;
}
if (g_use_sandbox) {
char current_dir[MAXPATHLEN], switcher_path[MAXPATHLEN];
getcwd( current_dir, sizeof(current_dir));
snprintf(switcher_path, sizeof(switcher_path),
"%s/%s/%s",
current_dir, SWITCHER_DIR, SWITCHER_FILE_NAME
);
argv[0] = const_cast<char*>(SWITCHER_FILE_NAME);
argv[1] = atp->app_version->graphics_exec_path;
argv[2] = atp->app_version->graphics_exec_file;
if (runfullscreen) {
argv[3] = (char*)"--fullscreen";
argc = 3;
} else {
argv[3] = 0;
argc = 2;
}
argv[4] = 0;
retval = run_program(
atp->slot_path, switcher_path,
argc, argv, 0, atp->graphics_pid
);
} else { // not g_use_sandbox
argv[0] = atp->app_version->graphics_exec_file;
if (runfullscreen) {
argv[1] = (char*)"--fullscreen";
argc = 2;
} else {
argv[2] = 0;
argc = 1;
}
argv[2] = 0;
retval = run_program(
atp->slot_path, atp->app_version->graphics_exec_path,
argc, argv, 0, atp->graphics_pid
);
}
if (retval) {
grc.mfout.printf("<error>couldn't run graphics app</error>\n");
return;
}
grc.mfout.printf(
"<graphics_pid>%d</graphics_pid>\n",
atp->graphics_pid
);
#endif // __APPLE__
}
// We use a different authentication scheme for HTTP because
// each request has its own connection.
// Send clients an "authentication ID".
......@@ -1599,6 +1795,7 @@ GUI_RPC gui_rpcs[] = {
GUI_RPC("project_reset", handle_project_reset, true, true, false),
GUI_RPC("project_update", handle_project_update, true, true, false),
GUI_RPC("retry_file_transfer", handle_retry_file_transfer, true, true, false),
GUI_RPC("run_graphics_app", handle_run_graphics_app, true, true, false),
};
// return nonzero only if we need to close the connection
......
// This file is part of BOINC.
// http://boinc.berkeley.edu
// Copyright (C) 2008 University of California
// Copyright (C) 2019 University of California
//
// BOINC is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License
......@@ -56,8 +56,10 @@ int main(int /*argc*/, char** argv) {
getcwd( current_dir, sizeof(current_dir));
fprintf(stderr, "current directory = %s\n", current_dir);
for (int i=0; i<argc; i++) {
int i = 0;
while(argv[i]) {
fprintf(stderr, "switcher arg %d: %s\n", i, argv[i]);
++i;
}
fflush(stderr);
#endif
......
// This file is part of BOINC.
// http://boinc.berkeley.edu
// Copyright (C) 2018 University of California
// Copyright (C) 2019 University of California
//
// BOINC is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License
......@@ -56,6 +56,10 @@ void launchedGfxApp(char * appPath, pid_t thePID, int slot);
void print_to_log_file(const char *format, ...);
void strip_cr(char *buf);
void PrintBacktrace(void);
extern bool gIsMojave;
extern bool gIsCatalina;
extern bool gIsHighSierra;
extern bool gUseLaunchAgent;
#ifdef __cplusplus
} // extern "C"
......@@ -84,18 +88,18 @@ public:
int Create();
int Run();
//
// Infrastructure layer
//
protected:
OSStatus initBOINCApp(void);
int GetBrandID(void);
char* PersistentFGets(char *buf, size_t buflen, FILE *f);
pid_t FindProcessPID(char* name, pid_t thePID);
pid_t getClientPID(void);
void updateSSMessageText(char *msg);
void strip_cr(char *buf);
char m_gfx_Switcher_Path[PATH_MAX];
char m_gfx_Cleanup_Path[PATH_MAX];
FILE* m_gfx_Cleanup_IPC;
void SetDiscreteGPU(bool setDiscrete);
void CheckDualGPUPowerSource();
bool Host_is_running_on_batteries();
......@@ -165,7 +169,7 @@ public:
bool SetError( bool bErrorMode, unsigned int hrError );
void setSSMessageText(const char *msg);
int terminate_v6_screensaver(int& graphics_application);
int terminate_v6_screensaver(int& graphics_application, RESULT* rp);
bool HasProcessExited(pid_t pid, int &exitCode);
CC_STATE state;
......
// This file is part of BOINC.
// http://boinc.berkeley.edu
// Copyright (C) 2018 University of California
// Copyright (C) 2019 University of California
//
// BOINC is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License
......@@ -95,6 +95,11 @@ void print_to_log_file(const char *format, ...);
void strip_cr(char *buf);
void PrintBacktrace(void);
extern bool gIsCatalina;
extern bool gIsHighSierra;
extern bool gIsMojave;
extern bool gUseLaunchAgent;
#ifdef __cplusplus
} // extern "C"
#endif
// This file is part of BOINC.
// http://boinc.berkeley.edu
// Copyright (C) 2018 University of California
// Copyright (C) 2019 University of California
//
// BOINC is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License
......@@ -157,7 +157,8 @@ static bool UseSharedOffscreenBuffer(void);
static double lastGetSSMsgTime;
static pthread_t mainThreadID;
static int CGWindowListTries;
static bool mojave;
static int DPI_multiplier = 1;
static bool myIsPreview;
#define TEXTBOXMINWIDTH 400.0
......@@ -168,8 +169,10 @@ static bool mojave;
#define MINDELTA 8
#define MAXDELTA 16
// On OS 10.13+, assume graphics app is not compatible if no MachO connection after 5 seconds
// On OS 10.13+, assume graphics app is not compatible if no MachO
// connection after MAXWAITFORCONNECTION seconds
#define MAXWAITFORCONNECTION 8.0
#define MAXWAITFORCONNECTIONCATALINA 12.0
#define MAX_CGWINDOWLIST_TRIES 3
int signof(float x) {
......@@ -199,7 +202,36 @@ void launchedGfxApp(char * appPath, pid_t thePID, int slot) {
- (id)initWithFrame:(NSRect)frame isPreview:(BOOL)isPreview {
self = [ super initWithFrame:frame isPreview:isPreview ];
mojave = (compareOSVersionTo(10, 14) >= 0);
gIsHighSierra = (compareOSVersionTo(10, 13) >= 0);
gIsMojave = (compareOSVersionTo(10, 14) >= 0);
gIsCatalina = (compareOSVersionTo(10, 15) >= 0);
// MIN_OS_TO_USE_SCREENSAVER_LAUNCH_AGENT is defined in mac_util.h
gUseLaunchAgent = (compareOSVersionTo(10, MIN_OS_TO_USE_SCREENSAVER_LAUNCH_AGENT) >= 0);
if (gIsCatalina) {
// Under OS 10.15, isPreview is often true even when it shouldn't be
// so we use this hack instead
myIsPreview = (frame.size.width < 500.) || (frame.size.height < 500.);
} else {
myIsPreview = isPreview;
}
// OpenGL apps built under Xcode 11 under Catalina apparently use window
// dimensions based on the number of backing store pixels. That is, they
// double the window dimensiona for Retina displays (which have two pixels
// per point.) But OpenGL apps built under earlier versions of Xcode don't.
// Catalina assumes OpenGL apps work as built under Xcode 11, so it displays
// older builds at half width and height, unless we compensate in our code.
// This code is part of my attempt to ensure that BOINC graphics apps built on
// all versions of Xcode work proprly on different versions of OS X. See also
// MacPassOffscreenBufferToScreenSaver() in lib/magglutfix.m for more info.
//
if (gIsCatalina) {
NSArray *allScreens = [ NSScreen screens ];
DPI_multiplier = [((NSScreen*)allScreens[0]) backingScaleFactor];
}
return self;
}
......@@ -311,7 +343,7 @@ void launchedGfxApp(char * appPath, pid_t thePID, int slot) {
[ super startAnimation ];
if ( [ self isPreview ] ) {
if (myIsPreview) {
[ self setAnimationTimeInterval:1.0/8.0 ];
return;
}
......@@ -346,7 +378,7 @@ void launchedGfxApp(char * appPath, pid_t thePID, int slot) {
- (void)stopAnimation {
[ super stopAnimation ];
if ([ self isPreview ]) return;
if (myIsPreview) return;
#if ! DEBUG_UNDER_XCODE
NSRect windowFrame = [ [ self window ] frame ];
if ( (windowFrame.origin.x != 0) || (windowFrame.origin.y != 0) ) {
......@@ -359,8 +391,7 @@ void launchedGfxApp(char * appPath, pid_t thePID, int slot) {
[imageView removeFromSuperview]; // Releases imageView
imageView = nil;
}
if ( ! [ self isPreview ] ) {
if (!myIsPreview) {
closeBOINCSaver();
}
......@@ -380,7 +411,7 @@ void launchedGfxApp(char * appPath, pid_t thePID, int slot) {
// against any problems that may cause.
- (void)drawRect:(NSRect)rect {
// optionally draw here
if (mojave) {
if (gIsMojave) {
[self doPeriodicTasks];
} else {
[ super drawRect:rect ];
......@@ -395,6 +426,7 @@ void launchedGfxApp(char * appPath, pid_t thePID, int slot) {
int coveredFreq = 0;
NSRect theFrame = [ self frame ];
NSUInteger n;
double maxWaitTime;
NSRect currentDrawingRect, eraseRect;
NSPoint imagePosition;
char *msg;
......@@ -402,7 +434,7 @@ void launchedGfxApp(char * appPath, pid_t thePID, int slot) {
double timeToBlock, frameStartTime = getDTime();
HIThemeTextInfo textInfo;
if ([ self isPreview ]) {
if (myIsPreview) {
#if 1 // Currently drawRect just draws our logo in the preview window
if (gPreview_Image == NULL) {
NSString *fileName = [[ NSBundle bundleForClass:[ self class ]] pathForImageResource:@"boinc" ];
......@@ -429,7 +461,7 @@ void launchedGfxApp(char * appPath, pid_t thePID, int slot) {
// For unkown reasons, OS 10.7 Lion screensaver and later delay several seconds
// after user activity before calling stopAnimation, so we check user activity here
if ((compareOSVersionTo(10, 7) >= 0) && ((getDTime() - gSS_StartTime) > 2.0)) {
if (! mojave) {
if (! gIsMojave) {
double idleTime = CGEventSourceSecondsSinceLastEventType
(kCGEventSourceStateCombinedSessionState, kCGAnyInputEventType);
if (idleTime < 1.5) {
......@@ -499,6 +531,11 @@ void launchedGfxApp(char * appPath, pid_t thePID, int slot) {
pthread_mutex_lock(&saver_mutex);
// terminate_v6_screensaver may have removed imageView via launchedGfxApp("", 0, -1)
//
// CGWindowListCopyWindowInfo and CGWindowListCreateImage can copy
// windows between user boinc_project and the user running the
// screensaver only if OS < 10.15 (before Catalina)
//
if (imageView) {
CGImageRef windowImage = CGWindowListCreateImage(CGRectNull,
kCGWindowListOptionIncludingWindow,
......@@ -552,17 +589,25 @@ void launchedGfxApp(char * appPath, pid_t thePID, int slot) {
// the graphics app is not compatible with OS 10.13+ and kill it.
//
// taskSlot<0 if no worker app is running, so launching default graphics
if (gfxAppStartTime && (taskSlot >= 0)) {
if ((getDTime() - gfxAppStartTime)> MAXWAITFORCONNECTION) {
if (++CGWindowListTries > MAX_CGWINDOWLIST_TRIES) {
// After displaying message for 5 seconds, incompatibleGfxApp
// will call launchedGfxApp("", 0, -1) which will clear
// gfxAppStartTime and CGWindowListTries
if (gfxAppStartTime && (taskSlot >= 0)) {
maxWaitTime = gIsCatalina ? MAXWAITFORCONNECTIONCATALINA : MAXWAITFORCONNECTION;
if ((getDTime() - gfxAppStartTime) > maxWaitTime) {
if (gIsCatalina) {
// CGWindowListCopyWindowInfo and CGWindowListCreateImage can copy
// windows between user boinc_project and the user running the
// screensaver only if OS < 10.15 (before Catalina)
incompatibleGfxApp(gfxAppPath, childPid, taskSlot);
} else {
if ([self setUpToUseCGWindowList]) {
CGWindowListTries = 0;
gfxAppStartTime = 0.0;
if (++CGWindowListTries > MAX_CGWINDOWLIST_TRIES) {
// After displaying message for 5 seconds, incompatibleGfxApp