Skip to content

Commit b39e0dc

Browse files
committed
fix(desktop): implement adaptive clipboard strategy for Safari compatibility
1 parent 7bcd0da commit b39e0dc

File tree

8 files changed

+178
-32
lines changed

8 files changed

+178
-32
lines changed

frontend/desktop/public/locales/en/common.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"completed_the_deployment_of_an_nginx_for_the_first_time": "Completed a deployment of nginx for the first time",
9393
"confirm": "Confirm",
9494
"confirm_again": "confirm again",
95+
"copy": "Copy",
9596
"confirmnewpassword": "Confirm New Password",
9697
"contact_info": "Phone Number",
9798
"contact_info_must_be_numeric": "It must be a numeric string",
@@ -299,6 +300,7 @@
299300
"recharge_amount": "Recharge Amount",
300301
"redirecting_to_homepage_in_3_seconds": "Redirecting to homepage in 3 seconds",
301302
"refresh_qr_code": "Refresh the QR code",
303+
"regenerate_link": "Regenerate",
302304
"region": "Region",
303305
"reject": "Reject",
304306
"remain_app_tips": "There are still undeleted application resources in your account. Please backup any important data and manually delete all application resources.",

frontend/desktop/public/locales/en/v2.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"confirm_password": "Confirm Password",
1313
"continue": "Continue",
1414
"copy_kubeconfig": "Copy Kubeconfig",
15+
"copy_failed": "Copy failed",
1516
"copy_success": "Copied!",
1617
"create_workspace": "Create Workspace",
1718
"database_create_desc": "Open database app to deploy a database",

frontend/desktop/public/locales/zh/common.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"completed": "完成",
9292
"completed_the_deployment_of_an_nginx_for_the_first_time": "部署一个 nginx ,首次完成 将",
9393
"confirm": "确认",
94+
"copy": "复制",
9495
"confirm_again": "再次确认",
9596
"confirmnewpassword": "确认新密码",
9697
"contact_info": "联系方式 (手机号码)",
@@ -294,6 +295,7 @@
294295
"recharge_amount": "充值金额",
295296
"redirecting_to_homepage_in_3_seconds": "3秒后跳回主页",
296297
"refresh_qr_code": "刷新二维码",
298+
"regenerate_link": "重新生成",
297299
"region": "可用区",
298300
"reject": "拒绝",
299301
"remain_app_tips": "您的账户中仍有未删除的应用资源,为了帮助您顺利完成账户注销流程,请您手动删除所有应用资源,以避免数据丢失",

frontend/desktop/public/locales/zh/v2.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"confirm_password": "确认密码",
1313
"continue": "继续",
1414
"copy_kubeconfig": "复制 Kubeconfig",
15+
"copy_failed": "复制失败",
1516
"copy_success": "复制成功",
1617
"create_workspace": "创建工作空间",
1718
"database_create_desc": "点击应用,进行数据库创建",

frontend/desktop/src/components/team/InviteMember.tsx

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { useTranslation } from 'next-i18next';
3030
import { GroupAddIcon } from '@sealos/ui';
3131
import { useCopyData } from '@/hooks/useCopyData';
3232
import { track } from '@sealos/gtm';
33+
import { needsClipboardWorkaround } from '@/utils/browserDetect';
3334

