Add permission setup flow
This commit is contained in:
parent
552a6bebcd
commit
d2ab6570ea
@ -268,7 +268,6 @@ export class ApiKeyHeader extends LitElement {
|
||||
this.handleAnimationEnd = this.handleAnimationEnd.bind(this);
|
||||
this.handleUsePicklesKey = this.handleUsePicklesKey.bind(this);
|
||||
this.handleProviderChange = this.handleProviderChange.bind(this);
|
||||
this.checkAndRequestPermissions = this.checkAndRequestPermissions.bind(this);
|
||||
}
|
||||
|
||||
reset() {
|
||||
@ -407,18 +406,10 @@ export class ApiKeyHeader extends LitElement {
|
||||
const isValid = await this.validateApiKey(this.apiKey.trim(), this.selectedProvider);
|
||||
|
||||
if (isValid) {
|
||||
console.log('API key valid – checking system permissions…');
|
||||
const permissionResult = await this.checkAndRequestPermissions();
|
||||
|
||||
if (permissionResult.success) {
|
||||
console.log('All permissions granted – starting slide-out animation');
|
||||
console.log('API key valid - starting slide out animation');
|
||||
this.startSlideOutAnimation();
|
||||
this.validatedApiKey = this.apiKey.trim();
|
||||
this.validatedProvider = this.selectedProvider;
|
||||
} else {
|
||||
this.errorMessage = permissionResult.error || 'Permission setup required';
|
||||
console.log('Permission setup incomplete:', permissionResult);
|
||||
}
|
||||
this.validatedProvider = this.selectedProvider;
|
||||
} else {
|
||||
this.errorMessage = 'Invalid API key - please check and try again';
|
||||
console.log('API key validation failed');
|
||||
@ -497,45 +488,6 @@ export class ApiKeyHeader extends LitElement {
|
||||
return false;
|
||||
}
|
||||
|
||||
async checkAndRequestPermissions() {
|
||||
if (!window.require) return { success: true };
|
||||
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
try {
|
||||
const permissions = await ipcRenderer.invoke('check-system-permissions');
|
||||
console.log('[Permissions] Current status:', permissions);
|
||||
|
||||
if (!permissions.needsSetup) return { success: true };
|
||||
|
||||
if (!permissions.microphone) {
|
||||
console.log('[Permissions] Requesting microphone permission…');
|
||||
const micResult = await ipcRenderer.invoke('request-microphone-permission');
|
||||
if (!micResult.success) {
|
||||
await ipcRenderer.invoke('open-system-preferences', 'microphone');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Please grant microphone access in System Preferences',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!permissions.screen) {
|
||||
console.log('[Permissions] Screen-recording permission needed');
|
||||
await ipcRenderer.invoke('open-system-preferences', 'screen-recording');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Please grant screen recording access in System Preferences',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('[Permissions] Error checking/requesting permissions:', err);
|
||||
return { success: false, error: 'Failed to check permissions' };
|
||||
}
|
||||
}
|
||||
|
||||
startSlideOutAnimation() {
|
||||
this.classList.add('sliding-out');
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import { getAuth, onAuthStateChanged, GoogleAuthProvider, signInWithCredential,
|
||||
|
||||
import './AppHeader.js';
|
||||
import './ApiKeyHeader.js';
|
||||
import './PermissionSetup.js';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: 'AIzaSyAgtJrmsFWG1C7m9S55HyT1laICEzuUS2g',
|
||||
@ -21,31 +22,40 @@ class HeaderTransitionManager {
|
||||
constructor() {
|
||||
|
||||
this.headerContainer = document.getElementById('header-container');
|
||||
this.currentHeaderType = null; // 'apikey' | 'app'
|
||||
this.currentHeaderType = null; // 'apikey' | 'app' | 'permission'
|
||||
this.apiKeyHeader = null;
|
||||
this.appHeader = null;
|
||||
this.permissionSetup = null;
|
||||
|
||||
/**
|
||||
* only one header window is allowed
|
||||
* @param {'apikey'|'app'} type
|
||||
* @param {'apikey'|'app'|'permission'} type
|
||||
*/
|
||||
this.ensureHeader = (type) => {
|
||||
if (this.currentHeaderType === type) return;
|
||||
|
||||
if (this.apiKeyHeader) { this.apiKeyHeader.remove(); this.apiKeyHeader = null; }
|
||||
if (this.appHeader) { this.appHeader.remove(); this.appHeader = null; }
|
||||
this.headerContainer.innerHTML = '';
|
||||
|
||||
this.apiKeyHeader = null;
|
||||
this.appHeader = null;
|
||||
this.permissionSetup = null;
|
||||
|
||||
// Create new header element
|
||||
if (type === 'apikey') {
|
||||
this.apiKeyHeader = document.createElement('apikey-header');
|
||||
this.apiKeyHeader = document.createElement('apikey-header');
|
||||
this.headerContainer.appendChild(this.apiKeyHeader);
|
||||
} else if (type === 'permission') {
|
||||
this.permissionSetup = document.createElement('permission-setup');
|
||||
this.permissionSetup.continueCallback = () => this.transitionToAppHeader();
|
||||
this.headerContainer.appendChild(this.permissionSetup);
|
||||
} else {
|
||||
this.appHeader = document.createElement('app-header');
|
||||
this.appHeader = document.createElement('app-header');
|
||||
this.headerContainer.appendChild(this.appHeader);
|
||||
this.appHeader.startSlideInAnimation?.();
|
||||
}
|
||||
|
||||
this.currentHeaderType = type;
|
||||
this.notifyHeaderState(type);
|
||||
this.notifyHeaderState(type === 'permission' ? 'apikey' : type); // Keep permission state as apikey for compatibility
|
||||
};
|
||||
|
||||
console.log('[HeaderController] Manager initialized');
|
||||
@ -80,32 +90,14 @@ class HeaderTransitionManager {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.warn('[HeaderController] Login payload indicates verification failure. Proceeding to AppHeader UI only.');
|
||||
// Check permissions before transitioning
|
||||
const permissionResult = await this.checkPermissions();
|
||||
if (permissionResult.success) {
|
||||
this.transitionToAppHeader();
|
||||
} else {
|
||||
console.log('[HeaderController] Permissions not granted after login error');
|
||||
if (this.apiKeyHeader) {
|
||||
this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required';
|
||||
this.apiKeyHeader.requestUpdate();
|
||||
}
|
||||
}
|
||||
console.warn('[HeaderController] Login payload indicates verification failure. Showing permission setup.');
|
||||
// Show permission setup after login error
|
||||
this.transitionToPermissionSetup();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HeaderController] Sign-in failed', error);
|
||||
// Check permissions before transitioning
|
||||
const permissionResult = await this.checkPermissions();
|
||||
if (permissionResult.success) {
|
||||
this.transitionToAppHeader();
|
||||
} else {
|
||||
console.log('[HeaderController] Permissions not granted after sign-in failure');
|
||||
if (this.apiKeyHeader) {
|
||||
this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required';
|
||||
this.apiKeyHeader.requestUpdate();
|
||||
}
|
||||
}
|
||||
// Show permission setup after sign-in failure
|
||||
this.transitionToPermissionSetup();
|
||||
}
|
||||
});
|
||||
|
||||
@ -122,7 +114,10 @@ class HeaderTransitionManager {
|
||||
|
||||
ipcRenderer.on('api-key-validated', () => {
|
||||
this.hasApiKey = true;
|
||||
this.transitionToAppHeader();
|
||||
// Wait for animation to complete before transitioning
|
||||
setTimeout(() => {
|
||||
this.transitionToPermissionSetup();
|
||||
}, 350); // Give time for slide-out animation to complete
|
||||
});
|
||||
|
||||
ipcRenderer.on('api-key-removed', () => {
|
||||
@ -133,7 +128,7 @@ class HeaderTransitionManager {
|
||||
ipcRenderer.on('api-key-updated', () => {
|
||||
this.hasApiKey = true;
|
||||
if (!auth.currentUser) {
|
||||
this.transitionToAppHeader();
|
||||
this.transitionToPermissionSetup();
|
||||
}
|
||||
});
|
||||
|
||||
@ -145,22 +140,13 @@ class HeaderTransitionManager {
|
||||
await signInWithCredential(auth, credential);
|
||||
console.log('[HeaderController] Firebase sign-in successful via ID token');
|
||||
} else {
|
||||
console.warn('[HeaderController] No ID token received from deeplink, virtual key request may fail');
|
||||
// Check permissions before transitioning
|
||||
const permissionResult = await this.checkPermissions();
|
||||
if (permissionResult.success) {
|
||||
this.transitionToAppHeader();
|
||||
} else {
|
||||
console.log('[HeaderController] Permissions not granted after Firebase auth');
|
||||
if (this.apiKeyHeader) {
|
||||
this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required';
|
||||
this.apiKeyHeader.requestUpdate();
|
||||
}
|
||||
}
|
||||
console.warn('[HeaderController] No ID token received from deeplink, showing permission setup');
|
||||
// Show permission setup after Firebase auth
|
||||
this.transitionToPermissionSetup();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HeaderController] Firebase auth failed:', error);
|
||||
this.transitionToAppHeader();
|
||||
this.transitionToPermissionSetup();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -201,28 +187,18 @@ class HeaderTransitionManager {
|
||||
|
||||
if (!this.isInitialized) {
|
||||
this.isInitialized = true;
|
||||
return; // Skip on initial load - bootstrap handles it
|
||||
}
|
||||
|
||||
// Only handle state changes after initial load
|
||||
if (user) {
|
||||
console.log('[HeaderController] User is logged in, checking permissions...');
|
||||
const permissionResult = await this.checkPermissions();
|
||||
if (permissionResult.success) {
|
||||
this.transitionToAppHeader(!this.hasApiKey);
|
||||
} else {
|
||||
console.log('[HeaderController] Permissions not granted, staying on ApiKeyHeader');
|
||||
if (this.apiKeyHeader) {
|
||||
this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required';
|
||||
this.apiKeyHeader.requestUpdate();
|
||||
}
|
||||
}
|
||||
console.log('[HeaderController] User logged in, updating hasApiKey and checking permissions...');
|
||||
this.hasApiKey = true; // User login should provide API key
|
||||
// Delay permission check to ensure smooth login flow
|
||||
setTimeout(() => this.transitionToPermissionSetup(), 500);
|
||||
} else if (this.hasApiKey) {
|
||||
console.log('[HeaderController] No Firebase user but API key exists, checking permissions...');
|
||||
const permissionResult = await this.checkPermissions();
|
||||
if (permissionResult.success) {
|
||||
this.transitionToAppHeader(false);
|
||||
} else {
|
||||
console.log('[HeaderController] Permissions not granted, staying on ApiKeyHeader');
|
||||
}
|
||||
console.log('[HeaderController] No Firebase user but API key exists, checking if permission setup is needed...');
|
||||
setTimeout(() => this.transitionToPermissionSetup(), 500);
|
||||
} else {
|
||||
console.log('[HeaderController] No auth & no API key — showing ApiKeyHeader');
|
||||
this.transitionToApiKeyHeader();
|
||||
@ -255,29 +231,60 @@ class HeaderTransitionManager {
|
||||
});
|
||||
});
|
||||
|
||||
if (user || this.hasApiKey) {
|
||||
// check flow order: API key -> Permissions -> App
|
||||
if (!user && !this.hasApiKey) {
|
||||
// No auth and no API key -> show API key input
|
||||
await this._resizeForApiKey();
|
||||
this.ensureHeader('apikey');
|
||||
} else {
|
||||
// Has API key or user -> check permissions first
|
||||
const permissionResult = await this.checkPermissions();
|
||||
|
||||
if (permissionResult.success) {
|
||||
// All permissions granted -> go to app
|
||||
await this._resizeForApp();
|
||||
this.ensureHeader('app');
|
||||
} else {
|
||||
await this._resizeForApiKey();
|
||||
this.ensureHeader('apikey');
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.apiKeyHeader) {
|
||||
this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required';
|
||||
this.apiKeyHeader.requestUpdate();
|
||||
}
|
||||
}, 100);
|
||||
// Permissions needed -> show permission setup
|
||||
await this._resizeForPermissionSetup();
|
||||
this.ensureHeader('permission');
|
||||
}
|
||||
} else {
|
||||
await this._resizeForApiKey();
|
||||
this.ensureHeader('apikey');
|
||||
}
|
||||
}
|
||||
|
||||
async transitionToPermissionSetup() {
|
||||
// Prevent duplicate transitions
|
||||
if (this.currentHeaderType === 'permission') {
|
||||
console.log('[HeaderController] Already showing permission setup, skipping transition');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if permissions were previously completed
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
try {
|
||||
const permissionsCompleted = await ipcRenderer.invoke('check-permissions-completed');
|
||||
if (permissionsCompleted) {
|
||||
console.log('[HeaderController] Permissions were previously completed, checking current status...');
|
||||
|
||||
// Double check current permission status
|
||||
const permissionResult = await this.checkPermissions();
|
||||
if (permissionResult.success) {
|
||||
// Skip permission setup if already granted
|
||||
this.transitionToAppHeader();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[HeaderController] Permissions were revoked, showing setup again');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HeaderController] Error checking permissions completed status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await this._resizeForPermissionSetup();
|
||||
this.ensureHeader('permission');
|
||||
}
|
||||
|
||||
async transitionToAppHeader(animate = true) {
|
||||
if (this.currentHeaderType === 'app') {
|
||||
return this._resizeForApp();
|
||||
@ -285,11 +292,10 @@ class HeaderTransitionManager {
|
||||
|
||||
const canAnimate =
|
||||
animate &&
|
||||
this.apiKeyHeader &&
|
||||
!this.apiKeyHeader.classList.contains('hidden') &&
|
||||
typeof this.apiKeyHeader.startSlideOutAnimation === 'function';
|
||||
(this.apiKeyHeader || this.permissionSetup) &&
|
||||
this.currentHeaderType !== 'app';
|
||||
|
||||
if (canAnimate) {
|
||||
if (canAnimate && this.apiKeyHeader?.startSlideOutAnimation) {
|
||||
const old = this.apiKeyHeader;
|
||||
const onEnd = () => {
|
||||
clearTimeout(fallback);
|
||||
@ -321,6 +327,14 @@ class HeaderTransitionManager {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async _resizeForPermissionSetup() {
|
||||
if (!window.require) return;
|
||||
return window
|
||||
.require('electron')
|
||||
.ipcRenderer.invoke('resize-header-window', { width: 285, height: 220 })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async transitionToApiKeyHeader() {
|
||||
await this._resizeForApiKey();
|
||||
|
||||
@ -351,10 +365,6 @@ class HeaderTransitionManager {
|
||||
let errorMessage = '';
|
||||
if (!permissions.microphone && !permissions.screen) {
|
||||
errorMessage = 'Microphone and screen recording access required';
|
||||
} else if (!permissions.microphone) {
|
||||
errorMessage = 'Microphone access required';
|
||||
} else if (!permissions.screen) {
|
||||
errorMessage = 'Screen recording access required';
|
||||
}
|
||||
|
||||
return {
|
||||
|
533
src/app/PermissionSetup.js
Normal file
533
src/app/PermissionSetup.js
Normal file
@ -0,0 +1,533 @@
|
||||
import { LitElement, html, css } from '../assets/lit-core-2.7.4.min.js';
|
||||
|
||||
export class PermissionSetup extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
transition: opacity 0.25s ease-out;
|
||||
}
|
||||
|
||||
:host(.sliding-out) {
|
||||
animation: slideOutUp 0.3s ease-in forwards;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
:host(.hidden) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes slideOutUp {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 285px;
|
||||
height: 220px;
|
||||
padding: 18px 20px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 16px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(169deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%, rgba(255, 255, 255, 0.5) 100%);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: destination-out;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
z-index: 10;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.close-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.permission-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.permission-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.permission-item.granted {
|
||||
color: rgba(34, 197, 94, 0.9);
|
||||
}
|
||||
|
||||
.permission-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: rgba(34, 197, 94, 0.9);
|
||||
}
|
||||
|
||||
.action-button {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.action-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 10px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(169deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%, rgba(255, 255, 255, 0.5) 100%);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: destination-out;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.action-button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.continue-button {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
background: rgba(34, 197, 94, 0.8);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.continue-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 10px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(169deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%, rgba(255, 255, 255, 0.5) 100%);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: destination-out;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.continue-button:hover:not(:disabled) {
|
||||
background: rgba(34, 197, 94, 0.9);
|
||||
}
|
||||
|
||||
.continue-button:disabled {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
static properties = {
|
||||
microphoneGranted: { type: String },
|
||||
screenGranted: { type: String },
|
||||
isChecking: { type: String },
|
||||
continueCallback: { type: Function }
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.microphoneGranted = 'unknown';
|
||||
this.screenGranted = 'unknown';
|
||||
this.isChecking = false;
|
||||
this.continueCallback = null;
|
||||
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
this.handleMouseUp = this.handleMouseUp.bind(this);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.checkPermissions();
|
||||
|
||||
// Set up periodic permission check
|
||||
this.permissionCheckInterval = setInterval(() => {
|
||||
this.checkPermissions();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.permissionCheckInterval) {
|
||||
clearInterval(this.permissionCheckInterval);
|
||||
}
|
||||
}
|
||||
|
||||
async handleMouseDown(e) {
|
||||
if (e.target.tagName === 'BUTTON') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
const initialPosition = await ipcRenderer.invoke('get-header-position');
|
||||
|
||||
this.dragState = {
|
||||
initialMouseX: e.screenX,
|
||||
initialMouseY: e.screenY,
|
||||
initialWindowX: initialPosition.x,
|
||||
initialWindowY: initialPosition.y,
|
||||
moved: false,
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', this.handleMouseMove);
|
||||
window.addEventListener('mouseup', this.handleMouseUp, { once: true });
|
||||
}
|
||||
|
||||
handleMouseMove(e) {
|
||||
if (!this.dragState) return;
|
||||
|
||||
const deltaX = Math.abs(e.screenX - this.dragState.initialMouseX);
|
||||
const deltaY = Math.abs(e.screenY - this.dragState.initialMouseY);
|
||||
|
||||
if (deltaX > 3 || deltaY > 3) {
|
||||
this.dragState.moved = true;
|
||||
}
|
||||
|
||||
const newWindowX = this.dragState.initialWindowX + (e.screenX - this.dragState.initialMouseX);
|
||||
const newWindowY = this.dragState.initialWindowY + (e.screenY - this.dragState.initialMouseY);
|
||||
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
ipcRenderer.invoke('move-header-to', newWindowX, newWindowY);
|
||||
}
|
||||
|
||||
handleMouseUp(e) {
|
||||
if (!this.dragState) return;
|
||||
|
||||
const wasDragged = this.dragState.moved;
|
||||
|
||||
window.removeEventListener('mousemove', this.handleMouseMove);
|
||||
this.dragState = null;
|
||||
|
||||
if (wasDragged) {
|
||||
this.wasJustDragged = true;
|
||||
setTimeout(() => {
|
||||
this.wasJustDragged = false;
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
async checkPermissions() {
|
||||
if (!window.require || this.isChecking) return;
|
||||
|
||||
this.isChecking = true;
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
try {
|
||||
const permissions = await ipcRenderer.invoke('check-system-permissions');
|
||||
console.log('[PermissionSetup] Permission check result:', permissions);
|
||||
|
||||
const prevMic = this.microphoneGranted;
|
||||
const prevScreen = this.screenGranted;
|
||||
|
||||
this.microphoneGranted = permissions.microphone;
|
||||
this.screenGranted = permissions.screen;
|
||||
|
||||
// if permissions changed == UI update
|
||||
if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted) {
|
||||
console.log('[PermissionSetup] Permission status changed, updating UI');
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// if all permissions granted == automatically continue
|
||||
if (this.microphoneGranted === 'granted' &&
|
||||
this.screenGranted === 'granted' &&
|
||||
this.continueCallback) {
|
||||
console.log('[PermissionSetup] All permissions granted, proceeding automatically');
|
||||
setTimeout(() => this.handleContinue(), 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PermissionSetup] Error checking permissions:', error);
|
||||
} finally {
|
||||
this.isChecking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleMicrophoneClick() {
|
||||
if (!window.require || this.microphoneGranted === 'granted' || this.wasJustDragged) return;
|
||||
|
||||
console.log('[PermissionSetup] Requesting microphone permission...');
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
try {
|
||||
const result = await ipcRenderer.invoke('check-system-permissions');
|
||||
console.log('[PermissionSetup] Microphone permission result:', result);
|
||||
|
||||
if (result.microphone === 'granted') {
|
||||
this.microphoneGranted = 'granted';
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.microphone === 'not-determined' || result.microphone === 'denied' || result.microphone === 'unknown' || result.microphone === 'restricted') {
|
||||
const res = await ipcRenderer.invoke('request-microphone-permission');
|
||||
if (res.status === 'granted' || res.success === true) {
|
||||
this.microphoneGranted = 'granted';
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check permissions again after a delay
|
||||
// setTimeout(() => this.checkPermissions(), 1000);
|
||||
} catch (error) {
|
||||
console.error('[PermissionSetup] Error requesting microphone permission:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleScreenClick() {
|
||||
if (!window.require || this.screenGranted === 'granted' || this.wasJustDragged) return;
|
||||
|
||||
console.log('[PermissionSetup] Checking screen recording permission...');
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
try {
|
||||
const permissions = await ipcRenderer.invoke('check-system-permissions');
|
||||
console.log('[PermissionSetup] Screen permission check result:', permissions);
|
||||
|
||||
if (permissions.screen === 'granted') {
|
||||
this.screenGranted = 'granted';
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (permissions.screen === 'not-determined' || permissions.screen === 'denied' || permissions.screen === 'unknown' || permissions.screen === 'restricted') {
|
||||
console.log('[PermissionSetup] Opening screen recording preferences...');
|
||||
await ipcRenderer.invoke('open-system-preferences', 'screen-recording');
|
||||
}
|
||||
|
||||
// Check permissions again after a delay
|
||||
// (This may not execute if app restarts after permission grant)
|
||||
// setTimeout(() => this.checkPermissions(), 2000);
|
||||
} catch (error) {
|
||||
console.error('[PermissionSetup] Error opening screen recording preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleContinue() {
|
||||
if (this.continueCallback &&
|
||||
this.microphoneGranted === 'granted' &&
|
||||
this.screenGranted === 'granted' &&
|
||||
!this.wasJustDragged) {
|
||||
// Mark permissions as completed
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
try {
|
||||
await ipcRenderer.invoke('mark-permissions-completed');
|
||||
console.log('[PermissionSetup] Marked permissions as completed');
|
||||
} catch (error) {
|
||||
console.error('[PermissionSetup] Error marking permissions as completed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.continueCallback();
|
||||
}
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
console.log('Close button clicked');
|
||||
if (window.require) {
|
||||
window.require('electron').ipcRenderer.invoke('quit-application');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted';
|
||||
|
||||
return html`
|
||||
<div class="container" @mousedown=${this.handleMouseDown}>
|
||||
<button class="close-button" @click=${this.handleClose} title="Close application">
|
||||
<svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor">
|
||||
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.2" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="title">Permission Setup Required</h1>
|
||||
|
||||
<div class="form-content">
|
||||
<div class="subtitle">Grant access to microphone and screen recording to continue</div>
|
||||
|
||||
<div class="permission-status">
|
||||
<div class="permission-item ${this.microphoneGranted === 'granted' ? 'granted' : ''}">
|
||||
${this.microphoneGranted === 'granted' ? html`
|
||||
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Microphone ✓</span>
|
||||
` : html`
|
||||
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Microphone</span>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="permission-item ${this.screenGranted === 'granted' ? 'granted' : ''}">
|
||||
${this.screenGranted === 'granted' ? html`
|
||||
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Screen ✓</span>
|
||||
` : html`
|
||||
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Screen Recording</span>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.microphoneGranted !== 'granted' ? html`
|
||||
<button
|
||||
class="action-button"
|
||||
@click=${this.handleMicrophoneClick}
|
||||
>
|
||||
Grant Microphone Access
|
||||
</button>
|
||||
` : ''}
|
||||
|
||||
${this.screenGranted !== 'granted' ? html`
|
||||
<button
|
||||
class="action-button"
|
||||
@click=${this.handleScreenClick}
|
||||
>
|
||||
Grant Screen Recording Access
|
||||
</button>
|
||||
` : ''}
|
||||
|
||||
${allGranted ? html`
|
||||
<button
|
||||
class="continue-button"
|
||||
@click=${this.handleContinue}
|
||||
>
|
||||
Continue to Pickle Glass
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('permission-setup', PermissionSetup);
|
@ -433,6 +433,34 @@ class SQLiteClient {
|
||||
this.db = null;
|
||||
}
|
||||
}
|
||||
|
||||
async query(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.db) {
|
||||
return reject(new Error('Database not connected'));
|
||||
}
|
||||
|
||||
if (sql.toUpperCase().startsWith('SELECT')) {
|
||||
this.db.all(sql, params, (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Query error:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(rows);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.db.run(sql, params, function(err) {
|
||||
if (err) {
|
||||
console.error('Query error:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ changes: this.changes, lastID: this.lastID });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sqliteClient = new SQLiteClient();
|
||||
|
@ -1845,32 +1845,27 @@ function setupIpcHandlers(openaiSessionRef) {
|
||||
ipcMain.handle('check-system-permissions', async () => {
|
||||
const { systemPreferences } = require('electron');
|
||||
const permissions = {
|
||||
microphone: false,
|
||||
screen: false,
|
||||
needsSetup: false
|
||||
microphone: 'unknown',
|
||||
screen: 'unknown',
|
||||
needsSetup: true
|
||||
};
|
||||
|
||||
try {
|
||||
if (process.platform === 'darwin') {
|
||||
// Check microphone permission on macOS
|
||||
const micStatus = systemPreferences.getMediaAccessStatus('microphone');
|
||||
permissions.microphone = micStatus === 'granted';
|
||||
console.log('[Permissions] Microphone status:', micStatus);
|
||||
permissions.microphone = micStatus;
|
||||
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['screen'],
|
||||
thumbnailSize: { width: 1, height: 1 }
|
||||
});
|
||||
permissions.screen = sources && sources.length > 0;
|
||||
} catch (err) {
|
||||
console.log('[Permissions] Screen capture test failed:', err);
|
||||
permissions.screen = false;
|
||||
}
|
||||
// Check screen recording permission using the system API
|
||||
const screenStatus = systemPreferences.getMediaAccessStatus('screen');
|
||||
console.log('[Permissions] Screen status:', screenStatus);
|
||||
permissions.screen = screenStatus;
|
||||
|
||||
permissions.needsSetup = !permissions.microphone || !permissions.screen;
|
||||
permissions.needsSetup = micStatus !== 'granted' || screenStatus !== 'granted';
|
||||
} else {
|
||||
permissions.microphone = true;
|
||||
permissions.screen = true;
|
||||
permissions.microphone = 'granted';
|
||||
permissions.screen = 'granted';
|
||||
permissions.needsSetup = false;
|
||||
}
|
||||
|
||||
@ -1879,8 +1874,8 @@ function setupIpcHandlers(openaiSessionRef) {
|
||||
} catch (error) {
|
||||
console.error('[Permissions] Error checking permissions:', error);
|
||||
return {
|
||||
microphone: false,
|
||||
screen: false,
|
||||
microphone: 'unknown',
|
||||
screen: 'unknown',
|
||||
needsSetup: true,
|
||||
error: error.message
|
||||
};
|
||||
@ -1895,15 +1890,16 @@ function setupIpcHandlers(openaiSessionRef) {
|
||||
const { systemPreferences } = require('electron');
|
||||
try {
|
||||
const status = systemPreferences.getMediaAccessStatus('microphone');
|
||||
console.log('[Permissions] Microphone status:', status);
|
||||
if (status === 'granted') {
|
||||
return { success: true, status: 'already-granted' };
|
||||
return { success: true, status: 'granted' };
|
||||
}
|
||||
|
||||
// Req mic permission
|
||||
const granted = await systemPreferences.askForMediaAccess('microphone');
|
||||
return {
|
||||
success: granted,
|
||||
status: granted ? 'granted' : 'denied'
|
||||
success: granted,
|
||||
status: granted ? 'granted' : 'denied'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Permissions] Error requesting microphone permission:', error);
|
||||
@ -1920,20 +1916,61 @@ function setupIpcHandlers(openaiSessionRef) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Open System Preferences to Privacy & Security > Screen Recording
|
||||
if (section === 'screen-recording') {
|
||||
await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture');
|
||||
} else if (section === 'microphone') {
|
||||
await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone');
|
||||
} else {
|
||||
await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy');
|
||||
// First trigger screen capture request to register the app in system preferences
|
||||
try {
|
||||
console.log('[Permissions] Triggering screen capture request to register app...');
|
||||
await desktopCapturer.getSources({
|
||||
types: ['screen'],
|
||||
thumbnailSize: { width: 1, height: 1 }
|
||||
});
|
||||
console.log('[Permissions] App registered for screen recording');
|
||||
} catch (captureError) {
|
||||
console.log('[Permissions] Screen capture request triggered (expected to fail):', captureError.message);
|
||||
}
|
||||
|
||||
// Then open system preferences
|
||||
// await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture');
|
||||
}
|
||||
// if (section === 'microphone') {
|
||||
// await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone');
|
||||
// }
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Permissions] Error opening system preferences:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('mark-permissions-completed', async () => {
|
||||
try {
|
||||
// Store in SQLite that permissions have been completed
|
||||
await sqliteClient.query(
|
||||
'INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, ?)',
|
||||
['permissions_completed', 'true']
|
||||
);
|
||||
console.log('[Permissions] Marked permissions as completed');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Permissions] Error marking permissions as completed:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('check-permissions-completed', async () => {
|
||||
try {
|
||||
const result = await sqliteClient.query(
|
||||
'SELECT value FROM system_settings WHERE key = ?',
|
||||
['permissions_completed']
|
||||
);
|
||||
const completed = result.length > 0 && result[0].value === 'true';
|
||||
console.log('[Permissions] Permissions completed status:', completed);
|
||||
return completed;
|
||||
} catch (error) {
|
||||
console.error('[Permissions] Error checking permissions completed status:', error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
@ -21,6 +21,75 @@ export class AskView extends LitElement {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: white;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
transition: transform 0.2s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.2s ease-out;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
:host(.hiding) {
|
||||
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.6, 1) forwards;
|
||||
}
|
||||
|
||||
:host(.showing) {
|
||||
animation: slideDown 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
:host(.hidden) {
|
||||
opacity: 0;
|
||||
transform: translateY(-150%) scale(0.85);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.7;
|
||||
transform: translateY(-20%) scale(0.98);
|
||||
filter: blur(0.5px);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.3;
|
||||
transform: translateY(-80%) scale(0.92);
|
||||
filter: blur(1.5px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-150%) scale(0.85);
|
||||
filter: blur(2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-150%) scale(0.85);
|
||||
filter: blur(2px);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.5;
|
||||
transform: translateY(-50%) scale(0.92);
|
||||
filter: blur(1px);
|
||||
}
|
||||
65% {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-5%) scale(0.99);
|
||||
filter: blur(0.2px);
|
||||
}
|
||||
85% {
|
||||
opacity: 0.98;
|
||||
transform: translateY(2%) scale(1.005);
|
||||
filter: blur(0px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
|
@ -5,6 +5,75 @@ export class AssistantView extends LitElement {
|
||||
:host {
|
||||
display: block;
|
||||
width: 400px;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
transition: transform 0.2s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.2s ease-out;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
:host(.hiding) {
|
||||
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.6, 1) forwards;
|
||||
}
|
||||
|
||||
:host(.showing) {
|
||||
animation: slideDown 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
:host(.hidden) {
|
||||
opacity: 0;
|
||||
transform: translateY(-150%) scale(0.85);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.7;
|
||||
transform: translateY(-20%) scale(0.98);
|
||||
filter: blur(0.5px);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.3;
|
||||
transform: translateY(-80%) scale(0.92);
|
||||
filter: blur(1.5px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-150%) scale(0.85);
|
||||
filter: blur(2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-150%) scale(0.85);
|
||||
filter: blur(2px);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.5;
|
||||
transform: translateY(-50%) scale(0.92);
|
||||
filter: blur(1px);
|
||||
}
|
||||
65% {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-5%) scale(0.99);
|
||||
filter: blur(0.2px);
|
||||
}
|
||||
85% {
|
||||
opacity: 0.98;
|
||||
transform: translateY(2%) scale(1.005);
|
||||
filter: blur(0px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
|
Loading…
x
Reference in New Issue
Block a user