3435
export default function InviteMember({
3536
ns_uid,
@@ -44,6 +45,8 @@ export default function InviteMember({
4445
const { onOpen, isOpen, onClose } = useDisclosure();
4546
const session = useSessionStore((s) => s.session);
4647
const [role, setRole] = useState(UserRole.Developer);
48+
const [inviteLink, setInviteLink] = useState<string>('');
49+
const [isFallbackMode, setIsFallbackMode] = useState(false);
4750
const toast = useToast();
4851
const queryClient = useQueryClient();
4952
const mutation = useMutation({
@@ -86,16 +89,49 @@ export default function InviteMember({
8689
const generateLink = (code: string) => {
8790
return window.location.origin + encodeURI(`/WorkspaceInvite/?code=${code}`);
8891
};
92+
8993
const handleGenLink: MouseEventHandler<HTMLButtonElement> = async (e) => {
9094
e.preventDefault();
95+
96+
// Get the invitation link first
9197
const data = await getLinkCode.mutateAsync({
9298
ns_uid,
9399
role
94100
});
95101
const code = data.data?.code!;
96102
const link = generateLink(code);
97-
await copyData(link, t('v2:invite_link_copied'));
103+
104+
// Check if browser needs workaround (Safari/iOS)
105+
if (needsClipboardWorkaround()) {
106+
// Safari: Show link and separate copy button
107+
setInviteLink(link);
108+
setIsFallbackMode(true);
109+
return;
110+
}
111+
112+
// Other browsers: Try to copy directly
113+
try {
114+
await copyData(link, t('v2:invite_link_copied'));
115+
setIsFallbackMode(false);
116+
} catch (error) {
117+
// If copy fails, fall back to showing the link
118+
console.warn('Direct copy failed, showing link for manual copy', error);
119+
setInviteLink(link);
120+
setIsFallbackMode(true);
121+
}
122+
};
123+
124+
const handleCopyLink: MouseEventHandler<HTMLButtonElement> = async (e) => {
125+
e.preventDefault();
126+
await copyData(inviteLink, t('v2:invite_link_copied'));
127+
};
128+
129+
const handleClose = () => {
130+
setInviteLink('');
131+
setIsFallbackMode(false);
132+
onClose();
98133
};
134+
99135
return (
100136
<>
101137
{[UserRole.Manager, UserRole.Owner].includes(ownRole) ? (
@@ -112,7 +148,7 @@ export default function InviteMember({
112148
) : (
113149
<></>
114150
)}
115-
<Modal isOpen={isOpen} onClose={onClose} isCentered>
151+
<Modal isOpen={isOpen} onClose={handleClose} isCentered>
116152
<ModalOverlay />
117153
<ModalContent
118154
borderRadius={'4px'}
@@ -164,6 +200,8 @@ export default function InviteMember({
164200
onClick={(e) => {
165201
e.preventDefault();
166202
setRole(idx);
203+
setInviteLink(''); // Clear link when role changes
204+
setIsFallbackMode(false);
167205
}}
168206
key={idx}
169207
>
@@ -187,9 +225,36 @@ export default function InviteMember({
187225
isDisabled={getLinkCode.isLoading}
188226
onClick={handleGenLink}
189227
>
190-
{t('common:generate_invitation_link')}
228+
{inviteLink && isFallbackMode
229+
? t('common:regenerate_link')
230+
: t('common:generate_invitation_link')}
191231
</Button>
192232
</HStack>
233+
{inviteLink && isFallbackMode && (
234+
<Flex
235+
mt="16px"
236+
p="12px"
237+
borderRadius="4px"
238+
border="1px solid #DEE0E2"
239+
bgColor="#FBFBFC"
240+
gap="8px"
241+
alignItems="center"
242+
>
243+
<Text
244+
flex="1"
245+
fontSize="sm"
246+
color="#5A646E"
247+
overflow="hidden"
248+
textOverflow="ellipsis"
249+
whiteSpace="nowrap"
250+
>
251+
{inviteLink}
252+
</Text>
253+
<Button size="sm" variant="outline" onClick={handleCopyLink} flexShrink={0}>
254+
{t('common:copy')}
255+
</Button>
256+
</Flex>
257+
)}
193258
</ModalBody>
194259
)}
195260
</ModalContent>

frontend/desktop/src/hooks/useCopyData.ts

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,70 @@ export const useCopyData = () => {
1010

1111
return {
1212
copyData: async (data: string, title: string = t('v2:copy_success')) => {
13-
try {
14-
if (navigator.clipboard) {
13+
let success = false;
14+
15+
// Try modern Clipboard API first (works in most browsers with user gesture)
16+
if (navigator.clipboard && navigator.clipboard.writeText) {
17+
try {
1518
await navigator.clipboard.writeText(data);
16-
} else {
17-
throw new Error('');
19+
success = true;
20+
} catch (error) {
21+
// Clipboard API failed (likely due to permissions or Safari async context loss)
22+
// Fall through to legacy method
23+
console.warn('Clipboard API failed, using fallback method', error);
1824
}
19-
} catch (error) {
20-
const textarea = document.createElement('textarea');
21-
textarea.value = data;
22-
document.body.appendChild(textarea);
23-
textarea.select();
24-
document.execCommand('copy');
25-
document.body.removeChild(textarea);
2625
}
27-
toast({
28-
position: 'top',
29-
title,
30-
status: 'success',
31-
duration: 1000
32-
});
26+
27+
// Fallback method: works reliably in Safari even after async operations
28+
if (!success) {
29+
try {
30+
const textarea = document.createElement('textarea');
31+
textarea.value = data;
32+
// Position off-screen to avoid visual flash
33+
textarea.style.position = 'fixed';
34+
textarea.style.top = '-9999px';
35+
textarea.style.left = '-9999px';
36+
textarea.setAttribute('readonly', '');
37+
document.body.appendChild(textarea);
38+
39+
// For iOS Safari - use both methods to ensure compatibility
40+
const range = document.createRange();
41+
range.selectNodeContents(textarea);
42+
const selection = window.getSelection();
43+
if (selection) {
44+
selection.removeAllRanges();
45+
selection.addRange(range);
46+
}
47+
textarea.setSelectionRange(0, textarea.value.length);
48+
textarea.select();
49+
50+
const successful = document.execCommand('copy');
51+
document.body.removeChild(textarea);
52+
53+
if (!successful) {
54+
throw new Error('execCommand copy failed');
55+
}
56+
success = true;
57+
} catch (error) {
58+
console.error('Fallback copy method also failed', error);
59+
toast({
60+
position: 'top',
61+
title: t('v2:copy_failed') || 'Copy failed',
62+
status: 'error',
63+
duration: 2000
64+
});
65+
return;
66+
}
67+
}
68+
69+
if (success) {
70+
toast({
71+
position: 'top',
72+
title,
73+
status: 'success',
74+
duration: 1000
75+
});
76+
}
3377
}
3478
};
3579
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Detect if the browser is Safari (including iOS Safari)
3+
*/
4+
export const isSafari = (): boolean => {
5+
if (typeof window === 'undefined') return false;
6+
7+
const ua = window.navigator.userAgent;
8+
const vendor = window.navigator.vendor;
9+
10+
// Check for Safari (but not Chrome, which also contains "Safari" in UA)
11+
const isSafariBrowser =
12+
/Safari/.test(ua) && /Apple Computer/.test(vendor) && !/Chrome/.test(ua) && !/CriOS/.test(ua); // Chrome on iOS
13+
14+
return isSafariBrowser;
15+
};
16+
17+
/**
18+
* Detect if the browser is on iOS (iPhone, iPad, iPod)
19+
*/
20+
export const isIOS = (): boolean => {
21+
if (typeof window === 'undefined') return false;
22+
23+
const ua = window.navigator.userAgent;
24+
25+
return /iPad|iPhone|iPod/.test(ua) && !(window as any).MSStream;
26+
};
27+
28+
/**
29+
* Check if browser likely has issues with clipboard API after async operations
30+
*/
31+
export const needsClipboardWorkaround = (): boolean => {
32+
return isSafari() || isIOS();
33+
};

frontend/providers/applaunchpad/src/pages/api/v2alpha/app/[name]/index.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,7 @@ const SIZE_UNITS = {
3939

4040
// Custom Error Classes
4141
class PortError extends Error {
42-
constructor(
43-
message: string,
44-
public code: number = 500,
45-
public details?: any
46-
) {
42+
constructor(message: string, public code: number = 500, public details?: any) {
4743
super(message);
4844
this.name = 'PortError';
4945
}
@@ -694,7 +690,9 @@ function updateNetworkConfig(existingNetwork: any, portConfig: any, appName: str
694690
}
695691
} else {
696692
throw new PortValidationError(
697-
`Cannot set isPublic for non-application protocol. Current protocol: ${finalAppProtocol || updatedNetwork.protocol}`,
693+
`Cannot set isPublic for non-application protocol. Current protocol: ${
694+
finalAppProtocol || updatedNetwork.protocol
695+
}`,
698696
{
699697
currentAppProtocol: finalAppProtocol,
700698
currentProtocol: updatedNetwork.protocol,
@@ -926,12 +924,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
926924
updateData.image.imageRegistry === null
927925
? null
928926
: updateData.image.imageRegistry
929-
? {
930-
username: updateData.image.imageRegistry.username,
931-
password: updateData.image.imageRegistry.password,
932-
serverAddress: updateData.image.imageRegistry.apiUrl
933-
}
934-
: undefined
927+
? {
928+
username: updateData.image.imageRegistry.username,
929+
password: updateData.image.imageRegistry.password,
930+
serverAddress: updateData.image.imageRegistry.apiUrl
931+
}
932+
: undefined
935933
})
936934
};
937935

0 commit comments

Comments
 (0)